Compare commits

...
215 Commits
Author SHA1 Message Date
Wesley Liddick 3fa08aa930 Merge pull request #3749 from moonfunjohn/codex/easypay-custom-methods
EasyPay custom visible payment methods
2026-07-06 16:06:59 +08:00
Albert Coady 27cb485d55 fix: share built-in payment method matching 2026-07-06 15:00:29 +08:00
Albert Coady 22ec77b570 fix: match built-in payment methods exactly 2026-07-06 15:00:29 +08:00
Albert Coady b197ba61ce test: align antigravity mapping preset label 2026-07-06 15:00:29 +08:00
Albert Coady a5a2fea045 Polish EasyPay custom method UI 2026-07-06 15:00:29 +08:00
Albert Coady 0dc6e56aae fix: harden easypay custom method validation 2026-07-06 15:00:29 +08:00
Albert Coady bf76168ba5 feat: add custom easypay payment methods 2026-07-06 15:00:29 +08:00
Wesley Liddick 2854ab2ace Merge pull request #3744 from Wei-Shaw/fix/subscription-cny-optin-rate
fix(payment): 订阅 CNY 换算改为独立汇率配置的显式 opt-in(含 #3738)
2026-07-06 14:58:20 +08:00
Wesley Liddick e95c909ee5 Merge pull request #3747 from Wei-Shaw/revert-3738-fix/subscription-confirm-amount-and-affiliate-base
Revert "fix(payment): 订阅确认页显示换算后 CNY 金额 + 邀请返利按 USD price 计算"
2026-07-06 14:58:00 +08:00
Wesley Liddick ba1bb0a3d3 Revert "fix(payment): 订阅确认页显示换算后 CNY 金额 + 邀请返利按 USD price 计算" 2026-07-06 14:55:04 +08:00
Wesley Liddick 11fd61458c Merge pull request #3738 from wucm667/fix/subscription-confirm-amount-and-affiliate-base
fix(payment): 订阅确认页显示换算后 CNY 金额 + 邀请返利按 USD price 计算
2026-07-06 14:53:59 +08:00
shaw d56e94b875 feat(payment): 订阅 CNY 换算改为独立汇率配置的显式 opt-in
- 新增 SUBSCRIPTION_USD_TO_CNY_RATE 配置(1 USD = X CNY,默认 0=关闭),
  替代复用 balance_recharge_multiplier 的隐式换算,促销倍率与订阅定价解耦
- 未配置汇率时订阅保持 price 直付的存量行为,存量部署升级零影响
- 前端确认页/原价/手续费/方式限额与后端换算条件严格镜像(rate>0 且币种为 CNY)
- 管理后台新增汇率配置输入(zh/en 文案),checkout-info 透出 subscription_usd_to_cny_rate
- 单测锁定:汇率未配置时不换算、换算使用汇率而非余额倍率、余额订单不受影响、返利仍按 USD price
2026-07-06 14:34:17 +08:00
shaw d089c5789c Merge branch 'pr-3738' into fix/subscription-cny-optin-rate 2026-07-06 14:14:19 +08:00
Wesley Liddick 9fac6f15ce Merge pull request #3739 from Wei-Shaw/fix/openai-advanced-scheduler-audit-fixes
fix(scheduler): 修复 OpenAI 高级调度器审计发现的正确性与性能问题
2026-07-06 13:45:15 +08:00
shaw 0fd2e9216d fix(scheduler): 修复 OpenAI 高级调度器审计发现的正确性与性能问题
针对 #3692 合并后审计发现的问题集中修复:

- previous_response_id 剥离条件改为按 call_id 全覆盖校验,
  部分可重建的工具续链不再被误剥离(不受开关门控的行为回归)
- 粘性加权回退路径补分组归属校验并清理失效绑定,杜绝跨分组账号泄漏
- 账号列表页:无 OpenAI 账号时跳过分数计算、过滤池限定 openai 平台、
  负载批查合并为账号并集一次查询,消除全表扫描与 Redis N+1
- 订阅优先模式下常规池不可用时回退订阅池等待计划,
  busy-but-waitable 的订阅账号不再导致请求硬失败
- TopK/权重 DB 覆盖显式受总开关门控,与兄弟子开关语义一致
- 前端未分组 OpenAI 账号回退展示基础分,不再显示 "-"
- ListAllWithFilters 等能力正式进入 AccountRepository/AdminService 接口,
  移除匿名接口断言与静默降级;负载批查失败补 warn 日志
- SelectAccountWithSchedulerForCapability 增加显式 previousResponseCanMove
  参数,移除 "previous_response_can_move" 魔法字符串哨兵
- 设置写入路径补"基础权重不得全为零"聚合校验;
  运行时设置批量读取失败的降级路径覆盖全部键并留痕
2026-07-06 11:43:16 +08:00
Wesley Liddick 759332aa92 Merge pull request #3692 from linshuboy/codex/openai-advanced-scheduler-controls-squashed
[codex] add OpenAI advanced scheduler controls
2026-07-06 10:59:23 +08:00
wucm667 b408edf97b fix(payment): convert subscription CNY pay amount 2026-07-06 10:56:43 +08:00
Wesley Liddick f0f1f59a44 Merge pull request #3716 from wucm667/fix/antigravity-refresh-server-invalidated-token
fix(antigravity): 401 服务端失效的 token 触发主动刷新,修复 NeedsRefresh 仅看 expires_at 的死循环
2026-07-06 10:18:49 +08:00
Wesley Liddick ac68d73f0c Merge pull request #3717 from wucm667/fix/anthropic-models-honor-group-config
fix(gateway): Anthropic /v1/models 返回分组 models_list_config 自定义模型列表
2026-07-06 10:18:36 +08:00
Wesley Liddick 2e70ec0a15 Merge pull request #3714 from MoRanHuiShou-pug/fix-payment-nul-bytes
fix: sanitize payment response NUL bytes
2026-07-06 10:18:18 +08:00
Wesley Liddick de15f29d1f Merge pull request #3718 from wucm667/fix/usage-csv-export-utf8-bom
fix(usage): CSV 导出补 UTF-8 BOM,修复 Excel 打开中文列乱码
2026-07-06 10:12:27 +08:00
Wesley Liddick 809e1cf8ee Merge pull request #3704 from feitianbubu/feat/sidebar-logo-home-link
feat: 点击侧边栏 Logo/站点名返回首页
2026-07-06 10:12:15 +08:00
Wesley Liddick c9dda3c01f Merge pull request #3734 from Yinr/patch-1
fix(i18n): correct misleading "irreversible" description for revoke action
2026-07-06 10:12:04 +08:00
shaw 6752cdc826 fix(i18n): 用量页费用明细提示将面向用户的"成本"措辞统一为"费用"
用户 /usage 页费用列悬浮提示中的成本明细/输入成本/输出成本/
缓存创建成本/缓存读取成本/图片输出成本 改为对应的"费用"表述;
成本是运营方视角的词,面向用户应称费用。管理端真正的账号成本
词条(accountCost 等)保持不变。
2026-07-06 09:36:21 +08:00
Yeyin Hu ee90246aa5 Modify revokeDesc for subscription termination
Updated revokeDesc to indicate restoration option.
2026-07-06 08:23:43 +08:00
Yeyin Hu 2d2b6f0325 Update revokeDesc to allow subscription recovery 2026-07-06 08:12:09 +08:00
linshuboy f26ca5661e feat: add OpenAI advanced scheduler controls
Related: #1089, #408, #123
2026-07-05 17:24:38 +08:00
wucm667 aee9a7ba98 fix(usage): add UTF-8 BOM to CSV export 2026-07-05 08:36:15 +08:00
wucm667 41cdd438d7 fix(gateway): honor Anthropic custom models list 2026-07-05 08:35:34 +08:00
wucm667 b23475ac0c fix(antigravity): refresh server-invalidated tokens 2026-07-05 08:34:34 +08:00
MoRanHuiShou-pug e76e0499d7 fix: sanitize payment response NUL bytes 2026-07-05 03:55:40 +08:00
shaw 498f010ec3 fix(部署): 统一 Docker 部署 URL 安全默认值为开发友好模式
docker-compose.yml / docker-compose.local.yml 中
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP 与
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS 的兜底值由 false 改为 true,
与代码默认值(0c7a58fc)保持一致,避免未配置 .env 的 Docker 部署
在测试账号连接时因 http base URL 报 "invalid url scheme: http"。

同步更新 README(三语)、.env.example、config.example.yaml 中
过时的"默认拒绝 HTTP"描述,改为默认允许并指导生产环境显式收紧。
2026-07-04 13:51:37 +08:00
feitianbubu 20008264fe feat: 点击侧边栏 Logo/站点名返回首页 2026-07-04 13:33:53 +08:00
github-actions[bot] b650bdd68d chore: sync VERSION to 0.1.144 [skip ci] 2026-07-04 03:49:44 +00:00
Wesley Liddick 41def4ba03 Merge pull request #3699 from wucm667/fix/responses-billing-use-mapped-model
fix(openai): /v1/responses 计费使用映射后模型,修复按原始模型价格误计费
2026-07-04 10:40:53 +08:00
Wesley Liddick 707b87a96f Merge pull request #3676 from wucm667/fix/codex-import-refresh-token-missing-collision
fix(admin): Codex Session 导入 refresh_token 缺失时不再合并同 workspace 不同账号
2026-07-04 10:29:47 +08:00
shaw cbe0c4665f chore: update readme 2026-07-04 10:18:41 +08:00
shaw dec709fb3a chore: update sponsors 2026-07-04 10:06:11 +08:00
wucm667 4dd3aee5c2 fix(openai): use mapped billing model for responses 2026-07-04 10:04:42 +08:00
wucm667 6bd248fd1f fix(admin): avoid merging Codex access-only imports 2026-07-04 09:53:01 +08:00
shaw be297b90ce refactor(frontend): merge Codex image bridge and tool policy into one four-state control
Replace the two adjacent account-level controls (3-state injection bridge
+ 2-state explicit tool policy) in EditAccountModal with a single
four-state selector: follow channel / force inject / no injection /
block all. The four states map onto the existing extra keys
(codex_image_generation_bridge, codex_image_generation_explicit_tool_policy)
with no backend change; strip already disables the bridge upstream, so
the effective decision space is exactly these four states.

- load: strip takes precedence and reads as "block all"; legacy
  bridge_enabled key still recognized and cleaned up on save
- save: block writes policy=strip and drops the bridge override;
  inherit clears both keys
- semantic accent colors per state (sky/emerald/amber/rose) with dark
  mode support; state badge follows the same palette
- replace account-scope i18n keys in zh/en (channel-scope keys untouched)
- rewrite specs to cover all four states (25/25 passing)
2026-07-04 09:49:37 +08:00
Wesley Liddick dfdb838ce4 Merge pull request #3690 from AndersonBY/feature/codex-image-tool-policy
feat: add Codex image tool strip policy
2026-07-04 09:27:37 +08:00
Wesley Liddick be3aee1a4b Merge pull request #3694 from zhaoteng45/fix/antigravity-gemini31-pro-agent
fix: normalize Antigravity Gemini 3.1 Pro routing
2026-07-04 09:23:46 +08:00
Wesley Liddick 588e02b29e Merge pull request #3673 from heathermhuang/codex/grok-admin-issues-3545-3649
fix: preserve configured Grok OAuth concurrency
2026-07-04 09:22:18 +08:00
Wesley Liddick 659086861d Merge pull request #3670 from weiness/feat/configurable-migration-timeout
feat: make setup migration timeout configurable
2026-07-04 09:21:14 +08:00
Wesley Liddick 0c53d4f334 Merge pull request #3661 from jianjianai/fix/group-capacity-batch-hotpath
fix: 优化用户组容量汇总批量统计性能
2026-07-04 09:20:02 +08:00
Wesley Liddick 8e07ec55db Merge pull request #3685 from jianjianai/fix/optimize-slot-cleanup
fix: 优化并发槽位定时清理性能
2026-07-04 09:19:34 +08:00
Wesley Liddick 129d05258a Merge pull request #3696 from jianjianai/codex/token-expired-nonretryable
fix:添加 token_expired 刷新不可重试判断
2026-07-04 09:18:43 +08:00
Wesley Liddick 46ef146225 Merge pull request #3689 from DaydreamCoding/feat/usage-error-requests-alignment
feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)
2026-07-04 09:18:08 +08:00
Wesley Liddick 35df02aeac Merge pull request #3698 from Wei-Shaw/feat/anthropic-7d-oi-fable-model-rate-limit
feat(anthropic): 7d_oi(Fable 专属 7d 窗口)429 仅做模型级限流 + 用量窗口新增 7d F 进度条
2026-07-04 08:49:08 +08:00
shaw b3f796972e feat(anthropic): treat 7d_oi (Fable) window 429 as model-level rate limit
Anthropic OAuth/setup-token accounts now expose a Fable-only 7d window in
429 responses (anthropic-ratelimit-unified-7d_oi-*). Previously, a 429
triggered solely by this window fell through to the legacy path and marked
the whole account rate limited (picking the sooner 5h/7d reset) even though
5h/7d were still "allowed".

Backend:
- HandleUpstreamError: when only 7d_oi is rejected/exceeded, set a
  model-level rate limit (scope "claude-fable-5") and skip account-level
  SetRateLimited / session-window rewrite / temp-unsched entirely.
  Real 5h/7d exhaustion keeps the exact legacy account-level behavior.
- modelRateLimitKeysForRequest: append the Fable family scope for any
  mapped Anthropic model containing "fable", so claude-fable-5[1m] and
  other variants are excluded from scheduling together.
- Passive sampling: collect 7d_oi utilization/reset into Extra
  (passive_usage_7d_oi_*), including from the 429 response itself so the
  usage bar doesn't freeze at a stale value during the limit window;
  cleared on 5h window reset like the existing 7d keys.
- Usage API: new seven_day_fable window in UsageInfo. GetPassiveUsage
  builds it from passive samples; active queries parse the upstream
  seven_day_overage_included field (claim name from the
  representative-claim header) and fall back to passive data when the
  upstream usage API doesn't return it, so the active-query button keeps
  the bar populated. seven_day_sonnet handling is preserved unchanged.

Frontend:
- /admin/accounts usage cell renders a "7d F" (amber) progress bar next to
  the existing 5h / 7d / 7d S bars for Anthropic OAuth/setup-token accounts.
2026-07-04 08:28:30 +08:00
jjaw e5dc1f5972 添加 token_expired 刷新不可重试判断 2026-07-04 04:30:15 +08:00
zhaoteng45 df2cedeead fix: normalize antigravity gemini 3.1 pro routing 2026-07-03 17:22:49 +00:00
DaydreamCoding ebbdc70311 feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)
错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab)
对齐用量明细的交互与信息密度。Squash of:

- feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列)
- refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort
- feat(usage): /admin/usage 错误请求新增分类过滤
- fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in
- fix(ops): 错误列表用户列回退显示已删除 KEY 所有者
- fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
2026-07-03 23:02:27 +08:00
Anderson f385cdceb7 feat: add Codex image tool strip policy 2026-07-03 22:36:39 +08:00
Wesley Liddick 2649573b99 Merge pull request #3684 from Wei-Shaw/fix/usage-log-queue-silent-drop
fix: prevent silent usage_logs drops under queue overflow
2026-07-03 22:17:24 +08:00
shaw a1b2b32e08 fix: prevent silent usage_logs drops under queue overflow (#3656)
高并发下扣费成功但 usage_logs 被双层异步队列静默丢弃(单用户实测丢失 49%),
导致消费排行与对账缺口。根因是 6a685727 为避免溢出时单行 INSERT 踩踏数据库,
把三处"队列满"改成了立即终态丢弃。

改为有界阻塞背压,兼顾数据不丢与批量写入模式不变:

- CreateBestEffort/createBatched 入队移除 default 立即丢弃分支,
  队列满时阻塞等待(受 detached 15s ctx 期限约束)
- writeUsageLogBestEffort 收到 dropped 不再直接放弃,统一走
  repo.Create 同步兜底(仍经批处理器;ctx 耗尽时换新 detached 窗口),
  重复写入由 ON CONFLICT DO NOTHING 幂等防护
- worker 池默认溢出策略 sample→sync(viper 默认 + 池常量),
  溢出时提交方内联执行(提交点在响应写出后,不阻塞客户端),
  显式配置 sample/drop 的部署不受影响
- 附带修复 ensure*Batcher 的存量数据竞争:channel nil 检查移入 sync.Once

正常负载零行为变化;突发时短暂等待替代永久丢失;数据库只见批量 INSERT。
2026-07-03 21:02:32 +08:00
jjaw f3a3a0869d 优化并发槽位清理 2026-07-03 21:02:31 +08:00
Heatherm Huang b2687b2258 fix(frontend): edit grok oauth model mapping 2026-07-03 19:43:54 +08:00
Wesley Liddick 28c2d20d9f Merge pull request #3675 from wucm667/fix/antigravity-401-auto-recover
fix(antigravity): 401 认证失败可自动恢复,不再永久卡在 error 状态
2026-07-03 16:58:37 +08:00
jianjian c620ad6a36 fix: align group capacity SQL with target schema 2026-07-03 08:44:39 +00:00
wucm667 d0a1443a41 fix(antigravity): allow oauth 401 auto recovery 2026-07-03 15:21:53 +08:00
Heatherm Huang 2b462b07de fix: preserve configured Grok OAuth concurrency 2026-07-03 14:41:18 +08:00
Wesley Liddick 87dfc66132 Merge pull request #3659 from jianjianai/fix/ops-realtime-stats-performance
fix: 优化运维实时账号统计接口性能
2026-07-03 10:45:24 +08:00
Wesley Liddick 5d7f213cbf Merge pull request #3657 from jianjianai/fix/redeem-invitation-bad-request
修复邀请码误走普通兑换接口时返回 500
2026-07-03 10:44:22 +08:00
weiness 36d5f4e4ca feat: make setup migration timeout configurable 2026-07-03 10:29:33 +08:00
shaw a5638a4e54 fix: match Codex session imports by chatgpt_user_id before shared account id
Members of the same ChatGPT team share chatgpt_account_id, so matching
imports by the account key first could overwrite another member's
account credentials. Identity keys are now ordered by strength
(user > email > access > account), and an account-key hit is rejected
when both sides carry different chatgpt_user_id values.

- Keep the account-key fallback when either side lacks a user id, so
  legacy accounts without chatgpt_user_id are updated and backfilled
  instead of duplicated
- Index all candidate accounts per shared key so a teammate's row can
  no longer shadow a legacy account depending on row order
- Apply the same conflict check to in-batch dedup and emit a warning
  when a legacy account is claimed via the shared account key
- Scope a 120s timeout to the Codex session import request instead of
  raising the global client timeout
2026-07-03 10:13:51 +08:00
jianjian 72ccd1b111 fix: batch group capacity summaries 2026-07-02 20:22:17 +00:00
jianjian 3f2ef60468 fix: optimize ops realtime account stats 2026-07-02 20:04:47 +00:00
jianjian 372436323a 修复邀请码普通兑换错误 2026-07-02 16:38:42 +00:00
github-actions[bot] 0b8e5eec32 chore: sync VERSION to 0.1.143 [skip ci] 2026-07-02 11:48:20 +00:00
Wesley Liddick 9caa3c9c5e Merge pull request #3635 from JRBaggins/hotfix/openai-oauth-count-tokens-latest
fix: handle OpenAI OAuth count_tokens scope errors
2026-07-02 17:41:43 +08:00
Wesley Liddick 3020652fa1 Merge pull request #3565 from zy6p/zy6p/pr-openai-ws-http-bridge
feat(openai-ws): 支持 http_bridge ingress 模式
2026-07-02 17:40:32 +08:00
Wesley Liddick 537bd50469 Merge pull request #3638 from infinityf4p/show-reset-credit-expiration
feat(openai): show reset credit expiration
2026-07-02 17:39:17 +08:00
Wesley Liddick 6bc6998b0e Merge pull request #3627 from alfadb/fix/ollama-anthropic-bearer-auth
fix(anthropic): 支持 API Key Bearer 认证方式
2026-07-02 17:37:30 +08:00
Wesley Liddick 4e23c77ece Merge pull request #3626 from DanisJiang/fix/claude-code-stream-keepalive
fix: prevent Claude Code stream keepalive stalls
2026-07-02 17:35:48 +08:00
Wesley Liddick 821399ade1 Merge pull request #3622 from deqiying/feat/subscription-revoke-restore
支持恢复已撤销订阅
2026-07-02 17:34:20 +08:00
Wesley Liddick 441b8ec2e2 Merge pull request #3614 from heathermhuang/codex/grok-media-group-gate-fix
fix: enable Grok media generation groups
2026-07-02 17:34:09 +08:00
Wesley Liddick fafdd8342f Merge pull request #3641 from infinityf4p/fix/openai-plan-type-workspace
fix(openai): avoid inactive workspace plan type overrides
2026-07-02 17:33:59 +08:00
Wesley Liddick 50dff6762c Merge pull request #3630 from seng1e/fix/codex-image-bridge-skip-compact
fix(openai): skip Codex image bridge injections for /responses/compact
2026-07-02 17:32:43 +08:00
Wesley Liddick e63492090a Merge pull request #3609 from phoenixikkifullstack/fix/antigravity-gemini-3.1-pro-high
fix: Handle invalid arguments correctly for Gemini reasoning models
2026-07-02 17:32:32 +08:00
Wesley Liddick 51b5142af7 Merge pull request #3612 from StarryKira/codex/fix-user-model-stats-requested-model
[codex] fix user model stats requested aggregation
2026-07-02 17:32:19 +08:00
Wesley Liddick 634b0eee48 Merge pull request #3430 from chaochaoweb3/codex/remove-claude-oauth-expires-in
fix: omit expires_in from Claude OAuth token exchange
2026-07-02 17:32:09 +08:00
Wesley Liddick fd66ae1ecb Merge pull request #3642 from infinityf4p/fix/openai-subscription-expiry-save
fix(openai): persist subscription expiration from OAuth
2026-07-02 17:31:58 +08:00
Wesley Liddick a32196eedd Merge pull request #3615 from DaydreamCoding/feat/usage-ip-geolocation
feat(usage): add IP geolocation lookup to usage tables
2026-07-02 16:42:06 +08:00
Wesley Liddick 3302146759 Merge pull request #3643 from bestony/feat/admin-group-column-settings
feat(admin): add group column settings
2026-07-02 16:40:59 +08:00
shaw 41af8e8e11 chore: update sponsors 2026-07-02 16:29:34 +08:00
shaw 11a3da65c0 fix(group): harden peak-rate config handling and label peak windows with server timezone
Follow-up fixes to #3569 based on code audit:

- Expose server_timezone / server_utc_offset in public settings (and the
  __APP_CONFIG__ injection payload) and label every peak-window display
  with the server UTC offset, so users don't misread the billing window
  as browser-local time
- Unify CreateGroup/UpdateGroup peak-config sanitization via a single
  NormalizePeakRateConfig chokepoint: non-subscription groups always get
  peak fields cleared; unparseable window strings and negative
  multipliers are scrubbed when peak is disabled
- Replace hot-path time.Parse in PeakMultiplierAt with a manual HH:MM
  parser (accept set verified byte-for-byte identical to
  time.Parse("15:04") by exhaustive fuzzing) and reuse it in validation
- Revert the zero-behavior CalculateCost indirection churn in
  billing_service/gateway_service introduced by #3569
- Remove dead GetGroupPlatformMap and the duplicate deref helper in the
  admin handler package
- Share frontend peak formatting via utils/peak-rate.ts, unify the ×N
  label format, and move hardcoded Chinese tooltips to i18n keys
2026-07-02 16:11:07 +08:00
Heatherm Huang 99a8d8ad28 fix: normalize grok imagine image alias 2026-07-02 15:04:40 +08:00
Bestony@Homelab 5436d0bf10 feat(admin): add group column settings 2026-07-02 15:04:36 +08:00
Wesley Liddick a632cb0050 Merge pull request #3569 from xueshiji/main
feat(group): 订阅分组新增可选的高峰时段倍率,以支持智谱等coding plan的高峰时段
2026-07-02 14:55:34 +08:00
infinityf4p df59b8b966 fix: persist OpenAI subscription expiration 2026-07-02 14:38:08 +08:00
infinityf4p d0b8760eb9 fix: preserve OpenAI plan type from inactive workspaces 2026-07-02 14:37:30 +08:00
zy6p 5fe4ee1da1 style(test): gofmt oauth passthrough tests 2026-07-02 14:33:55 +08:00
infinityf4p dfb36e45f1 show reset credit expiration 2026-07-02 13:27:58 +08:00
JRBaggins c4128580f2 fix: handle OpenAI OAuth count_tokens scope errors 2026-07-02 11:42:10 +08:00
seng1e c797159bf5 fix(openai): skip Codex image bridge injections for /responses/compact
The Codex image_generation bridge block in Forward injects
tools:[image_generation] and tool_choice:"auto" (since #3498) into every
Codex request, including /responses/compact. The upstream compact endpoint
rejects tool_choice with 400 "Unknown parameter: 'tool_choice'", so every
remote compaction fails once the bridge is enabled. Guard the whole image
generation block with !isCompactRequest.

Fixes #3629
2026-07-02 11:09:50 +08:00
alfadb 7869b7fe38 fix(anthropic): 支持 API Key Bearer 认证方式 2026-07-02 10:43:08 +08:00
Yuhao Jiang a5781fe31d fix Claude Code stream keepalive stalls 2026-07-01 21:01:11 -05:00
deqiying b26dcc3da2 feat(subscription): 支持恢复已撤销订阅 2026-07-01 22:19:21 +08:00
DaydreamCoding 0ff93aca7a feat: add usage IP geolocation lookup 2026-07-01 21:49:49 +08:00
Heatherm Huang 9934bd2572 fix: default grok group media generation 2026-07-01 20:55:53 +08:00
Heatherm Huang 0435417f43 fix: enable grok media generation groups 2026-07-01 20:31:32 +08:00
github-actions[bot] 7dc7cfce1d chore: sync VERSION to 0.1.142 [skip ci] 2026-07-01 11:06:15 +00:00
shaw 98be187d56 chore: update sponsors 2026-07-01 19:04:40 +08:00
Haruka e236bff1ed fix: aggregate user model stats by requested model 2026-07-01 19:01:30 +08:00
phoenix 45be32b254 fix: remove space lines 2026-07-01 18:51:33 +08:00
shaw 60da9ba178 fix(frontend): refine grok icon, palette and i18n 2026-07-01 18:49:33 +08:00
xueshiji be26e6ff2c fix: test 2026-07-01 18:09:20 +08:00
phoenix f5b2961270 fix: Handle invalid arguments correctly for Gemini reasoning models 2026-07-01 18:08:25 +08:00
xueshiji a0eaf12a01 fix: test 2026-07-01 17:54:39 +08:00
Wesley Liddick 5eb9da9c93 Merge pull request #3593 from heathermhuang/codex/grok-media-routing
fix: route Grok media endpoints
2026-07-01 17:49:27 +08:00
xueshiji 5e99561d6e Merge branch 'Wei-Shaw:main' into main 2026-07-01 17:48:42 +08:00
xueshiji 1034f576d7 fix: 高峰倍率全链路透传、计费术语修正与边界处理
- 高峰倍率信息透传至可用渠道、支付计划、结算信息等 API,前端
  GroupBadge / GroupOptionItem / SubscriptionPlanCard / PaymentView /
  SubscriptionsView 统一展示高峰时段与倍率标签
- 修正计费术语:"文本倍率" → "token 倍率",明确高峰倍率同时作用于
  token 计费的图片 token,图片按次计费不受高峰影响
- 允许高峰倍率 multiplier=0,支持高峰时段免费策略
- 切换分组类型为 standard 时自动清除高峰倍率配置
- 长上下文计费与标准计费路径改用内部实现,移除冗余中间调用
- 前端高峰倍率相关控件文案改为 i18n
- 新增多组高峰倍率相关单元测试
2026-07-01 17:46:12 +08:00
zy6p 71c16b7230 fix(test): correct passthrough failover assertions 2026-07-01 17:20:55 +08:00
Heatherm Huang aac3261c69 fix: convert grok image edit uploads 2026-07-01 16:31:33 +08:00
Heatherm Huang f77cf6b477 Revert "feat: add LLM media tester"
This reverts commit a34d4967e6.
2026-07-01 16:08:21 +08:00
shaw 8c2d9b9a12 chore(openai): remove gpt-5.3-codex from OpenAI default model list
移除 OpenAI OAuth 账号默认模型列表中的 gpt-5.3-codex(后端 DefaultModels、前端 openai 白名单及 UseKeyModal 目录),保留 gpt-5.3-codex-spark 与计费/别名/messages-dispatch 相关逻辑不变。
2026-07-01 15:54:34 +08:00
Heatherm Huang c9fb221a31 fix: satisfy grok media lint 2026-07-01 15:42:00 +08:00
Wesley Liddick 0a9146c3d1 Merge pull request #3586 from deqiying/codex/fix-subscription-revoke-soft-delete
修复订阅撤销操作实际上是软删除的bug
2026-07-01 15:38:45 +08:00
Heatherm Huang 42e471f59a fix: harden grok media routing 2026-07-01 15:36:08 +08:00
Heatherm Huang a34d4967e6 feat: add LLM media tester 2026-07-01 15:12:48 +08:00
Wesley Liddick d679ee3c29 Merge pull request #3596 from JRBaggins/fix/tag-version-resolution
fix: source-build version resolution for release tags
2026-07-01 14:45:11 +08:00
xueshiji ebd83dc0f9 fix: 触发ci 2026-07-01 14:43:22 +08:00
shaw fd004bdd88 fix(account-repo): Clone query before Count to prevent state pollution
ListWithFilters reused the Ent query builder across Count() and All(),
so interceptor-appended predicates (SoftDeleteMixin's deleted_at IS NULL)
accumulated on the shared builder — the same pattern already fixed in
group_repo/promo_code_repo/user_repo by commit 2588fa6a (P1-03 audit).

The integration test now asserts pagination.Total matches len(items) on a
single page, guarding the total-vs-items invariant reported in #3601.

Fixes #3601
2026-07-01 14:35:49 +08:00
xueshiji e998f8044b fix: ci 2026-07-01 14:34:57 +08:00
JRBaggins de64b02612 fix: resolve build version from release tag 2026-07-01 14:25:46 +08:00
Wesley Liddick 3812e627a8 Merge pull request #3546 from nslogx/fix/platform-quota-five-platforms
fix: allow five platform quota updates
2026-07-01 14:07:08 +08:00
xueshiji 8b46994dc2 Merge branch 'Wei-Shaw:main' into main 2026-07-01 14:01:03 +08:00
Wesley Liddick 9817d5d5bf Merge pull request #3598 from DaydreamCoding/feat/openai-spark-shadow-account
feat(spark-shadow): OpenAI Spark 链接型影子账号
2026-07-01 14:00:52 +08:00
xueshiji 5e9529bb88 Merge branch 'Wei-Shaw:main' into main 2026-07-01 14:00:51 +08:00
shaw 96c9166b0e fix(usage): 用户使用记录默认显示推理强度列 2026-07-01 14:00:27 +08:00
DaydreamCodingandClaude Sonnet 5 bdf7ead157 feat(spark-shadow): OpenAI Spark 链接型影子账号
背景:gpt-5.3-codex-spark 使用独立于 codex 全局(5h/7d)的配额窗口(数据源是
/wham/usage 响应体的 codex_bengalfox,而非 codex 全局用的 x-codex-* 响应头),且
只能挂在已完成 OAuth 授权的 OpenAI 账号下复用其登录态,不能作为独立账号单独接入。
为此新增“链接型影子账号”(spark shadow account):影子账号本身不持有任何凭据,
通过 parent_account_id 指向母账号,凭据/token/代理透传自母账号并共享母账号的刷新
周期,仅在配额维度(quota_dimension=spark)和用量窗口上与母账号完全独立调度、互不
连坐。

实现:
- 数据模型:migration 154(+154a)给 accounts 表加 parent_account_id /
  quota_dimension 列 + 4 条约束(维度合法 / parent⟺非 global 维度一致 / 禁自指 /
  FK)+ 2 个 CONCURRENTLY 索引(母账号索引 + 每母账号至多一个影子的唯一索引)。
- 创建:POST /api/v1/admin/accounts/:id/shadow(CreateShadow)—— 一母一影(唯一
  索引兜底并发竞态),继承母账号 proxy/分组/并发/优先级(显式传参可覆盖),默认
  model_mapping 恒等映射到 spark(拒绝非 spark 模型),母账号必须是真实的 OpenAI
  OAuth 账号(非影子)。
- 凭据透传:resolveCredentialAccount 把影子解析回母账号,GetAccessToken / 请求头
  / WS 三条路径统一走此函数;影子自身 Credentials 恒为空(仅允许写 model_mapping),
  凭据写入的汇聚点 persistAccountCredentials 对影子早返 no-op,防止误写。
- 调度:parentHealthyForShadow 只看母账号是否仍是 OpenAI OAuth + 凭据/传输是否
  可用(active、token 未过期、未处于 401/刷新失败/传输故障导致的临时不可调度冷却),
  刻意不看母账号的 global 限流窗口——两条 429 道互不连坐。
- 用量:影子的 codex_5h/7d 走 OpenAIQuotaService.QueryUsage(/wham/usage 的
  codex_bengalfox),与母账号走的 WSv2 探测(/responses 头)完全独立的数据源、
  刷新节流与 staleness 判定。
- 备份:ExportData 显式排除影子账号(影子不持凭据,通用凭据型导入强制
  credentials 非空、无法表达父子链接),按 skipped_shadows 计数提示前端。
- 前端:账号操作菜单新增“创建 Spark 影子”入口,影子行展示回填的母账号信息
  (邮箱 / plan / 隐私模式 / 订阅到期 / chatgpt_account_id),批量操作自动跳过
  影子账号。

说明:migrations 目录用完整文件名(而非纯数字前缀)标识迁移,故本次新增的
154_account_spark_shadow.sql / 154a_..._notx.sql 与已有的
154_add_ops_system_logs_api_key_id.sql 按序号共存,与目录里 145/151 已有的
先例一致。

测试:新增约 20 个测试文件,覆盖 handler(CreateShadow 校验 / 母账号信息回填)、
repository(影子 round-trip / 一母一影唯一索引 / 迁移 schema)、service(凭据
透传三路径 / 调度母健康门 / 用量窗口来源与刷新节流 / CRS 母账号不变量 / 各类
早返与 fail-closed 场景)及前端组件(账号列表 / 操作菜单 / 用量重置)。

验证(镜像 CI;golangci-lint 首次全量分析耗时过长被跳过,其余全部实测):
- gofmt -l:干净
- go build ./... / go vet ./...:通过
- go test ./... -count=1:全绿(全部包 ok,含 internal/service、
  internal/repository、migrations)
- go test -tags integration ./internal/repository/... ./internal/service/...
  (真实 Postgres,testcontainers):全绿,含迁移幂等性
  (TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate)与影子相关全部用例
- pnpm lint:check / pnpm typecheck / pnpm build(真实 vite 构建)/
  pnpm vitest run:全绿(124 文件 760 用例)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:21:45 +08:00
zy6p e28b11ad75 fix(test): align compact passthrough expectation 2026-07-01 11:49:43 +08:00
Heatherm Huang c3e860607d fix: include official grok media model ids 2026-07-01 11:43:35 +08:00
Heatherm Huang 2fe756e4be fix: recognize grok media models 2026-07-01 11:43:35 +08:00
Heatherm Huang 3b5d812f7a fix: route grok media endpoints 2026-07-01 11:43:35 +08:00
Wesley Liddick 5f62625ae6 Merge pull request #3577 from heathermhuang/codex/fix-english-ui-cjk
fix: remove Chinese strings from English UI
2026-07-01 11:35:04 +08:00
zy6p 605b6f6632 fix(test): resolve oauth passthrough unit build errors 2026-07-01 11:34:16 +08:00
shaw db0414233c feat: 适配 sonnet5 2026-07-01 11:32:22 +08:00
Wesley Liddick e2d21c35af Merge pull request #3587 from alfadb/fix/openai-gpt-55-pro-codex-model
fix(openai): 保留 GPT-5.5 Pro Codex 模型名
2026-07-01 10:54:51 +08:00
shaw 59e9356c51 feat: 抹除 Anthropic OAuth 请求中客户端 dateline 隐写指纹
对 /v1/messages 转发到 Anthropic OAuth/setup-token 账号的请求做 dateline
归一化,将 system prompt 与 <system-reminder> 块中 "Today's date is …"
语句里的 4 种撇号变体与 "/" 日期分隔符还原为 ASCII 撇号 + "-",抹除某些
客户端在检测到非官方 base URL 时注入的 3 bit 隐写指纹。API Key 账号不受
影响。新增系统设置开关 enable_client_dateline_normalization,默认开启。
2026-07-01 10:54:18 +08:00
Wesley Liddick c1335bae12 Merge pull request #3588 from Ogannesson/fix/openai-codex-preserve-encrypted-reasoning
fix(openai): preserve encrypted reasoning across turns on codex OAuth path
2026-07-01 09:11:35 +08:00
alfadb b28a223337 fix(openai): 保留 GPT-5.5 Pro Codex 模型名
避免 Codex OAuth 模型归一化把 gpt-5.5-pro 降级为 gpt-5.5。\n\n同时补充 GPT-5.5 Pro 的计费回退与长上下文计费策略,保证保留上游模型名后仍使用现有 GPT-5.5/GPT-5.4 计费规则。\n\n验证:\n- go test ./internal/service/...\n- go build ./cmd/server/
2026-07-01 01:30:42 +08:00
deqiying 03727ac363 fix(subscription): 修复订阅撤销软删除失效
总: 增加明确的订阅撤销接口,修复撤销后的缓存失效和管理端 revoked 展示。

分: 同步失效订阅 L1 与 billing cache,补跨实例失效通知、soft-delete-aware 列表查询、revoked_at DTO 字段和回归测试。
2026-07-01 00:37:51 +08:00
Heatherm Huang 1158cf31bc fix: require localized interval validation 2026-06-30 22:31:21 +08:00
Heatherm Huang 41bb15d302 fix: remove remaining English fallbacks 2026-06-30 22:27:37 +08:00
Heatherm Huang b4f38b092f fix: preserve zh locale in i18n cleanup 2026-06-30 22:06:27 +08:00
Oganneson 73de2ea7f0 fix(openai): preserve encrypted reasoning across turns on codex OAuth path
PR #2068 dropped every reasoning item from input[] on the OAuth/codex path
(store=false). That silently discards encrypted_content -- the out-of-band
channel that carries reasoning context across turns under store=false --
degrading multi-turn agent reasoning with no visible error. Reported by
@neteroster on PR #2068.

The 404 that #2068 worked around ("Item with id 'rs_...' not found") is
triggered by the rs_* id lookup under store=false, not by the reasoning item
itself -- so the correct fix is to strip the id, not delete the item.

Verified end-to-end against the live chatgpt.com codex backend (gpt-5.5) and
a real OpenClaw container (api: openai-responses):
  - bare rs_ id, no encrypted_content  -> 404
  - id stripped                        -> 200
  - encrypted_content + id stripped    -> 200, reasoning context preserved
  - reasoning items require a summary field (missing -> 400)
  - real OpenClaw multi-turn agent loop -> all /v1/responses 200, zero 404

Fix: keep the reasoning item, strip only the rs_* id (always, independent of
PreserveReferences), preserve encrypted_content/content/summary verbatim, and
backfill an empty summary when absent. Tool-call call_id pairing is untouched.

Also verified compaction_summary items (cmp_*, the other encrypted_content
carrier): they require encrypted_content (missing -> 400) and their id does not
404 when present (kept or stripped), so the existing generic path already
handles them safely -- no special-casing needed.

Adds regression tests for each verified reasoning contract.

Refs #1957, #2068
2026-06-30 21:08:40 +08:00
Heatherm Huang 6e54776509 fix: localize English UI strings 2026-06-30 20:05:23 +08:00
github-actions[bot] dc1bc15457 chore: sync VERSION to 0.1.141 [skip ci] 2026-06-30 09:29:40 +00:00
Wesley Liddick 7cb98e5bdc Merge pull request #2007 from PMExtra/feature/user-usage-admin-parity
feat: align user usage analytics with admin
2026-06-30 17:10:09 +08:00
shaw 930326116e fix: 修复订阅支付金额显示错误 2026-06-30 16:26:22 +08:00
PMExtra cafc95c3e2 feat: align user usage analytics with admin 2026-06-30 15:31:28 +08:00
github-actions[bot] 89b2d63ef2 chore: sync VERSION to 0.1.140 [skip ci] 2026-06-30 06:26:55 +00:00
xueshiji 915c60b150 feat(group): 订阅分组新增可选的高峰时段倍率,以支持智谱等coding plan的高峰时段 2026-06-30 14:17:05 +08:00
shaw 345d5c6b56 chore: update sponsors 2026-06-30 14:11:14 +08:00
nslogx 6c46c2cb7d test: update platform quota contract for grok 2026-06-30 14:03:33 +08:00
Wesley Liddick 76e0d90736 Merge pull request #3509 from wucm667/fix/refund-pending-not-success
fix(payment): 退款 pending 不再当成最终成功,避免站内账务与网关状态不一致
2026-06-30 13:38:04 +08:00
Wesley Liddick 56ca027a0f Merge pull request #3568 from DaydreamCoding/fix/auth-signup-grok-platform-quota
fix(auth-signup): 平台配额快照脱离注册事务 + grok 补入 CHECK 约束
2026-06-30 13:37:54 +08:00
shaw bf4f006736 fix: 修复用户列表使用时间排序冲突 2026-06-30 13:36:36 +08:00
DaydreamCodingandClaude Opus 4.8 185f9c9920 fix(auth-signup): 平台配额快照脱离注册事务 + grok 补入 CHECK 约束
自助注册(含钉钉/OAuth)报 500→404 的根因:grok 自 2026-06 进入默认平台配额
(default_platform_quotas / auth_source_*),但 user_platform_quotas 的 CHECK
约束(迁移 142)仅允许 anthropic/openai/gemini/antigravity。注册时
snapshotPlatformQuotaDefaults 写 grok 行违反约束 → 整个注册事务被 Postgres 标记
aborted → consumePendingOAuthBrowserSessionTx 撞 "transaction aborted" → 500 →
clearCookies → 用户重试拿到 404(PENDING_AUTH_SESSION_NOT_FOUND)。
影响面:所有新自助注册(不限钉钉)。

修复(两层):
- 事务隔离(fix①):snapshotPlatformQuotaDefaults 用 ent.WithoutTx 剥离调用方事务,
  在基础连接 autocommit 执行。best-effort 快照失败永不毒化注册主事务,从根上消除
  "事务内 fail-open 形同虚设"陷阱——今后任何平台/约束漂移都不会再连累注册。
- 迁移 157:把 grok 加入 user_platform_quotas.platform 的 CHECK 约束,与代码平台
  列表(domain/constants.go PlatformGrok)对齐(DROP IF EXISTS + ADD,可重入)。

新增 ent.WithoutTx(ctx) helper(手写文件,不动生成代码)。

测试:
- 单测 TestSnapshotPlatformQuotaDefaults_DetachesCallerTransaction(RED→GREEN):
  快照即便在事务 ctx 中也必须用脱离事务的 ctx 调 repo。
- 集成测试 TestUserPlatformQuotaRepository_BulkInsertInitial_GrokAllowed:
  迁移 157 后 grok 可写入(真实 postgres 容器验证)。

验证:go build ./... / go vet -tags unit ./... / 全量单测(-tags unit,45 包) /
平台配额+迁移集成测试 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:22:27 +08:00
Wesley Liddick dcd8689d78 Merge pull request #3548 from dftian478/codex/中文-上下文窗口不切号
修复 OpenAI 上下文窗口错误误触发账号切换
2026-06-30 10:57:26 +08:00
Wesley Liddick efb8b45876 Merge pull request #3560 from StarryKira/feat/issue-3557-oauth-email
feat: fix OAuth email completion flow
2026-06-30 10:55:00 +08:00
Wesley Liddick ac6d4ccf09 Merge pull request #3549 from dftian478/codex/中文-gpt55-codex-instructions
修复 OpenAI GPT-5.5 的 Codex 指令选择
2026-06-30 10:53:35 +08:00
Wesley Liddick ac6f2e9693 Merge pull request #3559 from 315944211/fix/privacy-toast-result
fix: show privacy setting result accurately
2026-06-30 10:50:17 +08:00
zy6p 906be3f742 fix(openai-ws): honor explicit http_bridge ingress mode
(cherry picked from commit 9c0498a873f31472c94922a4115adab4587cd3e7)
2026-06-30 10:42:32 +08:00
zy6p 5654321380 fix(frontend): enable ws mode editing for openai setup-token accounts
(cherry picked from commit 73031d1ba8079b2f369a11b3de9542f27bd61405)
(cherry picked from commit acdbb9ce0471e5ab1009dc25e171a6497222347c)
2026-06-30 10:41:41 +08:00
zy6p 0476b5c97a fix(openai-ws): allow http_bridge accounts in ws ingress selection
(cherry picked from commit f0d215ec522e05043f5012a5f1e7aba2bfb57fcc)
(cherry picked from commit 7b14846a65e16570cb7b472b0ed06c6dcb190e0d)
2026-06-30 10:41:41 +08:00
Wesley Liddick e70e36e4d2 Merge pull request #3484 from bwliangc/feat/risk-control-matched-keyword
feat(risk-control): record matched keyword in keyword-block logs
2026-06-30 10:40:30 +08:00
zy6p 901958ba1b feat(openai-ws): add http_bridge ingress mode and account ws selector
(cherry picked from commit 58647ff63d7ff994c7c14c84c4913a8d3ed6be05)
(cherry picked from commit 9f18fb7c24e187fb20e90d1d9e89fcec91c56937)
2026-06-30 10:40:05 +08:00
wucm667 93a3bf3077 Fix refund pending finalization gaps 2026-06-30 10:19:50 +08:00
wucm667 7316d83027 fix(payment): 区分退款 pending 并收敛匿名查单 2026-06-30 10:00:48 +08:00
Wesley Liddick 55902d8548 Merge pull request #3558 from heathermhuang/codex/grok-cli-compat
fix: support Grok admin test and CLI compatibility
2026-06-30 08:42:59 +08:00
haruka 260fda19b3 feat: fix OAuth email completion flow 2026-06-30 01:11:51 +08:00
315944211 a0a3d0c33e fix: show privacy setting result accurately 2026-06-30 00:37:21 +08:00
Heatherm Huang 438510d298 fix: sanitize grok codex responses payloads 2026-06-29 21:37:30 +08:00
Heatherm Huang 10e623f674 fix: allow grok messages compatibility 2026-06-29 18:33:37 +08:00
Heatherm Huang 4a7148e203 fix: support grok cli compatibility routes 2026-06-29 17:53:19 +08:00
Wesley Liddick d3acd8e96e Merge pull request #3497 from JRBaggins/fix/openai-count-tokens-bridge
Bridge OpenAI count_tokens to responses input_tokens
2026-06-29 15:23:11 +08:00
dftian478 7cbf82ed65 修复 OpenAI 上下文窗口错误误触发账号切换 2026-06-29 10:29:23 +08:00
dftian478 709cf61853 修复 OpenAI GPT-5.5 的 Codex 指令选择 2026-06-29 10:25:43 +08:00
nslogx d86e83259e fix: allow five platform quota updates 2026-06-29 09:40:04 +08:00
Wesley Liddick c99112a9e9 Merge pull request #3515 from bestony/feat/ops-system-log-key-id
feat(ops): add key id filter to system logs
2026-06-29 09:24:40 +08:00
Wesley Liddick a22b12677f Merge pull request #3517 from bestony/feat/keys-column-settings
feat(keys): add API key column settings
2026-06-29 09:24:20 +08:00
Wesley Liddick b0dbc22fe5 Merge pull request #3510 from wucm667/fix/subscription-confirm-show-converted-amount
fix(payment): 订阅订单确认页显示换算后的 CNY 实付金额,而非 USD 套餐价
2026-06-29 09:24:01 +08:00
Wesley Liddick 7c857bd080 Merge pull request #3441 from deqiying/feature/openai-quota-headroom-scheduler
新增 OpenAI 剩余额度调度权重
2026-06-29 09:23:32 +08:00
Wesley Liddick bebafe7697 Merge pull request #3437 from imfangwenjie/fix/api-base-fetch
fix(frontend): use configured API base for direct requests
2026-06-29 09:23:13 +08:00
Wesley Liddick fc1e5a94a9 Merge pull request #3534 from mxyhi/fix/openai-quota-platform-post-billing
fix(openai): preserve quota platform in usage billing
2026-06-29 09:21:44 +08:00
Wesley Liddick 61f735a667 Merge pull request #3498 from liuaho6-commits/fix/codex-image-bridge-tool-choice
fix(openai): set tool_choice auto for Codex image bridge
2026-06-29 09:21:25 +08:00
Wesley Liddick b105cc0fd5 Merge pull request #3337 from ddnio/codex/openai-json-mode-developer-input
fix(openai): preserve JSON instructions in Codex OAuth input
2026-06-29 09:21:05 +08:00
Wesley Liddick 38577c6ca0 Merge pull request #3533 from Pluviobyte/codex/fix-api-key-unlimited-quota
fix(keys): reactivate exhausted keys set to unlimited
2026-06-29 09:19:53 +08:00
Wesley Liddick 47598462f5 Merge pull request #3401 from StarryKira/fix/issue-3394-fallback-pricing-log-spam
fix: stop per-request fallback-pricing log spam for unknown models (#3394)
2026-06-29 09:19:44 +08:00
Wesley Liddick 753c5e255c Merge pull request #3215 from fchange/fix/image-billing-false-positive
fix(openai): prevent false image billing on text-only /v1/responses requests
2026-06-29 09:19:34 +08:00
Wesley Liddick 3b18d1fa3d Merge pull request #3532 from smartcmd/fix/subscription-direct-pay-price
fix(payment): keep subscription price as direct pay amount
2026-06-29 09:19:24 +08:00
Wesley Liddick a822972d80 Merge pull request #3494 from devnomad-byte/docs/source-compile-admin-setup
docs: note admin account wizard requirement in source-compile install
2026-06-29 09:19:14 +08:00
mxyhi 82553c4dca fix(openai): preserve quota platform in usage billing 2026-06-28 11:13:09 +08:00
Pluviobyte da810c3b43 fix(keys): reactivate exhausted keys set to unlimited 2026-06-28 09:05:28 +08:00
daoge_cmd b1403e8b29 fix(payment): keep subscription price as direct pay amount 2026-06-28 06:29:01 +08:00
Bestony b244f850e7 feat(keys): add column settings 2026-06-27 15:14:13 +08:00
Bestony bad87ff533 feat(ops): add api key filter to system logs 2026-06-27 14:35:19 +08:00
wucm667 88ca0c1d13 fix(payment): 显示订阅 CNY 换算实付金额 2026-06-27 12:15:39 +08:00
Hao Liu e5f7836bf3 fix(openai): set tool_choice auto for Codex image bridge 2026-06-26 19:10:37 +08:00
JRBaggins 7a38c66214 Bridge OpenAI count_tokens to responses input_tokens 2026-06-26 17:59:26 +08:00
github-actions[bot] c275422251 chore: sync VERSION to 0.1.139 [skip ci] 2026-06-26 09:55:00 +00:00
devnomad-byteandClaude Opus 4.6 98feeccbe1 docs: note admin account wizard requirement in source-compile install
Method 3 (source compile) instructs users to pre-create config.yaml,
but does not mention that doing so disables the setup wizard — the
only code path that creates the initial admin account. Users following
the README end up with a running server and an empty users table, and
login fails with "invalid email or password".

The default.admin_email / default.admin_password fields in config.yaml
are loaded into the config struct but never read by any code path
(setup.SetupConfig.Admin is tagged yaml:"-"), so they cannot serve as
an alternative way to seed the admin.

Add a "Creating the Admin Account" subsection to Method 3 in all three
language READMEs (EN/CN/JA), explaining the wizard dependency and two
workarounds: let the wizard generate config.yaml, or temporarily move
config.yaml aside to trigger the wizard on first run.

Related: #521 (docker variant of the same symptom), #2617, #2350.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 15:19:58 +08:00
bwlcandClaude Opus 4.8 815bc6c9b5 feat(risk-control): record matched keyword in keyword-block logs
The risk control center's moderation log records had no field for the
keyword that triggered a keyword block, so the admin UI couldn't show
which keyword was hit (only the application slog logged it).

- migration 156: add matched_keyword column to content_moderation_logs
- ContentModerationLog gains MatchedKeyword; set it on keyword block
- repo CreateLog/ListLogs persist and read the column
- frontend: show "命中关键词" inline in the log table and detail modal
- i18n: add matchedKeyword (zh/en)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:43:05 +08:00
imfangwenjie 2a58a57a7c fix(frontend): use configured API base for direct requests
Route direct fetch calls, setup requests, gateway usage checks, and ops WebSocket connections through the configured API base instead of assuming same-origin backend paths.

Also derive OAuth callback suggestions from the configured API base so split frontend/API deployments show usable redirect URLs.
2026-06-26 02:42:44 +08:00
chaochaoweb3 5bd9368ab4 fix claude oauth token exchange payload 2026-06-25 23:41:00 +08:00
deqiying a2cf297d90 feat: 新增 OpenAI quota headroom 调度权重 2026-06-24 00:13:22 +08:00
harukaandClaude Opus 4.8 6239e395b8 i18n(channel): explain case-insensitive matching in pricing conflict messages (#3394)
Update the modelConflict / mappingConflict strings (en + zh) to state that
model names are matched case-insensitively, so an existing entry (e.g.
"GLM-5.2") already covers all case variants and the lowercase variant does not
need to be added. This addresses the confusion in #3394 where the rejection of
a case-only duplicate looked like inconsistent behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:47:49 -07:00
harukaandClaude Opus 4.8 7c2fee6c99 fix(billing): dedup fallback pricing warn to stop per-request log spam (#3394)
The "[Billing] Using fallback pricing for model: X" warn was emitted on
every request for any model not in LiteLLM but matched by getFallbackPricing
(e.g. "glm-5.2" substring-matches the "glm-5" fallback price). Via the stdlib
log bridge it is inferred as WARN and persisted to ops_system_logs, producing
tens of thousands of rows/day. It fired even when channel pricing already
overrides the price, and from several call sites (resolver, account-stats
pricing, non-channel billing, admin lookup).

Dedup the warn at the source (BillingService.GetModelPricing) via a sync.Map
keyed by the already-lowercased model name, so each model logs at most once
per process while keeping one audit line. Billing amounts are unchanged.

Also clarify the model/mapping pattern conflict error to state that names are
matched case-insensitively, so an existing entry (e.g. "GLM-5.2") already
covers all case variants and the lowercase variant need not be added — the
behavior the issue mistook for a case-sensitivity bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:46:52 -07:00
欧阳丹 df51edfb06 Preserve OAuth instructions while keeping developer input 2026-06-18 03:15:31 +08:00
方程andClaude Opus 4.7 6bf6722bae chore: gofmt test file
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 17:45:09 +08:00
方程andClaude Opus 4.7 0da1fe28ef fix(openai): prevent false image billing on text-only /v1/responses requests
Two defects in the image output counter could inflate imageCount for
text-only responses, causing per-image charges instead of token billing:

1. addDataArray counted every element in a top-level "data" array
   without checking whether each element contained actual image output
   (url/b64_json).  Non-image objects in a "data" array are now skipped.

2. addImageOutputItem accepted image_generation.completed items with
   no result/b64_json/url, inflating the count.  These items are now
   filtered the same way as every other item type.

Fixes #3196

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 16:56:43 +08:00
470 changed files with 33403 additions and 4492 deletions
+2
View File
@@ -119,6 +119,8 @@ tests
CLAUDE.md
.claude
scripts
!backend/scripts/
!backend/scripts/resolve-version.sh
.code-review-state
#openspec/
code-reviews/
+2 -2
View File
@@ -67,9 +67,9 @@ COPY backend/ ./
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
# Build the binary (BuildType=release for CI builds, embed frontend)
# Version precedence: build arg VERSION > cmd/server/VERSION
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
RUN VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
CGO_ENABLED=0 GOOS=linux go build \
-tags embed \
+53 -32
View File
@@ -23,10 +23,11 @@ Please read the following carefully before using this project:
- **🚨 Terms of Service Risk**: Using this project may violate the terms of service of Anthropic and other upstream providers. Please review the relevant providers' user agreements before use; all risks arising from such use are borne solely by the user.
- **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited.
- **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project.
- **🚫 No Commercial Authorization**: The developers of this project have never authorized any individual or organization to conduct any form of commercial operation based on this project. Any commercial activity conducted in the name of or based on this project is unrelated to this project and its developers, and all resulting disputes, losses, and legal liabilities shall be borne solely by the party conducting such activity.
## ❤️ Sponsors
> [Want to appear here?](mailto:support@pincc.ai)
> [Want to appear here?](mailto:support@sub2api.org)
<table>
@@ -45,11 +46,6 @@ Please read the following carefully before using this project:
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
<td>Thanks to SilkAPI for sponsoring this project! <a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
@@ -95,8 +91,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.com">Register now</a> for a 1:1 top-up bonus — double your balance!
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.ai">Register now</a> for a 1:1 top-up bonus — double your balance!
</td>
</tr>
@@ -133,6 +129,22 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.<br>
Self-built primary account pool: 1-3s ultra-fast response, supports channel-partner distribution<br>
Extreme stability: multi-line intelligent routing + redundant backup system, ensuring year-round high-availability operation;<br>
Model authenticity: no content intervention or secondary filtering — experience the purest, most powerful native model capabilities.<br>
1:1 top-up, enterprise-grade service with invoicing available. Anpin AI is not just a relay — it's your secure, reliable, and efficient bridge to the frontier world of intelligence.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
</td>
</tr>
</table>
## Overview
@@ -447,7 +459,8 @@ pnpm run build
# 4. Build backend with embedded frontend
cd ../backend
go build -tags embed -o sub2api ./cmd/server
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. Create configuration file
cp ../deploy/config.example.yaml ./config.yaml
@@ -510,20 +523,20 @@ Additional security-related options are available in `config.yaml`:
**⚠️ Security Warning: HTTP URL Configuration**
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set:
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only:
```yaml
security:
url_allowlist:
enabled: false # Disable allowlist checks
allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE)
allow_insecure_http: false # HTTPS only (recommended for production)
```
**Or via environment variable:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**Risks of allowing HTTP:**
@@ -537,7 +550,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
- ✅ Testing account connectivity before obtaining HTTPS
- ❌ Production environments (use HTTPS only)
**Example error without this setting:**
**Example error for HTTP URLs when `allow_insecure_http: false` is set:**
```
Invalid base URL: invalid url scheme: http
```
@@ -548,6 +561,25 @@ If you disable URL validation or response header filtering, harden your network
- Enforce TLS-only outbound traffic
- Strip sensitive upstream response headers at the proxy
#### ⚠️ Important: Creating the Admin Account
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
Because step 5 above pre-creates `config.yaml`, the setup wizard will be **skipped on first run**: the server detects an existing config and boots straight into normal mode with an empty `users` table, so the first login attempt fails with `invalid email or password`.
**Two ways to create the admin account:**
1. **Recommended — let the wizard generate `config.yaml`:** Skip step 5 (do not run the `cp`). Start `./sub2api` directly; the setup wizard at `http://localhost:8080` walks you through database, Redis, and admin account setup, then writes `config.yaml` for you.
2. **If you already created `config.yaml`:** Temporarily move it aside so the wizard can trigger on first run, then restore it afterwards:
```bash
mv config.yaml config.yaml.bak
./sub2api # wizard runs at http://localhost:8080 and writes a fresh config.yaml
# stop the server (Ctrl+C) once the wizard completes, then restore your config:
mv config.yaml.bak config.yaml
./sub2api # restart in normal mode and log in with the admin you just created
```
```bash
# 6. Run the application
./sub2api
@@ -595,9 +627,14 @@ Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenA
- Platform name: `grok`
- Account type: OAuth subscription accounts
- Public gateway target: `/v1/responses` and `/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
- Initial models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Out of scope for this provider: public Grok Chat Completions routes, image, video, TTS, transcription, browser automation, cookies, and Grok web scraping
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission.
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
### OAuth Configuration
@@ -655,12 +692,6 @@ Antigravity accounts support optional **hybrid scheduling**. When enabled, the g
> **⚠️ Warning**: Anthropic Claude and Antigravity Claude **cannot be mixed within the same conversation context**. Use groups to isolate them properly.
### Known Issues
In Claude Code, Plan Mode cannot exit automatically. (Normally when using the native Claude API, after planning is complete, Claude Code will pop up options for users to approve or reject the plan.)
**Workaround**: Press `Shift + Tab` to manually exit Plan Mode, then type your response to approve or reject the plan.
---
## Project Structure
@@ -691,16 +722,6 @@ sub2api/
└── install.sh # One-click installation script
```
## Disclaimer
> **Please read carefully before using this project:**
>
> :rotating_light: **Terms of Service Risk**: Using this project may violate Anthropic's Terms of Service. Please read Anthropic's user agreement carefully before use. All risks arising from the use of this project are borne solely by the user.
>
> :book: **Disclaimer**: This project is for technical learning and research purposes only. The author assumes no responsibility for account suspension, service interruption, or any other losses caused by the use of this project.
---
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
+46 -27
View File
@@ -24,10 +24,11 @@
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
- **🚫 无商业授权**:本项目从未授权任何个人或组织基于本项目开展任何形式的商业化运营。任何以本项目名义或基于本项目从事的商业行为均与本项目及其开发者无关,由此产生的一切纠纷、损失和法律责任由行为主体自行承担。
## ❤️ 赞助商
> [想出现在这里?](mailto:support@pincc.ai)
> [想出现在这里?](mailto:support@sub2api.org)
<table>
@@ -46,11 +47,6 @@
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
<td>感谢 丝绸API 赞助了本项目! <a href="https://code.silkapi.com/register?aff=SUB2API">丝绸API</a> 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
@@ -98,8 +94,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.com">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.ai">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
</td>
</tr>
@@ -136,6 +132,23 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
自建一手号池:1-3S超快响应 支持同行分发<br>
极致稳定:多线智能路由 + 冗余备份系统,确保服务全年无休、高可用运行;<br>
模型真实性:不做任何内容干预与二次过滤,让您体验到最纯粹、最强大的原生模型能力。<br>
充值1:1 企业级服务可开票,安品Ai不只是中转站,更是您连接前沿智能世界的安全、可靠、高效桥梁
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
</td>
</tr>
</table>
## 项目概述
@@ -462,7 +475,8 @@ pnpm run build
# 4. 编译后端(嵌入前端)
cd ../backend
go build -tags embed -o sub2api ./cmd/server
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. 创建配置文件
cp ../deploy/config.example.yaml ./config.yaml
@@ -554,20 +568,20 @@ gateway:
**⚠️ 安全警告:HTTP URL 配置**
当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置:
当 `security.url_allowlist.enabled=false` 时,系统仅执行最小 URL 校验,且**默认允许 HTTP URL**(开发友好模式,Docker Compose 部署的默认值一致)。生产环境建议显式收紧为仅允许 HTTPS:
```yaml
security:
url_allowlist:
enabled: false # 禁用白名单检查
allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全)
allow_insecure_http: false # 仅允许 HTTPS(生产环境推荐)
```
**或通过环境变量:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**允许 HTTP 的风险:**
@@ -581,7 +595,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
- ✅ 获取 HTTPS 前测试账号连通性
- ❌ 生产环境(仅使用 HTTPS)
**未设置此项时的错误示例:**
**设置 `allow_insecure_http: false` 后,HTTP URL 会返回如下错误:**
```
Invalid base URL: invalid url scheme: http
```
@@ -592,6 +606,25 @@ Invalid base URL: invalid url scheme: http
- 强制仅允许 TLS 出站
- 在反向代理层移除敏感响应头
#### ⚠️ 重要:创建管理员账号
初始管理员账号**只能通过 setup 向导创建**(首次启动时访问 `http://<host>:8080`)。`config.yaml` 中的 `default.admin_email` / `default.admin_password` 字段**不会被用来创建管理员**——它们只是出于历史原因保留在模板里。
由于上面第 5 步预先创建了 `config.yaml`,**setup 向导在首次启动时会被跳过**:服务检测到 config 已存在,会直接进入正常模式,此时 `users` 表为空,首次登录会返回 `invalid email or password`。
**创建管理员的两种方式:**
1. **推荐——让向导自动生成 `config.yaml`:** 跳过上面的第 5 步(不要执行 `cp`)。直接运行 `./sub2api`,访问 `http://localhost:8080`,向导会引导你完成数据库、Redis 和管理员账号配置,并自动写出 `config.yaml`。
2. **如果你已经创建了 `config.yaml`:** 首次启动前先把它临时移走以触发向导,完成后再恢复:
```bash
mv config.yaml config.yaml.bak
./sub2api # 向导在 http://localhost:8080 启动,并生成新的 config.yaml
# 向导完成后 Ctrl+C 停服,再恢复你的配置:
mv config.yaml.bak config.yaml
./sub2api # 重启进入正常模式,用刚创建的管理员登录
```
```bash
# 6. 运行应用
./sub2api
@@ -678,10 +711,6 @@ Antigravity 账户支持可选的**混合调度**功能。开启后,通用端
> **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。
### 已知问题
在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。)
解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan
---
## 项目结构
@@ -712,16 +741,6 @@ sub2api/
└── install.sh # 一键安装脚本
```
## 免责声明
> **使用本项目前请仔细阅读:**
>
> :rotating_light: **服务条款风险**: 使用本项目可能违反 Anthropic 的服务条款。请在使用前仔细阅读 Anthropic 的用户协议,使用本项目的一切风险由用户自行承担。
>
> :book: **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
---
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
+45 -29
View File
@@ -23,10 +23,11 @@
- **🚨 利用規約のリスク**:本プロジェクトの使用は、Anthropic をはじめとする上流プロバイダーの利用規約に違反する可能性があります。ご利用前に各プロバイダーのユーザー規約を必ずご確認ください。使用により生じるすべてのリスクはユーザーご自身が負うものとします。
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
- **🚫 商用利用の非許諾**:本プロジェクトの開発者は、いかなる個人または組織に対しても、本プロジェクトを利用したいかなる形態の商業運営も一切許諾していません。本プロジェクトの名義で、または本プロジェクトに基づいて行われる商業行為はすべて本プロジェクトおよびその開発者とは無関係であり、それにより生じる一切の紛争、損失、法的責任は行為者自身が負うものとします。
## ❤️ スポンサー
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
> [こちらに掲載しませんか?](mailto:support@sub2api.org)
<table>
@@ -45,11 +46,6 @@
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
</tr>
<tr>
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
<td>SilkAPI のご支援に感謝します!<a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
@@ -97,8 +93,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.com">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.ai">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
</td>
</tr>
@@ -131,6 +127,22 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
自社構築の一次アカウントプール:1-3秒の超高速応答、同業者への配布に対応<br>
極めて高い安定性:マルチライン・インテリジェントルーティング + 冗長バックアップシステムにより、年中無休の高可用性運用を確保;<br>
モデルの真正性:一切のコンテンツ介入や二次フィルタリングを行わず、最も純粋で強力なネイティブモデル能力を体験できます。<br>
チャージ 1:1、エンタープライズグレードサービスは請求書発行可能。安品 AI は単なる中継所ではなく、最先端のインテリジェント世界へと繋がる安全・信頼性・高効率の橋渡し役です。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
</td>
</tr>
</table>
## 概要
@@ -445,7 +457,8 @@ pnpm run build
# 4. フロントエンドを組み込んだバックエンドをビルド
cd ../backend
go build -tags embed -o sub2api ./cmd/server
VERSION="$(./scripts/resolve-version.sh)"
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
# 5. 設定ファイルを作成
cp ../deploy/config.example.yaml ./config.yaml
@@ -508,20 +521,20 @@ default:
**⚠️ セキュリティ警告: HTTP URL 設定**
`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
`security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します:
```yaml
security:
url_allowlist:
enabled: false # 許可リストチェックを無効化
allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
allow_insecure_http: false # HTTPS のみ許可(本番環境推奨)
```
**または環境変数で設定:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**HTTP を許可するリスク:**
@@ -535,7 +548,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
- ✅ HTTPS 取得前のアカウント接続テスト
- ❌ 本番環境(HTTPS のみを使用)
**この設定なしで表示されるエラー例:**
**`allow_insecure_http: false` 設定時に HTTP URL で表示されるエラー例:**
```
Invalid base URL: invalid url scheme: http
```
@@ -546,6 +559,25 @@ URL バリデーションまたはレスポンスヘッダーフィルタリン
- TLS のみのアウトバウンドトラフィックを強制
- プロキシで機密性の高い上流レスポンスヘッダーを除去
#### ⚠️ 重要:管理者アカウントの作成
初期管理者アカウントは**セットアップウィザード経由でのみ作成**されます(初回起動時に `http://<host>:8080` にアクセス)。`config.yaml` の `default.admin_email` / `default.admin_password` フィールドは**管理者作成には使われません**。テンプレートに残っているのは歴史的経緯によるものです。
上記ステップ 5 で事前に `config.yaml` を作成しているため、**初回起動時にセットアップウィザードはスキップされます**。サーバーは既存の config を検出して通常モードで直接起動し、この時点では `users` テーブルが空のため、初回ログインは `invalid email or password` を返します。
**管理者アカウントを作成する 2 つの方法:**
1. **推奨 — ウィザードに `config.yaml` を自動生成させる:** 上記ステップ 5 をスキップします(`cp` を実行しない)。`./sub2api` を直接起動し、`http://localhost:8080` にアクセスすると、セットアップウィザードがデータベース・Redis・管理者アカウントの設定を案内し、`config.yaml` を自動生成します。
2. **すでに `config.yaml` を作成してしまった場合:** 初回起動前に一時的に退避してウィザードを発生させ、完了後に戻します:
```bash
mv config.yaml config.yaml.bak
./sub2api # ウィザードが http://localhost:8080 で起動し、新しい config.yaml を生成します
# ウィザード完了後、Ctrl+C でサーバーを停止し、設定を復元します:
mv config.yaml.bak config.yaml
./sub2api # 通常モードで再起動し、作成した管理者でログインします
```
```bash
# 6. アプリケーションを実行
./sub2api
@@ -609,12 +641,6 @@ Antigravity アカウントはオプションの**ハイブリッドスケジュ
> **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。
### 既知の問題
Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。)
**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。
---
## プロジェクト構成
@@ -645,16 +671,6 @@ sub2api/
└── install.sh # ワンクリックインストールスクリプト
```
## 免責事項
> **本プロジェクトをご利用の前に、以下をよくお読みください:**
>
> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。
>
> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
---
## スター履歴
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+2 -1
View File
@@ -15,7 +15,8 @@ RUN go mod download
COPY . .
# 构建应用
RUN go build -o main ./cmd/server/
RUN VERSION_VALUE="$(./scripts/resolve-version.sh)" && \
go build -ldflags="-s -w -X main.Version=${VERSION_VALUE}" -o main ./cmd/server/
# 暴露端口
EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
.PHONY: build generate test test-unit test-integration test-e2e
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
VERSION ?= $(shell ./scripts/resolve-version.sh)
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
build:
+1 -1
View File
@@ -1 +1 @@
0.1.138
0.1.144
+3 -3
View File
@@ -182,9 +182,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
usageCache := service.NewUsageCache()
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
@@ -195,7 +196,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupHandler := admin.NewBackupHandler(backupService, userService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
+64 -5
View File
@@ -77,6 +77,10 @@ type Account struct {
SessionWindowEnd *time.Time `json:"session_window_end,omitempty"`
// SessionWindowStatus holds the value of the "session_window_status" field.
SessionWindowStatus *string `json:"session_window_status,omitempty"`
// Parent account id for a linked spark shadow (NULL = normal).
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
// 'global' (default) or 'spark' (shadow reads codex_bengalfox).
QuotaDimension account.QuotaDimension `json:"quota_dimension,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the AccountQuery when eager-loading is set.
Edges AccountEdges `json:"edges"`
@@ -89,13 +93,17 @@ type AccountEdges struct {
Groups []*Group `json:"groups,omitempty"`
// Proxy holds the value of the proxy edge.
Proxy *Proxy `json:"proxy,omitempty"`
// Parent holds the value of the parent edge.
Parent *Account `json:"parent,omitempty"`
// Children holds the value of the children edge.
Children []*Account `json:"children,omitempty"`
// UsageLogs holds the value of the usage_logs edge.
UsageLogs []*UsageLog `json:"usage_logs,omitempty"`
// AccountGroups holds the value of the account_groups edge.
AccountGroups []*AccountGroup `json:"account_groups,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [4]bool
loadedTypes [6]bool
}
// GroupsOrErr returns the Groups value or an error if the edge
@@ -118,10 +126,30 @@ func (e AccountEdges) ProxyOrErr() (*Proxy, error) {
return nil, &NotLoadedError{edge: "proxy"}
}
// ParentOrErr returns the Parent value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e AccountEdges) ParentOrErr() (*Account, error) {
if e.Parent != nil {
return e.Parent, nil
} else if e.loadedTypes[2] {
return nil, &NotFoundError{label: account.Label}
}
return nil, &NotLoadedError{edge: "parent"}
}
// ChildrenOrErr returns the Children value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) ChildrenOrErr() ([]*Account, error) {
if e.loadedTypes[3] {
return e.Children, nil
}
return nil, &NotLoadedError{edge: "children"}
}
// UsageLogsOrErr returns the UsageLogs value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
if e.loadedTypes[2] {
if e.loadedTypes[4] {
return e.UsageLogs, nil
}
return nil, &NotLoadedError{edge: "usage_logs"}
@@ -130,7 +158,7 @@ func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
// AccountGroupsOrErr returns the AccountGroups value or an error if the edge
// was not loaded in eager-loading.
func (e AccountEdges) AccountGroupsOrErr() ([]*AccountGroup, error) {
if e.loadedTypes[3] {
if e.loadedTypes[5] {
return e.AccountGroups, nil
}
return nil, &NotLoadedError{edge: "account_groups"}
@@ -147,9 +175,9 @@ func (*Account) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case account.FieldRateMultiplier:
values[i] = new(sql.NullFloat64)
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority:
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority, account.FieldParentAccountID:
values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus:
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus, account.FieldQuotaDimension:
values[i] = new(sql.NullString)
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldTempUnschedulableUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
values[i] = new(sql.NullTime)
@@ -368,6 +396,19 @@ func (_m *Account) assignValues(columns []string, values []any) error {
_m.SessionWindowStatus = new(string)
*_m.SessionWindowStatus = value.String
}
case account.FieldParentAccountID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field parent_account_id", values[i])
} else if value.Valid {
_m.ParentAccountID = new(int64)
*_m.ParentAccountID = value.Int64
}
case account.FieldQuotaDimension:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field quota_dimension", values[i])
} else if value.Valid {
_m.QuotaDimension = account.QuotaDimension(value.String)
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -391,6 +432,16 @@ func (_m *Account) QueryProxy() *ProxyQuery {
return NewAccountClient(_m.config).QueryProxy(_m)
}
// QueryParent queries the "parent" edge of the Account entity.
func (_m *Account) QueryParent() *AccountQuery {
return NewAccountClient(_m.config).QueryParent(_m)
}
// QueryChildren queries the "children" edge of the Account entity.
func (_m *Account) QueryChildren() *AccountQuery {
return NewAccountClient(_m.config).QueryChildren(_m)
}
// QueryUsageLogs queries the "usage_logs" edge of the Account entity.
func (_m *Account) QueryUsageLogs() *UsageLogQuery {
return NewAccountClient(_m.config).QueryUsageLogs(_m)
@@ -542,6 +593,14 @@ func (_m *Account) String() string {
builder.WriteString("session_window_status=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.ParentAccountID; v != nil {
builder.WriteString("parent_account_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("quota_dimension=")
builder.WriteString(fmt.Sprintf("%v", _m.QuotaDimension))
builder.WriteByte(')')
return builder.String()
}
+90
View File
@@ -3,6 +3,7 @@
package account
import (
"fmt"
"time"
"entgo.io/ent"
@@ -73,10 +74,18 @@ const (
FieldSessionWindowEnd = "session_window_end"
// FieldSessionWindowStatus holds the string denoting the session_window_status field in the database.
FieldSessionWindowStatus = "session_window_status"
// FieldParentAccountID holds the string denoting the parent_account_id field in the database.
FieldParentAccountID = "parent_account_id"
// FieldQuotaDimension holds the string denoting the quota_dimension field in the database.
FieldQuotaDimension = "quota_dimension"
// EdgeGroups holds the string denoting the groups edge name in mutations.
EdgeGroups = "groups"
// EdgeProxy holds the string denoting the proxy edge name in mutations.
EdgeProxy = "proxy"
// EdgeParent holds the string denoting the parent edge name in mutations.
EdgeParent = "parent"
// EdgeChildren holds the string denoting the children edge name in mutations.
EdgeChildren = "children"
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
EdgeUsageLogs = "usage_logs"
// EdgeAccountGroups holds the string denoting the account_groups edge name in mutations.
@@ -95,6 +104,14 @@ const (
ProxyInverseTable = "proxies"
// ProxyColumn is the table column denoting the proxy relation/edge.
ProxyColumn = "proxy_id"
// ParentTable is the table that holds the parent relation/edge.
ParentTable = "accounts"
// ParentColumn is the table column denoting the parent relation/edge.
ParentColumn = "parent_account_id"
// ChildrenTable is the table that holds the children relation/edge.
ChildrenTable = "accounts"
// ChildrenColumn is the table column denoting the children relation/edge.
ChildrenColumn = "parent_account_id"
// UsageLogsTable is the table that holds the usage_logs relation/edge.
UsageLogsTable = "usage_logs"
// UsageLogsInverseTable is the table name for the UsageLog entity.
@@ -143,6 +160,8 @@ var Columns = []string{
FieldSessionWindowStart,
FieldSessionWindowEnd,
FieldSessionWindowStatus,
FieldParentAccountID,
FieldQuotaDimension,
}
var (
@@ -203,6 +222,32 @@ var (
SessionWindowStatusValidator func(string) error
)
// QuotaDimension defines the type for the "quota_dimension" enum field.
type QuotaDimension string
// QuotaDimensionGlobal is the default value of the QuotaDimension enum.
const DefaultQuotaDimension = QuotaDimensionGlobal
// QuotaDimension values.
const (
QuotaDimensionGlobal QuotaDimension = "global"
QuotaDimensionSpark QuotaDimension = "spark"
)
func (qd QuotaDimension) String() string {
return string(qd)
}
// QuotaDimensionValidator is a validator for the "quota_dimension" field enum values. It is called by the builders before save.
func QuotaDimensionValidator(qd QuotaDimension) error {
switch qd {
case QuotaDimensionGlobal, QuotaDimensionSpark:
return nil
default:
return fmt.Errorf("account: invalid enum value for quota_dimension field: %q", qd)
}
}
// OrderOption defines the ordering options for the Account queries.
type OrderOption func(*sql.Selector)
@@ -346,6 +391,16 @@ func BySessionWindowStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSessionWindowStatus, opts...).ToFunc()
}
// ByParentAccountID orders the results by the parent_account_id field.
func ByParentAccountID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldParentAccountID, opts...).ToFunc()
}
// ByQuotaDimension orders the results by the quota_dimension field.
func ByQuotaDimension(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldQuotaDimension, opts...).ToFunc()
}
// ByGroupsCount orders the results by groups count.
func ByGroupsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -367,6 +422,27 @@ func ByProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
}
}
// ByParentField orders the results by parent field.
func ByParentField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newParentStep(), sql.OrderByField(field, opts...))
}
}
// ByChildrenCount orders the results by children count.
func ByChildrenCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newChildrenStep(), opts...)
}
}
// ByChildren orders the results by children terms.
func ByChildren(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newChildrenStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByUsageLogsCount orders the results by usage_logs count.
func ByUsageLogsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -408,6 +484,20 @@ func newProxyStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, false, ProxyTable, ProxyColumn),
)
}
func newParentStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
)
}
func newChildrenStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
)
}
func newUsageLogsStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
+101
View File
@@ -190,6 +190,11 @@ func SessionWindowStatus(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSessionWindowStatus, v))
}
// ParentAccountID applies equality check predicate on the "parent_account_id" field. It's identical to ParentAccountIDEQ.
func ParentAccountID(v int64) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldCreatedAt, v))
@@ -1550,6 +1555,56 @@ func SessionWindowStatusContainsFold(v string) predicate.Account {
return predicate.Account(sql.FieldContainsFold(FieldSessionWindowStatus, v))
}
// ParentAccountIDEQ applies the EQ predicate on the "parent_account_id" field.
func ParentAccountIDEQ(v int64) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
}
// ParentAccountIDNEQ applies the NEQ predicate on the "parent_account_id" field.
func ParentAccountIDNEQ(v int64) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldParentAccountID, v))
}
// ParentAccountIDIn applies the In predicate on the "parent_account_id" field.
func ParentAccountIDIn(vs ...int64) predicate.Account {
return predicate.Account(sql.FieldIn(FieldParentAccountID, vs...))
}
// ParentAccountIDNotIn applies the NotIn predicate on the "parent_account_id" field.
func ParentAccountIDNotIn(vs ...int64) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldParentAccountID, vs...))
}
// ParentAccountIDIsNil applies the IsNil predicate on the "parent_account_id" field.
func ParentAccountIDIsNil() predicate.Account {
return predicate.Account(sql.FieldIsNull(FieldParentAccountID))
}
// ParentAccountIDNotNil applies the NotNil predicate on the "parent_account_id" field.
func ParentAccountIDNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldParentAccountID))
}
// QuotaDimensionEQ applies the EQ predicate on the "quota_dimension" field.
func QuotaDimensionEQ(v QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldQuotaDimension, v))
}
// QuotaDimensionNEQ applies the NEQ predicate on the "quota_dimension" field.
func QuotaDimensionNEQ(v QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldQuotaDimension, v))
}
// QuotaDimensionIn applies the In predicate on the "quota_dimension" field.
func QuotaDimensionIn(vs ...QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldIn(FieldQuotaDimension, vs...))
}
// QuotaDimensionNotIn applies the NotIn predicate on the "quota_dimension" field.
func QuotaDimensionNotIn(vs ...QuotaDimension) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldQuotaDimension, vs...))
}
// HasGroups applies the HasEdge predicate on the "groups" edge.
func HasGroups() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
@@ -1596,6 +1651,52 @@ func HasProxyWith(preds ...predicate.Proxy) predicate.Account {
})
}
// HasParent applies the HasEdge predicate on the "parent" edge.
func HasParent() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasParentWith applies the HasEdge predicate on the "parent" edge with a given conditions (other predicates).
func HasParentWith(preds ...predicate.Account) predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := newParentStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasChildren applies the HasEdge predicate on the "children" edge.
func HasChildren() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasChildrenWith applies the HasEdge predicate on the "children" edge with a given conditions (other predicates).
func HasChildrenWith(preds ...predicate.Account) predicate.Account {
return predicate.Account(func(s *sql.Selector) {
step := newChildrenStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasUsageLogs applies the HasEdge predicate on the "usage_logs" edge.
func HasUsageLogs() predicate.Account {
return predicate.Account(func(s *sql.Selector) {
+211
View File
@@ -391,6 +391,34 @@ func (_c *AccountCreate) SetNillableSessionWindowStatus(v *string) *AccountCreat
return _c
}
// SetParentAccountID sets the "parent_account_id" field.
func (_c *AccountCreate) SetParentAccountID(v int64) *AccountCreate {
_c.mutation.SetParentAccountID(v)
return _c
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_c *AccountCreate) SetNillableParentAccountID(v *int64) *AccountCreate {
if v != nil {
_c.SetParentAccountID(*v)
}
return _c
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_c *AccountCreate) SetQuotaDimension(v account.QuotaDimension) *AccountCreate {
_c.mutation.SetQuotaDimension(v)
return _c
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_c *AccountCreate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountCreate {
if v != nil {
_c.SetQuotaDimension(*v)
}
return _c
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_c *AccountCreate) AddGroupIDs(ids ...int64) *AccountCreate {
_c.mutation.AddGroupIDs(ids...)
@@ -411,6 +439,40 @@ func (_c *AccountCreate) SetProxy(v *Proxy) *AccountCreate {
return _c.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_c *AccountCreate) SetParentID(id int64) *AccountCreate {
_c.mutation.SetParentID(id)
return _c
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_c *AccountCreate) SetNillableParentID(id *int64) *AccountCreate {
if id != nil {
_c = _c.SetParentID(*id)
}
return _c
}
// SetParent sets the "parent" edge to the Account entity.
func (_c *AccountCreate) SetParent(v *Account) *AccountCreate {
return _c.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_c *AccountCreate) AddChildIDs(ids ...int64) *AccountCreate {
_c.mutation.AddChildIDs(ids...)
return _c
}
// AddChildren adds the "children" edges to the Account entity.
func (_c *AccountCreate) AddChildren(v ...*Account) *AccountCreate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_c *AccountCreate) AddUsageLogIDs(ids ...int64) *AccountCreate {
_c.mutation.AddUsageLogIDs(ids...)
@@ -515,6 +577,10 @@ func (_c *AccountCreate) defaults() error {
v := account.DefaultSchedulable
_c.mutation.SetSchedulable(v)
}
if _, ok := _c.mutation.QuotaDimension(); !ok {
v := account.DefaultQuotaDimension
_c.mutation.SetQuotaDimension(v)
}
return nil
}
@@ -584,6 +650,14 @@ func (_c *AccountCreate) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if _, ok := _c.mutation.QuotaDimension(); !ok {
return &ValidationError{Name: "quota_dimension", err: errors.New(`ent: missing required field "Account.quota_dimension"`)}
}
if v, ok := _c.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -723,6 +797,10 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_spec.SetField(account.FieldSessionWindowStatus, field.TypeString, value)
_node.SessionWindowStatus = &value
}
if value, ok := _c.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
_node.QuotaDimension = value
}
if nodes := _c.mutation.GroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -760,6 +838,39 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_node.ProxyID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_node.ParentAccountID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.UsageLogsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1290,6 +1401,36 @@ func (u *AccountUpsert) ClearSessionWindowStatus() *AccountUpsert {
return u
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsert) SetParentAccountID(v int64) *AccountUpsert {
u.Set(account.FieldParentAccountID, v)
return u
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsert) UpdateParentAccountID() *AccountUpsert {
u.SetExcluded(account.FieldParentAccountID)
return u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsert) ClearParentAccountID() *AccountUpsert {
u.SetNull(account.FieldParentAccountID)
return u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsert) SetQuotaDimension(v account.QuotaDimension) *AccountUpsert {
u.Set(account.FieldQuotaDimension, v)
return u
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsert) UpdateQuotaDimension() *AccountUpsert {
u.SetExcluded(account.FieldQuotaDimension)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1874,6 +2015,41 @@ func (u *AccountUpsertOne) ClearSessionWindowStatus() *AccountUpsertOne {
})
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsertOne) SetParentAccountID(v int64) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetParentAccountID(v)
})
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateParentAccountID() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateParentAccountID()
})
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsertOne) ClearParentAccountID() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.ClearParentAccountID()
})
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsertOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetQuotaDimension(v)
})
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateQuotaDimension() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateQuotaDimension()
})
}
// Exec executes the query.
func (u *AccountUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -2624,6 +2800,41 @@ func (u *AccountUpsertBulk) ClearSessionWindowStatus() *AccountUpsertBulk {
})
}
// SetParentAccountID sets the "parent_account_id" field.
func (u *AccountUpsertBulk) SetParentAccountID(v int64) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetParentAccountID(v)
})
}
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateParentAccountID() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateParentAccountID()
})
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (u *AccountUpsertBulk) ClearParentAccountID() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.ClearParentAccountID()
})
}
// SetQuotaDimension sets the "quota_dimension" field.
func (u *AccountUpsertBulk) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetQuotaDimension(v)
})
}
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateQuotaDimension() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateQuotaDimension()
})
}
// Exec executes the query.
func (u *AccountUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
+154 -1
View File
@@ -30,6 +30,8 @@ type AccountQuery struct {
predicates []predicate.Account
withGroups *GroupQuery
withProxy *ProxyQuery
withParent *AccountQuery
withChildren *AccountQuery
withUsageLogs *UsageLogQuery
withAccountGroups *AccountGroupQuery
modifiers []func(*sql.Selector)
@@ -113,6 +115,50 @@ func (_q *AccountQuery) QueryProxy() *ProxyQuery {
return query
}
// QueryParent chains the current query on the "parent" edge.
func (_q *AccountQuery) QueryParent() *AccountQuery {
query := (&AccountClient{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(account.Table, account.FieldID, selector),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryChildren chains the current query on the "children" edge.
func (_q *AccountQuery) QueryChildren() *AccountQuery {
query := (&AccountClient{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(account.Table, account.FieldID, selector),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryUsageLogs chains the current query on the "usage_logs" edge.
func (_q *AccountQuery) QueryUsageLogs() *UsageLogQuery {
query := (&UsageLogClient{config: _q.config}).Query()
@@ -351,6 +397,8 @@ func (_q *AccountQuery) Clone() *AccountQuery {
predicates: append([]predicate.Account{}, _q.predicates...),
withGroups: _q.withGroups.Clone(),
withProxy: _q.withProxy.Clone(),
withParent: _q.withParent.Clone(),
withChildren: _q.withChildren.Clone(),
withUsageLogs: _q.withUsageLogs.Clone(),
withAccountGroups: _q.withAccountGroups.Clone(),
// clone intermediate query.
@@ -381,6 +429,28 @@ func (_q *AccountQuery) WithProxy(opts ...func(*ProxyQuery)) *AccountQuery {
return _q
}
// WithParent tells the query-builder to eager-load the nodes that are connected to
// the "parent" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithParent(opts ...func(*AccountQuery)) *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withParent = query
return _q
}
// WithChildren tells the query-builder to eager-load the nodes that are connected to
// the "children" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithChildren(opts ...func(*AccountQuery)) *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withChildren = query
return _q
}
// WithUsageLogs tells the query-builder to eager-load the nodes that are connected to
// the "usage_logs" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *AccountQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *AccountQuery {
@@ -481,9 +551,11 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
var (
nodes = []*Account{}
_spec = _q.querySpec()
loadedTypes = [4]bool{
loadedTypes = [6]bool{
_q.withGroups != nil,
_q.withProxy != nil,
_q.withParent != nil,
_q.withChildren != nil,
_q.withUsageLogs != nil,
_q.withAccountGroups != nil,
}
@@ -522,6 +594,19 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
return nil, err
}
}
if query := _q.withParent; query != nil {
if err := _q.loadParent(ctx, query, nodes, nil,
func(n *Account, e *Account) { n.Edges.Parent = e }); err != nil {
return nil, err
}
}
if query := _q.withChildren; query != nil {
if err := _q.loadChildren(ctx, query, nodes,
func(n *Account) { n.Edges.Children = []*Account{} },
func(n *Account, e *Account) { n.Edges.Children = append(n.Edges.Children, e) }); err != nil {
return nil, err
}
}
if query := _q.withUsageLogs; query != nil {
if err := _q.loadUsageLogs(ctx, query, nodes,
func(n *Account) { n.Edges.UsageLogs = []*UsageLog{} },
@@ -632,6 +717,71 @@ func (_q *AccountQuery) loadProxy(ctx context.Context, query *ProxyQuery, nodes
}
return nil
}
func (_q *AccountQuery) loadParent(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*Account)
for i := range nodes {
if nodes[i].ParentAccountID == nil {
continue
}
fk := *nodes[i].ParentAccountID
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
nodeids[fk] = append(nodeids[fk], nodes[i])
}
if len(ids) == 0 {
return nil
}
query.Where(account.IDIn(ids...))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nodeids[n.ID]
if !ok {
return fmt.Errorf(`unexpected foreign-key "parent_account_id" returned %v`, n.ID)
}
for i := range nodes {
assign(nodes[i], n)
}
}
return nil
}
func (_q *AccountQuery) loadChildren(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Account)
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(account.FieldParentAccountID)
}
query.Where(predicate.Account(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(account.ChildrenColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
fk := n.ParentAccountID
if fk == nil {
return fmt.Errorf(`foreign-key "parent_account_id" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected referenced foreign-key "parent_account_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}
func (_q *AccountQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, nodes []*Account, init func(*Account), assign func(*Account, *UsageLog)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Account)
@@ -724,6 +874,9 @@ func (_q *AccountQuery) querySpec() *sqlgraph.QuerySpec {
if _q.withProxy != nil {
_spec.Node.AddColumnOnce(account.FieldProxyID)
}
if _q.withParent != nil {
_spec.Node.AddColumnOnce(account.FieldParentAccountID)
}
}
if ps := _q.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
+354
View File
@@ -530,6 +530,40 @@ func (_u *AccountUpdate) ClearSessionWindowStatus() *AccountUpdate {
return _u
}
// SetParentAccountID sets the "parent_account_id" field.
func (_u *AccountUpdate) SetParentAccountID(v int64) *AccountUpdate {
_u.mutation.SetParentAccountID(v)
return _u
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableParentAccountID(v *int64) *AccountUpdate {
if v != nil {
_u.SetParentAccountID(*v)
}
return _u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (_u *AccountUpdate) ClearParentAccountID() *AccountUpdate {
_u.mutation.ClearParentAccountID()
return _u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_u *AccountUpdate) SetQuotaDimension(v account.QuotaDimension) *AccountUpdate {
_u.mutation.SetQuotaDimension(v)
return _u
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdate {
if v != nil {
_u.SetQuotaDimension(*v)
}
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *AccountUpdate) AddGroupIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddGroupIDs(ids...)
@@ -550,6 +584,40 @@ func (_u *AccountUpdate) SetProxy(v *Proxy) *AccountUpdate {
return _u.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_u *AccountUpdate) SetParentID(id int64) *AccountUpdate {
_u.mutation.SetParentID(id)
return _u
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_u *AccountUpdate) SetNillableParentID(id *int64) *AccountUpdate {
if id != nil {
_u = _u.SetParentID(*id)
}
return _u
}
// SetParent sets the "parent" edge to the Account entity.
func (_u *AccountUpdate) SetParent(v *Account) *AccountUpdate {
return _u.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_u *AccountUpdate) AddChildIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddChildIDs(ids...)
return _u
}
// AddChildren adds the "children" edges to the Account entity.
func (_u *AccountUpdate) AddChildren(v ...*Account) *AccountUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_u *AccountUpdate) AddUsageLogIDs(ids ...int64) *AccountUpdate {
_u.mutation.AddUsageLogIDs(ids...)
@@ -597,6 +665,33 @@ func (_u *AccountUpdate) ClearProxy() *AccountUpdate {
return _u
}
// ClearParent clears the "parent" edge to the Account entity.
func (_u *AccountUpdate) ClearParent() *AccountUpdate {
_u.mutation.ClearParent()
return _u
}
// ClearChildren clears all "children" edges to the Account entity.
func (_u *AccountUpdate) ClearChildren() *AccountUpdate {
_u.mutation.ClearChildren()
return _u
}
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
func (_u *AccountUpdate) RemoveChildIDs(ids ...int64) *AccountUpdate {
_u.mutation.RemoveChildIDs(ids...)
return _u
}
// RemoveChildren removes "children" edges to Account entities.
func (_u *AccountUpdate) RemoveChildren(v ...*Account) *AccountUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveChildIDs(ids...)
}
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
func (_u *AccountUpdate) ClearUsageLogs() *AccountUpdate {
_u.mutation.ClearUsageLogs()
@@ -687,6 +782,11 @@ func (_u *AccountUpdate) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if v, ok := _u.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -843,6 +943,9 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.SessionWindowStatusCleared() {
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
}
if value, ok := _u.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
}
if _u.mutation.GroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -929,6 +1032,80 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ParentCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.UsageLogsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1493,6 +1670,40 @@ func (_u *AccountUpdateOne) ClearSessionWindowStatus() *AccountUpdateOne {
return _u
}
// SetParentAccountID sets the "parent_account_id" field.
func (_u *AccountUpdateOne) SetParentAccountID(v int64) *AccountUpdateOne {
_u.mutation.SetParentAccountID(v)
return _u
}
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableParentAccountID(v *int64) *AccountUpdateOne {
if v != nil {
_u.SetParentAccountID(*v)
}
return _u
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (_u *AccountUpdateOne) ClearParentAccountID() *AccountUpdateOne {
_u.mutation.ClearParentAccountID()
return _u
}
// SetQuotaDimension sets the "quota_dimension" field.
func (_u *AccountUpdateOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpdateOne {
_u.mutation.SetQuotaDimension(v)
return _u
}
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdateOne {
if v != nil {
_u.SetQuotaDimension(*v)
}
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *AccountUpdateOne) AddGroupIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddGroupIDs(ids...)
@@ -1513,6 +1724,40 @@ func (_u *AccountUpdateOne) SetProxy(v *Proxy) *AccountUpdateOne {
return _u.SetProxyID(v.ID)
}
// SetParentID sets the "parent" edge to the Account entity by ID.
func (_u *AccountUpdateOne) SetParentID(id int64) *AccountUpdateOne {
_u.mutation.SetParentID(id)
return _u
}
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableParentID(id *int64) *AccountUpdateOne {
if id != nil {
_u = _u.SetParentID(*id)
}
return _u
}
// SetParent sets the "parent" edge to the Account entity.
func (_u *AccountUpdateOne) SetParent(v *Account) *AccountUpdateOne {
return _u.SetParentID(v.ID)
}
// AddChildIDs adds the "children" edge to the Account entity by IDs.
func (_u *AccountUpdateOne) AddChildIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddChildIDs(ids...)
return _u
}
// AddChildren adds the "children" edges to the Account entity.
func (_u *AccountUpdateOne) AddChildren(v ...*Account) *AccountUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddChildIDs(ids...)
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
func (_u *AccountUpdateOne) AddUsageLogIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.AddUsageLogIDs(ids...)
@@ -1560,6 +1805,33 @@ func (_u *AccountUpdateOne) ClearProxy() *AccountUpdateOne {
return _u
}
// ClearParent clears the "parent" edge to the Account entity.
func (_u *AccountUpdateOne) ClearParent() *AccountUpdateOne {
_u.mutation.ClearParent()
return _u
}
// ClearChildren clears all "children" edges to the Account entity.
func (_u *AccountUpdateOne) ClearChildren() *AccountUpdateOne {
_u.mutation.ClearChildren()
return _u
}
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
func (_u *AccountUpdateOne) RemoveChildIDs(ids ...int64) *AccountUpdateOne {
_u.mutation.RemoveChildIDs(ids...)
return _u
}
// RemoveChildren removes "children" edges to Account entities.
func (_u *AccountUpdateOne) RemoveChildren(v ...*Account) *AccountUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveChildIDs(ids...)
}
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
func (_u *AccountUpdateOne) ClearUsageLogs() *AccountUpdateOne {
_u.mutation.ClearUsageLogs()
@@ -1663,6 +1935,11 @@ func (_u *AccountUpdateOne) check() error {
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
}
}
if v, ok := _u.mutation.QuotaDimension(); ok {
if err := account.QuotaDimensionValidator(v); err != nil {
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
}
}
return nil
}
@@ -1836,6 +2113,9 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if _u.mutation.SessionWindowStatusCleared() {
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
}
if value, ok := _u.mutation.QuotaDimension(); ok {
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
}
if _u.mutation.GroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -1922,6 +2202,80 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.ParentCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: true,
Table: account.ParentTable,
Columns: []string{account.ParentColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.ChildrenIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: false,
Table: account.ChildrenTable,
Columns: []string{account.ChildrenColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(account.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.UsageLogsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
+32
View File
@@ -820,6 +820,38 @@ func (c *AccountClient) QueryProxy(_m *Account) *ProxyQuery {
return query
}
// QueryParent queries the parent edge of a Account.
func (c *AccountClient) QueryParent(_m *Account) *AccountQuery {
query := (&AccountClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, id),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryChildren queries the children edge of a Account.
func (c *AccountClient) QueryChildren(_m *Account) *AccountQuery {
query := (&AccountClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(account.Table, account.FieldID, id),
sqlgraph.To(account.Table, account.FieldID),
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryUsageLogs queries the usage_logs edge of a Account.
func (c *AccountClient) QueryUsageLogs(_m *Account) *UsageLogQuery {
query := (&UsageLogClient{config: c.config}).Query()
+47 -3
View File
@@ -31,6 +31,14 @@ type Group struct {
Description *string `json:"description,omitempty"`
// RateMultiplier holds the value of the "rate_multiplier" field.
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
// 是否启用高峰时段倍率
PeakRateEnabled bool `json:"peak_rate_enabled,omitempty"`
// 高峰开始时间 HH:MM(含),如 14:00;空表示未配置
PeakStart string `json:"peak_start,omitempty"`
// 高峰结束时间 HH:MM(不含),如 18:00
PeakEnd string `json:"peak_end,omitempty"`
// 高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率
PeakRateMultiplier float64 `json:"peak_rate_multiplier,omitempty"`
// IsExclusive holds the value of the "is_exclusive" field.
IsExclusive bool `json:"is_exclusive,omitempty"`
// Status holds the value of the "status" field.
@@ -197,13 +205,13 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig:
values[i] = new([]byte)
case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
values[i] = new(sql.NullString)
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
values[i] = new(sql.NullTime)
@@ -266,6 +274,30 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.RateMultiplier = value.Float64
}
case group.FieldPeakRateEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field peak_rate_enabled", values[i])
} else if value.Valid {
_m.PeakRateEnabled = value.Bool
}
case group.FieldPeakStart:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field peak_start", values[i])
} else if value.Valid {
_m.PeakStart = value.String
}
case group.FieldPeakEnd:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field peak_end", values[i])
} else if value.Valid {
_m.PeakEnd = value.String
}
case group.FieldPeakRateMultiplier:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field peak_rate_multiplier", values[i])
} else if value.Valid {
_m.PeakRateMultiplier = value.Float64
}
case group.FieldIsExclusive:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field is_exclusive", values[i])
@@ -554,6 +586,18 @@ func (_m *Group) String() string {
builder.WriteString("rate_multiplier=")
builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier))
builder.WriteString(", ")
builder.WriteString("peak_rate_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.PeakRateEnabled))
builder.WriteString(", ")
builder.WriteString("peak_start=")
builder.WriteString(_m.PeakStart)
builder.WriteString(", ")
builder.WriteString("peak_end=")
builder.WriteString(_m.PeakEnd)
builder.WriteString(", ")
builder.WriteString("peak_rate_multiplier=")
builder.WriteString(fmt.Sprintf("%v", _m.PeakRateMultiplier))
builder.WriteString(", ")
builder.WriteString("is_exclusive=")
builder.WriteString(fmt.Sprintf("%v", _m.IsExclusive))
builder.WriteString(", ")
+44
View File
@@ -28,6 +28,14 @@ const (
FieldDescription = "description"
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
FieldRateMultiplier = "rate_multiplier"
// FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database.
FieldPeakRateEnabled = "peak_rate_enabled"
// FieldPeakStart holds the string denoting the peak_start field in the database.
FieldPeakStart = "peak_start"
// FieldPeakEnd holds the string denoting the peak_end field in the database.
FieldPeakEnd = "peak_end"
// FieldPeakRateMultiplier holds the string denoting the peak_rate_multiplier field in the database.
FieldPeakRateMultiplier = "peak_rate_multiplier"
// FieldIsExclusive holds the string denoting the is_exclusive field in the database.
FieldIsExclusive = "is_exclusive"
// FieldStatus holds the string denoting the status field in the database.
@@ -167,6 +175,10 @@ var Columns = []string{
FieldName,
FieldDescription,
FieldRateMultiplier,
FieldPeakRateEnabled,
FieldPeakStart,
FieldPeakEnd,
FieldPeakRateMultiplier,
FieldIsExclusive,
FieldStatus,
FieldPlatform,
@@ -235,6 +247,18 @@ var (
NameValidator func(string) error
// DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field.
DefaultRateMultiplier float64
// DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field.
DefaultPeakRateEnabled bool
// DefaultPeakStart holds the default value on creation for the "peak_start" field.
DefaultPeakStart string
// PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
PeakStartValidator func(string) error
// DefaultPeakEnd holds the default value on creation for the "peak_end" field.
DefaultPeakEnd string
// PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
PeakEndValidator func(string) error
// DefaultPeakRateMultiplier holds the default value on creation for the "peak_rate_multiplier" field.
DefaultPeakRateMultiplier float64
// DefaultIsExclusive holds the default value on creation for the "is_exclusive" field.
DefaultIsExclusive bool
// DefaultStatus holds the default value on creation for the "status" field.
@@ -323,6 +347,26 @@ func ByRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRateMultiplier, opts...).ToFunc()
}
// ByPeakRateEnabled orders the results by the peak_rate_enabled field.
func ByPeakRateEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPeakRateEnabled, opts...).ToFunc()
}
// ByPeakStart orders the results by the peak_start field.
func ByPeakStart(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPeakStart, opts...).ToFunc()
}
// ByPeakEnd orders the results by the peak_end field.
func ByPeakEnd(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPeakEnd, opts...).ToFunc()
}
// ByPeakRateMultiplier orders the results by the peak_rate_multiplier field.
func ByPeakRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPeakRateMultiplier, opts...).ToFunc()
}
// ByIsExclusive orders the results by the is_exclusive field.
func ByIsExclusive(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldIsExclusive, opts...).ToFunc()
+200
View File
@@ -85,6 +85,26 @@ func RateMultiplier(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRateMultiplier, v))
}
// PeakRateEnabled applies equality check predicate on the "peak_rate_enabled" field. It's identical to PeakRateEnabledEQ.
func PeakRateEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v))
}
// PeakStart applies equality check predicate on the "peak_start" field. It's identical to PeakStartEQ.
func PeakStart(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakStart, v))
}
// PeakEnd applies equality check predicate on the "peak_end" field. It's identical to PeakEndEQ.
func PeakEnd(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakEnd, v))
}
// PeakRateMultiplier applies equality check predicate on the "peak_rate_multiplier" field. It's identical to PeakRateMultiplierEQ.
func PeakRateMultiplier(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v))
}
// IsExclusive applies equality check predicate on the "is_exclusive" field. It's identical to IsExclusiveEQ.
func IsExclusive(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldIsExclusive, v))
@@ -520,6 +540,186 @@ func RateMultiplierLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldRateMultiplier, v))
}
// PeakRateEnabledEQ applies the EQ predicate on the "peak_rate_enabled" field.
func PeakRateEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v))
}
// PeakRateEnabledNEQ applies the NEQ predicate on the "peak_rate_enabled" field.
func PeakRateEnabledNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldPeakRateEnabled, v))
}
// PeakStartEQ applies the EQ predicate on the "peak_start" field.
func PeakStartEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakStart, v))
}
// PeakStartNEQ applies the NEQ predicate on the "peak_start" field.
func PeakStartNEQ(v string) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldPeakStart, v))
}
// PeakStartIn applies the In predicate on the "peak_start" field.
func PeakStartIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldIn(FieldPeakStart, vs...))
}
// PeakStartNotIn applies the NotIn predicate on the "peak_start" field.
func PeakStartNotIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldPeakStart, vs...))
}
// PeakStartGT applies the GT predicate on the "peak_start" field.
func PeakStartGT(v string) predicate.Group {
return predicate.Group(sql.FieldGT(FieldPeakStart, v))
}
// PeakStartGTE applies the GTE predicate on the "peak_start" field.
func PeakStartGTE(v string) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldPeakStart, v))
}
// PeakStartLT applies the LT predicate on the "peak_start" field.
func PeakStartLT(v string) predicate.Group {
return predicate.Group(sql.FieldLT(FieldPeakStart, v))
}
// PeakStartLTE applies the LTE predicate on the "peak_start" field.
func PeakStartLTE(v string) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldPeakStart, v))
}
// PeakStartContains applies the Contains predicate on the "peak_start" field.
func PeakStartContains(v string) predicate.Group {
return predicate.Group(sql.FieldContains(FieldPeakStart, v))
}
// PeakStartHasPrefix applies the HasPrefix predicate on the "peak_start" field.
func PeakStartHasPrefix(v string) predicate.Group {
return predicate.Group(sql.FieldHasPrefix(FieldPeakStart, v))
}
// PeakStartHasSuffix applies the HasSuffix predicate on the "peak_start" field.
func PeakStartHasSuffix(v string) predicate.Group {
return predicate.Group(sql.FieldHasSuffix(FieldPeakStart, v))
}
// PeakStartEqualFold applies the EqualFold predicate on the "peak_start" field.
func PeakStartEqualFold(v string) predicate.Group {
return predicate.Group(sql.FieldEqualFold(FieldPeakStart, v))
}
// PeakStartContainsFold applies the ContainsFold predicate on the "peak_start" field.
func PeakStartContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldPeakStart, v))
}
// PeakEndEQ applies the EQ predicate on the "peak_end" field.
func PeakEndEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakEnd, v))
}
// PeakEndNEQ applies the NEQ predicate on the "peak_end" field.
func PeakEndNEQ(v string) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldPeakEnd, v))
}
// PeakEndIn applies the In predicate on the "peak_end" field.
func PeakEndIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldIn(FieldPeakEnd, vs...))
}
// PeakEndNotIn applies the NotIn predicate on the "peak_end" field.
func PeakEndNotIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldPeakEnd, vs...))
}
// PeakEndGT applies the GT predicate on the "peak_end" field.
func PeakEndGT(v string) predicate.Group {
return predicate.Group(sql.FieldGT(FieldPeakEnd, v))
}
// PeakEndGTE applies the GTE predicate on the "peak_end" field.
func PeakEndGTE(v string) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldPeakEnd, v))
}
// PeakEndLT applies the LT predicate on the "peak_end" field.
func PeakEndLT(v string) predicate.Group {
return predicate.Group(sql.FieldLT(FieldPeakEnd, v))
}
// PeakEndLTE applies the LTE predicate on the "peak_end" field.
func PeakEndLTE(v string) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldPeakEnd, v))
}
// PeakEndContains applies the Contains predicate on the "peak_end" field.
func PeakEndContains(v string) predicate.Group {
return predicate.Group(sql.FieldContains(FieldPeakEnd, v))
}
// PeakEndHasPrefix applies the HasPrefix predicate on the "peak_end" field.
func PeakEndHasPrefix(v string) predicate.Group {
return predicate.Group(sql.FieldHasPrefix(FieldPeakEnd, v))
}
// PeakEndHasSuffix applies the HasSuffix predicate on the "peak_end" field.
func PeakEndHasSuffix(v string) predicate.Group {
return predicate.Group(sql.FieldHasSuffix(FieldPeakEnd, v))
}
// PeakEndEqualFold applies the EqualFold predicate on the "peak_end" field.
func PeakEndEqualFold(v string) predicate.Group {
return predicate.Group(sql.FieldEqualFold(FieldPeakEnd, v))
}
// PeakEndContainsFold applies the ContainsFold predicate on the "peak_end" field.
func PeakEndContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldPeakEnd, v))
}
// PeakRateMultiplierEQ applies the EQ predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v))
}
// PeakRateMultiplierNEQ applies the NEQ predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldPeakRateMultiplier, v))
}
// PeakRateMultiplierIn applies the In predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldPeakRateMultiplier, vs...))
}
// PeakRateMultiplierNotIn applies the NotIn predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldPeakRateMultiplier, vs...))
}
// PeakRateMultiplierGT applies the GT predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldPeakRateMultiplier, v))
}
// PeakRateMultiplierGTE applies the GTE predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldPeakRateMultiplier, v))
}
// PeakRateMultiplierLT applies the LT predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldPeakRateMultiplier, v))
}
// PeakRateMultiplierLTE applies the LTE predicate on the "peak_rate_multiplier" field.
func PeakRateMultiplierLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldPeakRateMultiplier, v))
}
// IsExclusiveEQ applies the EQ predicate on the "is_exclusive" field.
func IsExclusiveEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldIsExclusive, v))
+290
View File
@@ -105,6 +105,62 @@ func (_c *GroupCreate) SetNillableRateMultiplier(v *float64) *GroupCreate {
return _c
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (_c *GroupCreate) SetPeakRateEnabled(v bool) *GroupCreate {
_c.mutation.SetPeakRateEnabled(v)
return _c
}
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
func (_c *GroupCreate) SetNillablePeakRateEnabled(v *bool) *GroupCreate {
if v != nil {
_c.SetPeakRateEnabled(*v)
}
return _c
}
// SetPeakStart sets the "peak_start" field.
func (_c *GroupCreate) SetPeakStart(v string) *GroupCreate {
_c.mutation.SetPeakStart(v)
return _c
}
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
func (_c *GroupCreate) SetNillablePeakStart(v *string) *GroupCreate {
if v != nil {
_c.SetPeakStart(*v)
}
return _c
}
// SetPeakEnd sets the "peak_end" field.
func (_c *GroupCreate) SetPeakEnd(v string) *GroupCreate {
_c.mutation.SetPeakEnd(v)
return _c
}
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
func (_c *GroupCreate) SetNillablePeakEnd(v *string) *GroupCreate {
if v != nil {
_c.SetPeakEnd(*v)
}
return _c
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (_c *GroupCreate) SetPeakRateMultiplier(v float64) *GroupCreate {
_c.mutation.SetPeakRateMultiplier(v)
return _c
}
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
func (_c *GroupCreate) SetNillablePeakRateMultiplier(v *float64) *GroupCreate {
if v != nil {
_c.SetPeakRateMultiplier(*v)
}
return _c
}
// SetIsExclusive sets the "is_exclusive" field.
func (_c *GroupCreate) SetIsExclusive(v bool) *GroupCreate {
_c.mutation.SetIsExclusive(v)
@@ -640,6 +696,22 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultRateMultiplier
_c.mutation.SetRateMultiplier(v)
}
if _, ok := _c.mutation.PeakRateEnabled(); !ok {
v := group.DefaultPeakRateEnabled
_c.mutation.SetPeakRateEnabled(v)
}
if _, ok := _c.mutation.PeakStart(); !ok {
v := group.DefaultPeakStart
_c.mutation.SetPeakStart(v)
}
if _, ok := _c.mutation.PeakEnd(); !ok {
v := group.DefaultPeakEnd
_c.mutation.SetPeakEnd(v)
}
if _, ok := _c.mutation.PeakRateMultiplier(); !ok {
v := group.DefaultPeakRateMultiplier
_c.mutation.SetPeakRateMultiplier(v)
}
if _, ok := _c.mutation.IsExclusive(); !ok {
v := group.DefaultIsExclusive
_c.mutation.SetIsExclusive(v)
@@ -742,6 +814,28 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.RateMultiplier(); !ok {
return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)}
}
if _, ok := _c.mutation.PeakRateEnabled(); !ok {
return &ValidationError{Name: "peak_rate_enabled", err: errors.New(`ent: missing required field "Group.peak_rate_enabled"`)}
}
if _, ok := _c.mutation.PeakStart(); !ok {
return &ValidationError{Name: "peak_start", err: errors.New(`ent: missing required field "Group.peak_start"`)}
}
if v, ok := _c.mutation.PeakStart(); ok {
if err := group.PeakStartValidator(v); err != nil {
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
}
}
if _, ok := _c.mutation.PeakEnd(); !ok {
return &ValidationError{Name: "peak_end", err: errors.New(`ent: missing required field "Group.peak_end"`)}
}
if v, ok := _c.mutation.PeakEnd(); ok {
if err := group.PeakEndValidator(v); err != nil {
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
}
}
if _, ok := _c.mutation.PeakRateMultiplier(); !ok {
return &ValidationError{Name: "peak_rate_multiplier", err: errors.New(`ent: missing required field "Group.peak_rate_multiplier"`)}
}
if _, ok := _c.mutation.IsExclusive(); !ok {
return &ValidationError{Name: "is_exclusive", err: errors.New(`ent: missing required field "Group.is_exclusive"`)}
}
@@ -873,6 +967,22 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
_node.RateMultiplier = value
}
if value, ok := _c.mutation.PeakRateEnabled(); ok {
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
_node.PeakRateEnabled = value
}
if value, ok := _c.mutation.PeakStart(); ok {
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
_node.PeakStart = value
}
if value, ok := _c.mutation.PeakEnd(); ok {
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
_node.PeakEnd = value
}
if value, ok := _c.mutation.PeakRateMultiplier(); ok {
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
_node.PeakRateMultiplier = value
}
if value, ok := _c.mutation.IsExclusive(); ok {
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
_node.IsExclusive = value
@@ -1223,6 +1333,60 @@ func (u *GroupUpsert) AddRateMultiplier(v float64) *GroupUpsert {
return u
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (u *GroupUpsert) SetPeakRateEnabled(v bool) *GroupUpsert {
u.Set(group.FieldPeakRateEnabled, v)
return u
}
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
func (u *GroupUpsert) UpdatePeakRateEnabled() *GroupUpsert {
u.SetExcluded(group.FieldPeakRateEnabled)
return u
}
// SetPeakStart sets the "peak_start" field.
func (u *GroupUpsert) SetPeakStart(v string) *GroupUpsert {
u.Set(group.FieldPeakStart, v)
return u
}
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
func (u *GroupUpsert) UpdatePeakStart() *GroupUpsert {
u.SetExcluded(group.FieldPeakStart)
return u
}
// SetPeakEnd sets the "peak_end" field.
func (u *GroupUpsert) SetPeakEnd(v string) *GroupUpsert {
u.Set(group.FieldPeakEnd, v)
return u
}
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
func (u *GroupUpsert) UpdatePeakEnd() *GroupUpsert {
u.SetExcluded(group.FieldPeakEnd)
return u
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (u *GroupUpsert) SetPeakRateMultiplier(v float64) *GroupUpsert {
u.Set(group.FieldPeakRateMultiplier, v)
return u
}
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsert) UpdatePeakRateMultiplier() *GroupUpsert {
u.SetExcluded(group.FieldPeakRateMultiplier)
return u
}
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
func (u *GroupUpsert) AddPeakRateMultiplier(v float64) *GroupUpsert {
u.Add(group.FieldPeakRateMultiplier, v)
return u
}
// SetIsExclusive sets the "is_exclusive" field.
func (u *GroupUpsert) SetIsExclusive(v bool) *GroupUpsert {
u.Set(group.FieldIsExclusive, v)
@@ -1833,6 +1997,69 @@ func (u *GroupUpsertOne) UpdateRateMultiplier() *GroupUpsertOne {
})
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (u *GroupUpsertOne) SetPeakRateEnabled(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetPeakRateEnabled(v)
})
}
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdatePeakRateEnabled() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakRateEnabled()
})
}
// SetPeakStart sets the "peak_start" field.
func (u *GroupUpsertOne) SetPeakStart(v string) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetPeakStart(v)
})
}
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdatePeakStart() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakStart()
})
}
// SetPeakEnd sets the "peak_end" field.
func (u *GroupUpsertOne) SetPeakEnd(v string) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetPeakEnd(v)
})
}
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdatePeakEnd() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakEnd()
})
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (u *GroupUpsertOne) SetPeakRateMultiplier(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetPeakRateMultiplier(v)
})
}
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
func (u *GroupUpsertOne) AddPeakRateMultiplier(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddPeakRateMultiplier(v)
})
}
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdatePeakRateMultiplier() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakRateMultiplier()
})
}
// SetIsExclusive sets the "is_exclusive" field.
func (u *GroupUpsertOne) SetIsExclusive(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -2688,6 +2915,69 @@ func (u *GroupUpsertBulk) UpdateRateMultiplier() *GroupUpsertBulk {
})
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (u *GroupUpsertBulk) SetPeakRateEnabled(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetPeakRateEnabled(v)
})
}
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdatePeakRateEnabled() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakRateEnabled()
})
}
// SetPeakStart sets the "peak_start" field.
func (u *GroupUpsertBulk) SetPeakStart(v string) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetPeakStart(v)
})
}
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdatePeakStart() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakStart()
})
}
// SetPeakEnd sets the "peak_end" field.
func (u *GroupUpsertBulk) SetPeakEnd(v string) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetPeakEnd(v)
})
}
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdatePeakEnd() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakEnd()
})
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (u *GroupUpsertBulk) SetPeakRateMultiplier(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetPeakRateMultiplier(v)
})
}
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
func (u *GroupUpsertBulk) AddPeakRateMultiplier(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddPeakRateMultiplier(v)
})
}
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdatePeakRateMultiplier() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdatePeakRateMultiplier()
})
}
// SetIsExclusive sets the "is_exclusive" field.
func (u *GroupUpsertBulk) SetIsExclusive(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+176
View File
@@ -117,6 +117,69 @@ func (_u *GroupUpdate) AddRateMultiplier(v float64) *GroupUpdate {
return _u
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (_u *GroupUpdate) SetPeakRateEnabled(v bool) *GroupUpdate {
_u.mutation.SetPeakRateEnabled(v)
return _u
}
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
func (_u *GroupUpdate) SetNillablePeakRateEnabled(v *bool) *GroupUpdate {
if v != nil {
_u.SetPeakRateEnabled(*v)
}
return _u
}
// SetPeakStart sets the "peak_start" field.
func (_u *GroupUpdate) SetPeakStart(v string) *GroupUpdate {
_u.mutation.SetPeakStart(v)
return _u
}
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
func (_u *GroupUpdate) SetNillablePeakStart(v *string) *GroupUpdate {
if v != nil {
_u.SetPeakStart(*v)
}
return _u
}
// SetPeakEnd sets the "peak_end" field.
func (_u *GroupUpdate) SetPeakEnd(v string) *GroupUpdate {
_u.mutation.SetPeakEnd(v)
return _u
}
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
func (_u *GroupUpdate) SetNillablePeakEnd(v *string) *GroupUpdate {
if v != nil {
_u.SetPeakEnd(*v)
}
return _u
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (_u *GroupUpdate) SetPeakRateMultiplier(v float64) *GroupUpdate {
_u.mutation.ResetPeakRateMultiplier()
_u.mutation.SetPeakRateMultiplier(v)
return _u
}
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
func (_u *GroupUpdate) SetNillablePeakRateMultiplier(v *float64) *GroupUpdate {
if v != nil {
_u.SetPeakRateMultiplier(*v)
}
return _u
}
// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field.
func (_u *GroupUpdate) AddPeakRateMultiplier(v float64) *GroupUpdate {
_u.mutation.AddPeakRateMultiplier(v)
return _u
}
// SetIsExclusive sets the "is_exclusive" field.
func (_u *GroupUpdate) SetIsExclusive(v bool) *GroupUpdate {
_u.mutation.SetIsExclusive(v)
@@ -921,6 +984,16 @@ func (_u *GroupUpdate) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
}
}
if v, ok := _u.mutation.PeakStart(); ok {
if err := group.PeakStartValidator(v); err != nil {
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
}
}
if v, ok := _u.mutation.PeakEnd(); ok {
if err := group.PeakEndValidator(v); err != nil {
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
}
}
if v, ok := _u.mutation.Status(); ok {
if err := group.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
@@ -980,6 +1053,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
_spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.PeakRateEnabled(); ok {
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.PeakStart(); ok {
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
}
if value, ok := _u.mutation.PeakEnd(); ok {
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
}
if value, ok := _u.mutation.PeakRateMultiplier(); ok {
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok {
_spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.IsExclusive(); ok {
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
}
@@ -1530,6 +1618,69 @@ func (_u *GroupUpdateOne) AddRateMultiplier(v float64) *GroupUpdateOne {
return _u
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (_u *GroupUpdateOne) SetPeakRateEnabled(v bool) *GroupUpdateOne {
_u.mutation.SetPeakRateEnabled(v)
return _u
}
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillablePeakRateEnabled(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetPeakRateEnabled(*v)
}
return _u
}
// SetPeakStart sets the "peak_start" field.
func (_u *GroupUpdateOne) SetPeakStart(v string) *GroupUpdateOne {
_u.mutation.SetPeakStart(v)
return _u
}
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillablePeakStart(v *string) *GroupUpdateOne {
if v != nil {
_u.SetPeakStart(*v)
}
return _u
}
// SetPeakEnd sets the "peak_end" field.
func (_u *GroupUpdateOne) SetPeakEnd(v string) *GroupUpdateOne {
_u.mutation.SetPeakEnd(v)
return _u
}
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillablePeakEnd(v *string) *GroupUpdateOne {
if v != nil {
_u.SetPeakEnd(*v)
}
return _u
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (_u *GroupUpdateOne) SetPeakRateMultiplier(v float64) *GroupUpdateOne {
_u.mutation.ResetPeakRateMultiplier()
_u.mutation.SetPeakRateMultiplier(v)
return _u
}
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillablePeakRateMultiplier(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetPeakRateMultiplier(*v)
}
return _u
}
// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field.
func (_u *GroupUpdateOne) AddPeakRateMultiplier(v float64) *GroupUpdateOne {
_u.mutation.AddPeakRateMultiplier(v)
return _u
}
// SetIsExclusive sets the "is_exclusive" field.
func (_u *GroupUpdateOne) SetIsExclusive(v bool) *GroupUpdateOne {
_u.mutation.SetIsExclusive(v)
@@ -2347,6 +2498,16 @@ func (_u *GroupUpdateOne) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
}
}
if v, ok := _u.mutation.PeakStart(); ok {
if err := group.PeakStartValidator(v); err != nil {
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
}
}
if v, ok := _u.mutation.PeakEnd(); ok {
if err := group.PeakEndValidator(v); err != nil {
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
}
}
if v, ok := _u.mutation.Status(); ok {
if err := group.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
@@ -2423,6 +2584,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
_spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.PeakRateEnabled(); ok {
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.PeakStart(); ok {
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
}
if value, ok := _u.mutation.PeakEnd(); ok {
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
}
if value, ok := _u.mutation.PeakRateMultiplier(); ok {
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok {
_spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
}
if value, ok := _u.mutation.IsExclusive(); ok {
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
}
@@ -46,6 +46,16 @@ func TestPaymentOrdersOutTradeNoPartialUniqueIndex(t *testing.T) {
require.Equal(t, (&entsql.IndexAnnotation{Where: "out_trade_no <> ''"}).Where, idx.Annotation.Where)
}
func TestAccountsParentAccountForeignKey(t *testing.T) {
fk := findForeignKeyByColumn(t, AccountsTable, "parent_account_id")
require.Len(t, fk.Columns, 1)
require.Equal(t, "parent_account_id", fk.Columns[0].Name)
require.False(t, fk.Columns[0].Unique, "active-shadow uniqueness is enforced by the partial uq_accounts_spark_shadow_per_parent index")
require.Len(t, fk.RefColumns, 1)
require.Equal(t, "id", fk.RefColumns[0].Name)
require.Equal(t, entschema.Restrict, fk.OnDelete)
}
func findForeignKeyBySymbol(t *testing.T, table *entschema.Table, symbol string) *entschema.ForeignKey {
t.Helper()
@@ -71,3 +81,18 @@ func findIndexByName(t *testing.T, table *entschema.Table, name string) *entsche
require.Failf(t, "missing index", "table %s should include index %s", table.Name, name)
return nil
}
func findForeignKeyByColumn(t *testing.T, table *entschema.Table, column string) *entschema.ForeignKey {
t.Helper()
for _, fk := range table.ForeignKeys {
for _, col := range fk.Columns {
if col.Name == column {
return fk
}
}
}
require.Failf(t, "missing foreign key", "table %s should include foreign key for column %s", table.Name, column)
return nil
}
+25 -7
View File
@@ -124,7 +124,9 @@ var (
{Name: "session_window_start", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "session_window_end", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "session_window_status", Type: field.TypeString, Nullable: true, Size: 20},
{Name: "quota_dimension", Type: field.TypeEnum, Enums: []string{"global", "spark"}, Default: "global"},
{Name: "proxy_id", Type: field.TypeInt64, Nullable: true},
{Name: "parent_account_id", Type: field.TypeInt64, Nullable: true},
}
// AccountsTable holds the schema information for the "accounts" table.
AccountsTable = &schema.Table{
@@ -134,10 +136,16 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[29]},
Columns: []*schema.Column{AccountsColumns[30]},
RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "accounts_accounts_children",
Columns: []*schema.Column{AccountsColumns[31]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.Restrict,
},
},
Indexes: []*schema.Index{
{
@@ -158,7 +166,7 @@ var (
{
Name: "account_proxy_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[29]},
Columns: []*schema.Column{AccountsColumns[30]},
},
{
Name: "account_priority",
@@ -205,6 +213,11 @@ var (
Unique: false,
Columns: []*schema.Column{AccountsColumns[3]},
},
{
Name: "account_parent_account_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[31]},
},
},
}
// AccountGroupsColumns holds the columns for the "account_groups" table.
@@ -644,6 +657,10 @@ var (
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "peak_rate_enabled", Type: field.TypeBool, Default: false},
{Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""},
{Name: "peak_end", Type: field.TypeString, Size: 5, Default: ""},
{Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "is_exclusive", Type: field.TypeBool, Default: false},
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"},
@@ -683,22 +700,22 @@ var (
{
Name: "group_status",
Unique: false,
Columns: []*schema.Column{GroupsColumns[8]},
Columns: []*schema.Column{GroupsColumns[12]},
},
{
Name: "group_platform",
Unique: false,
Columns: []*schema.Column{GroupsColumns[9]},
Columns: []*schema.Column{GroupsColumns[13]},
},
{
Name: "group_subscription_type",
Unique: false,
Columns: []*schema.Column{GroupsColumns[10]},
Columns: []*schema.Column{GroupsColumns[14]},
},
{
Name: "group_is_exclusive",
Unique: false,
Columns: []*schema.Column{GroupsColumns[7]},
Columns: []*schema.Column{GroupsColumns[11]},
},
{
Name: "group_deleted_at",
@@ -708,7 +725,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[28]},
Columns: []*schema.Column{GroupsColumns[32]},
},
},
}
@@ -1820,6 +1837,7 @@ func init() {
Table: "api_keys",
}
AccountsTable.ForeignKeys[0].RefTable = ProxiesTable
AccountsTable.ForeignKeys[1].RefTable = AccountsTable
AccountsTable.Annotation = &entsql.Annotation{
Table: "accounts",
}
+523 -5
View File
@@ -2310,12 +2310,18 @@ type AccountMutation struct {
session_window_start *time.Time
session_window_end *time.Time
session_window_status *string
quota_dimension *account.QuotaDimension
clearedFields map[string]struct{}
groups map[int64]struct{}
removedgroups map[int64]struct{}
clearedgroups bool
proxy *int64
clearedproxy bool
parent *int64
clearedparent bool
children map[int64]struct{}
removedchildren map[int64]struct{}
clearedchildren bool
usage_logs map[int64]struct{}
removedusage_logs map[int64]struct{}
clearedusage_logs bool
@@ -3776,6 +3782,91 @@ func (m *AccountMutation) ResetSessionWindowStatus() {
delete(m.clearedFields, account.FieldSessionWindowStatus)
}
// SetParentAccountID sets the "parent_account_id" field.
func (m *AccountMutation) SetParentAccountID(i int64) {
m.parent = &i
}
// ParentAccountID returns the value of the "parent_account_id" field in the mutation.
func (m *AccountMutation) ParentAccountID() (r int64, exists bool) {
v := m.parent
if v == nil {
return
}
return *v, true
}
// OldParentAccountID returns the old "parent_account_id" field's value of the Account entity.
// If the Account object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AccountMutation) OldParentAccountID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldParentAccountID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldParentAccountID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldParentAccountID: %w", err)
}
return oldValue.ParentAccountID, nil
}
// ClearParentAccountID clears the value of the "parent_account_id" field.
func (m *AccountMutation) ClearParentAccountID() {
m.parent = nil
m.clearedFields[account.FieldParentAccountID] = struct{}{}
}
// ParentAccountIDCleared returns if the "parent_account_id" field was cleared in this mutation.
func (m *AccountMutation) ParentAccountIDCleared() bool {
_, ok := m.clearedFields[account.FieldParentAccountID]
return ok
}
// ResetParentAccountID resets all changes to the "parent_account_id" field.
func (m *AccountMutation) ResetParentAccountID() {
m.parent = nil
delete(m.clearedFields, account.FieldParentAccountID)
}
// SetQuotaDimension sets the "quota_dimension" field.
func (m *AccountMutation) SetQuotaDimension(ad account.QuotaDimension) {
m.quota_dimension = &ad
}
// QuotaDimension returns the value of the "quota_dimension" field in the mutation.
func (m *AccountMutation) QuotaDimension() (r account.QuotaDimension, exists bool) {
v := m.quota_dimension
if v == nil {
return
}
return *v, true
}
// OldQuotaDimension returns the old "quota_dimension" field's value of the Account entity.
// If the Account object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AccountMutation) OldQuotaDimension(ctx context.Context) (v account.QuotaDimension, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuotaDimension is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuotaDimension requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuotaDimension: %w", err)
}
return oldValue.QuotaDimension, nil
}
// ResetQuotaDimension resets all changes to the "quota_dimension" field.
func (m *AccountMutation) ResetQuotaDimension() {
m.quota_dimension = nil
}
// AddGroupIDs adds the "groups" edge to the Group entity by ids.
func (m *AccountMutation) AddGroupIDs(ids ...int64) {
if m.groups == nil {
@@ -3857,6 +3948,100 @@ func (m *AccountMutation) ResetProxy() {
m.clearedproxy = false
}
// SetParentID sets the "parent" edge to the Account entity by id.
func (m *AccountMutation) SetParentID(id int64) {
m.parent = &id
}
// ClearParent clears the "parent" edge to the Account entity.
func (m *AccountMutation) ClearParent() {
m.clearedparent = true
m.clearedFields[account.FieldParentAccountID] = struct{}{}
}
// ParentCleared reports if the "parent" edge to the Account entity was cleared.
func (m *AccountMutation) ParentCleared() bool {
return m.ParentAccountIDCleared() || m.clearedparent
}
// ParentID returns the "parent" edge ID in the mutation.
func (m *AccountMutation) ParentID() (id int64, exists bool) {
if m.parent != nil {
return *m.parent, true
}
return
}
// ParentIDs returns the "parent" edge IDs in the mutation.
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
// ParentID instead. It exists only for internal usage by the builders.
func (m *AccountMutation) ParentIDs() (ids []int64) {
if id := m.parent; id != nil {
ids = append(ids, *id)
}
return
}
// ResetParent resets all changes to the "parent" edge.
func (m *AccountMutation) ResetParent() {
m.parent = nil
m.clearedparent = false
}
// AddChildIDs adds the "children" edge to the Account entity by ids.
func (m *AccountMutation) AddChildIDs(ids ...int64) {
if m.children == nil {
m.children = make(map[int64]struct{})
}
for i := range ids {
m.children[ids[i]] = struct{}{}
}
}
// ClearChildren clears the "children" edge to the Account entity.
func (m *AccountMutation) ClearChildren() {
m.clearedchildren = true
}
// ChildrenCleared reports if the "children" edge to the Account entity was cleared.
func (m *AccountMutation) ChildrenCleared() bool {
return m.clearedchildren
}
// RemoveChildIDs removes the "children" edge to the Account entity by IDs.
func (m *AccountMutation) RemoveChildIDs(ids ...int64) {
if m.removedchildren == nil {
m.removedchildren = make(map[int64]struct{})
}
for i := range ids {
delete(m.children, ids[i])
m.removedchildren[ids[i]] = struct{}{}
}
}
// RemovedChildren returns the removed IDs of the "children" edge to the Account entity.
func (m *AccountMutation) RemovedChildrenIDs() (ids []int64) {
for id := range m.removedchildren {
ids = append(ids, id)
}
return
}
// ChildrenIDs returns the "children" edge IDs in the mutation.
func (m *AccountMutation) ChildrenIDs() (ids []int64) {
for id := range m.children {
ids = append(ids, id)
}
return
}
// ResetChildren resets all changes to the "children" edge.
func (m *AccountMutation) ResetChildren() {
m.children = nil
m.clearedchildren = false
m.removedchildren = nil
}
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by ids.
func (m *AccountMutation) AddUsageLogIDs(ids ...int64) {
if m.usage_logs == nil {
@@ -3945,7 +4130,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 29)
fields := make([]string, 0, 31)
if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt)
}
@@ -4033,6 +4218,12 @@ func (m *AccountMutation) Fields() []string {
if m.session_window_status != nil {
fields = append(fields, account.FieldSessionWindowStatus)
}
if m.parent != nil {
fields = append(fields, account.FieldParentAccountID)
}
if m.quota_dimension != nil {
fields = append(fields, account.FieldQuotaDimension)
}
return fields
}
@@ -4099,6 +4290,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.SessionWindowEnd()
case account.FieldSessionWindowStatus:
return m.SessionWindowStatus()
case account.FieldParentAccountID:
return m.ParentAccountID()
case account.FieldQuotaDimension:
return m.QuotaDimension()
}
return nil, false
}
@@ -4166,6 +4361,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldSessionWindowEnd(ctx)
case account.FieldSessionWindowStatus:
return m.OldSessionWindowStatus(ctx)
case account.FieldParentAccountID:
return m.OldParentAccountID(ctx)
case account.FieldQuotaDimension:
return m.OldQuotaDimension(ctx)
}
return nil, fmt.Errorf("unknown Account field %s", name)
}
@@ -4378,6 +4577,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
}
m.SetSessionWindowStatus(v)
return nil
case account.FieldParentAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetParentAccountID(v)
return nil
case account.FieldQuotaDimension:
v, ok := value.(account.QuotaDimension)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuotaDimension(v)
return nil
}
return fmt.Errorf("unknown Account field %s", name)
}
@@ -4519,6 +4732,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldSessionWindowStatus) {
fields = append(fields, account.FieldSessionWindowStatus)
}
if m.FieldCleared(account.FieldParentAccountID) {
fields = append(fields, account.FieldParentAccountID)
}
return fields
}
@@ -4581,6 +4797,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldSessionWindowStatus:
m.ClearSessionWindowStatus()
return nil
case account.FieldParentAccountID:
m.ClearParentAccountID()
return nil
}
return fmt.Errorf("unknown Account nullable field %s", name)
}
@@ -4676,19 +4895,31 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldSessionWindowStatus:
m.ResetSessionWindowStatus()
return nil
case account.FieldParentAccountID:
m.ResetParentAccountID()
return nil
case account.FieldQuotaDimension:
m.ResetQuotaDimension()
return nil
}
return fmt.Errorf("unknown Account field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *AccountMutation) AddedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.groups != nil {
edges = append(edges, account.EdgeGroups)
}
if m.proxy != nil {
edges = append(edges, account.EdgeProxy)
}
if m.parent != nil {
edges = append(edges, account.EdgeParent)
}
if m.children != nil {
edges = append(edges, account.EdgeChildren)
}
if m.usage_logs != nil {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4709,6 +4940,16 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
if id := m.proxy; id != nil {
return []ent.Value{*id}
}
case account.EdgeParent:
if id := m.parent; id != nil {
return []ent.Value{*id}
}
case account.EdgeChildren:
ids := make([]ent.Value, 0, len(m.children))
for id := range m.children {
ids = append(ids, id)
}
return ids
case account.EdgeUsageLogs:
ids := make([]ent.Value, 0, len(m.usage_logs))
for id := range m.usage_logs {
@@ -4721,10 +4962,13 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *AccountMutation) RemovedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.removedgroups != nil {
edges = append(edges, account.EdgeGroups)
}
if m.removedchildren != nil {
edges = append(edges, account.EdgeChildren)
}
if m.removedusage_logs != nil {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4741,6 +4985,12 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case account.EdgeChildren:
ids := make([]ent.Value, 0, len(m.removedchildren))
for id := range m.removedchildren {
ids = append(ids, id)
}
return ids
case account.EdgeUsageLogs:
ids := make([]ent.Value, 0, len(m.removedusage_logs))
for id := range m.removedusage_logs {
@@ -4753,13 +5003,19 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *AccountMutation) ClearedEdges() []string {
edges := make([]string, 0, 3)
edges := make([]string, 0, 5)
if m.clearedgroups {
edges = append(edges, account.EdgeGroups)
}
if m.clearedproxy {
edges = append(edges, account.EdgeProxy)
}
if m.clearedparent {
edges = append(edges, account.EdgeParent)
}
if m.clearedchildren {
edges = append(edges, account.EdgeChildren)
}
if m.clearedusage_logs {
edges = append(edges, account.EdgeUsageLogs)
}
@@ -4774,6 +5030,10 @@ func (m *AccountMutation) EdgeCleared(name string) bool {
return m.clearedgroups
case account.EdgeProxy:
return m.clearedproxy
case account.EdgeParent:
return m.clearedparent
case account.EdgeChildren:
return m.clearedchildren
case account.EdgeUsageLogs:
return m.clearedusage_logs
}
@@ -4787,6 +5047,9 @@ func (m *AccountMutation) ClearEdge(name string) error {
case account.EdgeProxy:
m.ClearProxy()
return nil
case account.EdgeParent:
m.ClearParent()
return nil
}
return fmt.Errorf("unknown Account unique edge %s", name)
}
@@ -4801,6 +5064,12 @@ func (m *AccountMutation) ResetEdge(name string) error {
case account.EdgeProxy:
m.ResetProxy()
return nil
case account.EdgeParent:
m.ResetParent()
return nil
case account.EdgeChildren:
m.ResetChildren()
return nil
case account.EdgeUsageLogs:
m.ResetUsageLogs()
return nil
@@ -15056,6 +15325,11 @@ type GroupMutation struct {
description *string
rate_multiplier *float64
addrate_multiplier *float64
peak_rate_enabled *bool
peak_start *string
peak_end *string
peak_rate_multiplier *float64
addpeak_rate_multiplier *float64
is_exclusive *bool
status *string
platform *string
@@ -15482,6 +15756,170 @@ func (m *GroupMutation) ResetRateMultiplier() {
m.addrate_multiplier = nil
}
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
func (m *GroupMutation) SetPeakRateEnabled(b bool) {
m.peak_rate_enabled = &b
}
// PeakRateEnabled returns the value of the "peak_rate_enabled" field in the mutation.
func (m *GroupMutation) PeakRateEnabled() (r bool, exists bool) {
v := m.peak_rate_enabled
if v == nil {
return
}
return *v, true
}
// OldPeakRateEnabled returns the old "peak_rate_enabled" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldPeakRateEnabled(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldPeakRateEnabled is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldPeakRateEnabled requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldPeakRateEnabled: %w", err)
}
return oldValue.PeakRateEnabled, nil
}
// ResetPeakRateEnabled resets all changes to the "peak_rate_enabled" field.
func (m *GroupMutation) ResetPeakRateEnabled() {
m.peak_rate_enabled = nil
}
// SetPeakStart sets the "peak_start" field.
func (m *GroupMutation) SetPeakStart(s string) {
m.peak_start = &s
}
// PeakStart returns the value of the "peak_start" field in the mutation.
func (m *GroupMutation) PeakStart() (r string, exists bool) {
v := m.peak_start
if v == nil {
return
}
return *v, true
}
// OldPeakStart returns the old "peak_start" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldPeakStart(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldPeakStart is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldPeakStart requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldPeakStart: %w", err)
}
return oldValue.PeakStart, nil
}
// ResetPeakStart resets all changes to the "peak_start" field.
func (m *GroupMutation) ResetPeakStart() {
m.peak_start = nil
}
// SetPeakEnd sets the "peak_end" field.
func (m *GroupMutation) SetPeakEnd(s string) {
m.peak_end = &s
}
// PeakEnd returns the value of the "peak_end" field in the mutation.
func (m *GroupMutation) PeakEnd() (r string, exists bool) {
v := m.peak_end
if v == nil {
return
}
return *v, true
}
// OldPeakEnd returns the old "peak_end" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldPeakEnd(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldPeakEnd is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldPeakEnd requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldPeakEnd: %w", err)
}
return oldValue.PeakEnd, nil
}
// ResetPeakEnd resets all changes to the "peak_end" field.
func (m *GroupMutation) ResetPeakEnd() {
m.peak_end = nil
}
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
func (m *GroupMutation) SetPeakRateMultiplier(f float64) {
m.peak_rate_multiplier = &f
m.addpeak_rate_multiplier = nil
}
// PeakRateMultiplier returns the value of the "peak_rate_multiplier" field in the mutation.
func (m *GroupMutation) PeakRateMultiplier() (r float64, exists bool) {
v := m.peak_rate_multiplier
if v == nil {
return
}
return *v, true
}
// OldPeakRateMultiplier returns the old "peak_rate_multiplier" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldPeakRateMultiplier(ctx context.Context) (v float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldPeakRateMultiplier is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldPeakRateMultiplier requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldPeakRateMultiplier: %w", err)
}
return oldValue.PeakRateMultiplier, nil
}
// AddPeakRateMultiplier adds f to the "peak_rate_multiplier" field.
func (m *GroupMutation) AddPeakRateMultiplier(f float64) {
if m.addpeak_rate_multiplier != nil {
*m.addpeak_rate_multiplier += f
} else {
m.addpeak_rate_multiplier = &f
}
}
// AddedPeakRateMultiplier returns the value that was added to the "peak_rate_multiplier" field in this mutation.
func (m *GroupMutation) AddedPeakRateMultiplier() (r float64, exists bool) {
v := m.addpeak_rate_multiplier
if v == nil {
return
}
return *v, true
}
// ResetPeakRateMultiplier resets all changes to the "peak_rate_multiplier" field.
func (m *GroupMutation) ResetPeakRateMultiplier() {
m.peak_rate_multiplier = nil
m.addpeak_rate_multiplier = nil
}
// SetIsExclusive sets the "is_exclusive" field.
func (m *GroupMutation) SetIsExclusive(b bool) {
m.is_exclusive = &b
@@ -17264,7 +17702,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 35)
fields := make([]string, 0, 39)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -17283,6 +17721,18 @@ func (m *GroupMutation) Fields() []string {
if m.rate_multiplier != nil {
fields = append(fields, group.FieldRateMultiplier)
}
if m.peak_rate_enabled != nil {
fields = append(fields, group.FieldPeakRateEnabled)
}
if m.peak_start != nil {
fields = append(fields, group.FieldPeakStart)
}
if m.peak_end != nil {
fields = append(fields, group.FieldPeakEnd)
}
if m.peak_rate_multiplier != nil {
fields = append(fields, group.FieldPeakRateMultiplier)
}
if m.is_exclusive != nil {
fields = append(fields, group.FieldIsExclusive)
}
@@ -17390,6 +17840,14 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.Description()
case group.FieldRateMultiplier:
return m.RateMultiplier()
case group.FieldPeakRateEnabled:
return m.PeakRateEnabled()
case group.FieldPeakStart:
return m.PeakStart()
case group.FieldPeakEnd:
return m.PeakEnd()
case group.FieldPeakRateMultiplier:
return m.PeakRateMultiplier()
case group.FieldIsExclusive:
return m.IsExclusive()
case group.FieldStatus:
@@ -17469,6 +17927,14 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldDescription(ctx)
case group.FieldRateMultiplier:
return m.OldRateMultiplier(ctx)
case group.FieldPeakRateEnabled:
return m.OldPeakRateEnabled(ctx)
case group.FieldPeakStart:
return m.OldPeakStart(ctx)
case group.FieldPeakEnd:
return m.OldPeakEnd(ctx)
case group.FieldPeakRateMultiplier:
return m.OldPeakRateMultiplier(ctx)
case group.FieldIsExclusive:
return m.OldIsExclusive(ctx)
case group.FieldStatus:
@@ -17578,6 +18044,34 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetRateMultiplier(v)
return nil
case group.FieldPeakRateEnabled:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetPeakRateEnabled(v)
return nil
case group.FieldPeakStart:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetPeakStart(v)
return nil
case group.FieldPeakEnd:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetPeakEnd(v)
return nil
case group.FieldPeakRateMultiplier:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetPeakRateMultiplier(v)
return nil
case group.FieldIsExclusive:
v, ok := value.(bool)
if !ok {
@@ -17792,6 +18286,9 @@ func (m *GroupMutation) AddedFields() []string {
if m.addrate_multiplier != nil {
fields = append(fields, group.FieldRateMultiplier)
}
if m.addpeak_rate_multiplier != nil {
fields = append(fields, group.FieldPeakRateMultiplier)
}
if m.adddaily_limit_usd != nil {
fields = append(fields, group.FieldDailyLimitUsd)
}
@@ -17838,6 +18335,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case group.FieldRateMultiplier:
return m.AddedRateMultiplier()
case group.FieldPeakRateMultiplier:
return m.AddedPeakRateMultiplier()
case group.FieldDailyLimitUsd:
return m.AddedDailyLimitUsd()
case group.FieldWeeklyLimitUsd:
@@ -17878,6 +18377,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
}
m.AddRateMultiplier(v)
return nil
case group.FieldPeakRateMultiplier:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddPeakRateMultiplier(v)
return nil
case group.FieldDailyLimitUsd:
v, ok := value.(float64)
if !ok {
@@ -18076,6 +18582,18 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldRateMultiplier:
m.ResetRateMultiplier()
return nil
case group.FieldPeakRateEnabled:
m.ResetPeakRateEnabled()
return nil
case group.FieldPeakStart:
m.ResetPeakStart()
return nil
case group.FieldPeakEnd:
m.ResetPeakEnd()
return nil
case group.FieldPeakRateMultiplier:
m.ResetPeakRateMultiplier()
return nil
case group.FieldIsExclusive:
m.ResetIsExclusive()
return nil
+40 -20
View File
@@ -796,92 +796,112 @@ func init() {
groupDescRateMultiplier := groupFields[2].Descriptor()
// group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64)
// groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field.
groupDescPeakRateEnabled := groupFields[3].Descriptor()
// group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field.
group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool)
// groupDescPeakStart is the schema descriptor for peak_start field.
groupDescPeakStart := groupFields[4].Descriptor()
// group.DefaultPeakStart holds the default value on creation for the peak_start field.
group.DefaultPeakStart = groupDescPeakStart.Default.(string)
// group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error)
// groupDescPeakEnd is the schema descriptor for peak_end field.
groupDescPeakEnd := groupFields[5].Descriptor()
// group.DefaultPeakEnd holds the default value on creation for the peak_end field.
group.DefaultPeakEnd = groupDescPeakEnd.Default.(string)
// group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error)
// groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field.
groupDescPeakRateMultiplier := groupFields[6].Descriptor()
// group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field.
group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64)
// groupDescIsExclusive is the schema descriptor for is_exclusive field.
groupDescIsExclusive := groupFields[3].Descriptor()
groupDescIsExclusive := groupFields[7].Descriptor()
// group.DefaultIsExclusive holds the default value on creation for the is_exclusive field.
group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool)
// groupDescStatus is the schema descriptor for status field.
groupDescStatus := groupFields[4].Descriptor()
groupDescStatus := groupFields[8].Descriptor()
// group.DefaultStatus holds the default value on creation for the status field.
group.DefaultStatus = groupDescStatus.Default.(string)
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
// groupDescPlatform is the schema descriptor for platform field.
groupDescPlatform := groupFields[5].Descriptor()
groupDescPlatform := groupFields[9].Descriptor()
// group.DefaultPlatform holds the default value on creation for the platform field.
group.DefaultPlatform = groupDescPlatform.Default.(string)
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
groupDescSubscriptionType := groupFields[6].Descriptor()
groupDescSubscriptionType := groupFields[10].Descriptor()
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
groupDescDefaultValidityDays := groupFields[10].Descriptor()
groupDescDefaultValidityDays := groupFields[14].Descriptor()
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
groupDescAllowImageGeneration := groupFields[11].Descriptor()
groupDescAllowImageGeneration := groupFields[15].Descriptor()
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
groupDescImageRateIndependent := groupFields[12].Descriptor()
groupDescImageRateIndependent := groupFields[16].Descriptor()
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
groupDescImageRateMultiplier := groupFields[13].Descriptor()
groupDescImageRateMultiplier := groupFields[17].Descriptor()
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[17].Descriptor()
groupDescClaudeCodeOnly := groupFields[21].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
groupDescModelRoutingEnabled := groupFields[21].Descriptor()
groupDescModelRoutingEnabled := groupFields[25].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
groupDescMcpXMLInject := groupFields[22].Descriptor()
groupDescMcpXMLInject := groupFields[26].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
groupDescSupportedModelScopes := groupFields[23].Descriptor()
groupDescSupportedModelScopes := groupFields[27].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
groupDescSortOrder := groupFields[24].Descriptor()
groupDescSortOrder := groupFields[28].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
groupDescAllowMessagesDispatch := groupFields[25].Descriptor()
groupDescAllowMessagesDispatch := groupFields[29].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[26].Descriptor()
groupDescRequireOauthOnly := groupFields[30].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
groupDescRequirePrivacySet := groupFields[27].Descriptor()
groupDescRequirePrivacySet := groupFields[31].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
groupDescDefaultMappedModel := groupFields[28].Descriptor()
groupDescDefaultMappedModel := groupFields[32].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[33].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[30].Descriptor()
groupDescModelsListConfig := groupFields[34].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[31].Descriptor()
groupDescRpmLimit := groupFields[35].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+14
View File
@@ -196,6 +196,11 @@ func (Account) Fields() []ent.Field {
Optional().
Nillable().
MaxLen(20),
field.Int64("parent_account_id").Optional().Nillable().
Comment("Parent account id for a linked spark shadow (NULL = normal)."),
field.Enum("quota_dimension").Values("global", "spark").Default("global").
Comment("'global' (default) or 'spark' (shadow reads codex_bengalfox)."),
}
}
@@ -212,6 +217,14 @@ func (Account) Edges() []ent.Edge {
edge.To("proxy", Proxy.Type).
Field("proxy_id").
Unique(),
// children/parent: linked spark shadow relationship.
// parent_account_id is nullable, and the active one-shadow-per-parent rule
// is enforced by the partial unique index in migration 154a.
edge.To("children", Account.Type).
Annotations(entsql.OnDelete(entsql.Restrict)).
From("parent").
Field("parent_account_id").
Unique(),
// usage_logs: 该账户的使用日志
edge.To("usage_logs", UsageLog.Type),
}
@@ -235,5 +248,6 @@ func (Account) Indexes() []ent.Index {
index.Fields("platform", "priority"),
index.Fields("priority", "status"),
index.Fields("deleted_at"), // 软删除查询优化
index.Fields("parent_account_id"),
}
}
+16
View File
@@ -45,6 +45,22 @@ func (Group) Fields() []ent.Field {
field.Float("rate_multiplier").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(1.0),
// 高峰时段倍率(added by migration 158)
field.Bool("peak_rate_enabled").
Default(false).
Comment("是否启用高峰时段倍率"),
field.String("peak_start").
MaxLen(5).
Default("").
Comment("高峰开始时间 HH:MM(含),如 14:00;空表示未配置;不支持跨天"),
field.String("peak_end").
MaxLen(5).
Default("").
Comment("高峰结束时间 HH:MM(不含),必须大于 peak_start;不支持跨天,如 22:00-02:00"),
field.Float("peak_rate_multiplier").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(1.0).
Comment("高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率"),
field.Bool("is_exclusive").
Default(false),
field.String("status").
+17
View File
@@ -0,0 +1,17 @@
package ent
import "context"
// WithoutTx 返回一个剥离了所附 *Tx 的 ctx 副本,使调用方可以在基础 client
// (autocommit)上执行 best-effort、非关键的副作用,而不加入——也就不会毒化——
// 外层事务。
//
// Postgres 语义:事务内任一语句失败即把整个事务标记为 aborted,后续语句全部被拒,
// 直到 ROLLBACK。因此对 fail-open 的副作用(如注册时的默认平台配额快照)必须做事务
// 隔离,否则一条无关紧要的写入失败会连累调用方的关键事务。
func WithoutTx(ctx context.Context) context.Context {
if TxFromContext(ctx) == nil {
return ctx
}
return context.WithValue(ctx, txCtxKey{}, (*Tx)(nil))
}
+2
View File
@@ -86,6 +86,7 @@ require (
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
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
@@ -155,6 +156,7 @@ require (
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tiktoken-go/tokenizer v0.8.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+4
View File
@@ -108,6 +108,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
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=
github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -360,6 +362,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+26 -7
View File
@@ -721,6 +721,9 @@ type GatewayConfig struct {
// OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头
// 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
// OpenAICompactModel: /responses/compact 上游使用的模型。
// compact 端点支持模型滞后于普通 /responses 时,可用该配置降级规避上游错误。
OpenAICompactModel string `mapstructure:"openai_compact_model"`
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP)
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
// OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置
@@ -867,7 +870,7 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string {
type GatewayOpenAIWSConfig struct {
// ModeRouterV2Enabled: 新版 WS mode 路由开关(默认 false;关闭时保持 legacy 行为)
ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"`
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough)
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough/http_bridge)
IngressModeDefault string `mapstructure:"ingress_mode_default"`
// Enabled: 全局总开关(默认 true)
Enabled bool `mapstructure:"enabled"`
@@ -964,6 +967,11 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
Reset float64 `mapstructure:"reset"`
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
// PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。
PreviousResponse float64 `mapstructure:"previous_response"`
SessionSticky float64 `mapstructure:"session_sticky"`
}
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
@@ -1834,6 +1842,7 @@ func setDefaults() {
viper.SetDefault("gateway.force_codex_cli", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
@@ -1884,6 +1893,9 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0)
// OpenAI HTTP upstream protocol strategy
viper.SetDefault("gateway.openai_http2.enabled", true)
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
@@ -1938,7 +1950,10 @@ func setDefaults() {
viper.SetDefault("gateway.usage_record.worker_count", 128)
viper.SetDefault("gateway.usage_record.queue_size", 16384)
viper.SetDefault("gateway.usage_record.task_timeout_seconds", 5)
viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySample)
// 默认 sync:队列满时由提交方内联执行(提交点在响应写出之后,不阻塞客户端)。
// sample/drop 会在溢出时静默丢弃计费任务,造成扣费与 usage_logs 对账缺口(issue #3656),
// 仅供显式配置的运维场景使用。
viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySync)
viper.SetDefault("gateway.usage_record.overflow_sample_percent", 10)
viper.SetDefault("gateway.usage_record.auto_scale_enabled", true)
viper.SetDefault("gateway.usage_record.auto_scale_min_workers", 128)
@@ -2621,11 +2636,11 @@ func (c *Config) Validate() error {
}
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.IngressModeDefault)); mode != "" {
switch mode {
case "off", "ctx_pool", "passthrough":
case "off", "ctx_pool", "passthrough", "http_bridge":
case "shared", "dedicated":
slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough", "value", mode)
slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough|http_bridge", "value", mode)
default:
return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough")
return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough|http_bridge")
}
}
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.StoreDisabledConnMode)); mode != "" {
@@ -2663,14 +2678,18 @@ func (c *Config) Validate() error {
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 {
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
}
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
if weightSum <= 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
}
+40 -3
View File
@@ -167,6 +167,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.PayloadLogSampleRate != 0.2 {
t.Fatalf("Gateway.OpenAIWS.PayloadLogSampleRate = %v, want 0.2", cfg.Gateway.OpenAIWS.PayloadLogSampleRate)
}
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
}
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
}
@@ -181,6 +184,23 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
}
}
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
}
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_OPENAI_COMPACT_MODEL", "gpt-5.3-codex")
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel)
}
func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -1675,7 +1695,7 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
wantErr: "gateway.openai_ws.store_disabled_conn_mode",
},
{
name: "ingress_mode_default 必须为 off|ctx_pool|passthrough",
name: "ingress_mode_default 必须为 off|ctx_pool|passthrough|http_bridge",
mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressModeDefault = "invalid" },
wantErr: "gateway.openai_ws.ingress_mode_default",
},
@@ -1717,6 +1737,11 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights quota_headroom 不能为负数",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights 不能全为 0",
mutate: func(c *Config) {
@@ -1756,6 +1781,18 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
require.Contains(t, err.Error(), tc.wantErr)
})
}
t.Run("quota_headroom 可作为唯一有效调度权重", func(t *testing.T) {
cfg := buildValid(t)
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = 0.1
require.NoError(t, cfg.Validate())
})
}
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
@@ -1866,8 +1903,8 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) {
if cfg.Gateway.UsageRecord.TaskTimeoutSeconds != 5 {
t.Fatalf("task_timeout_seconds = %d, want 5", cfg.Gateway.UsageRecord.TaskTimeoutSeconds)
}
if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySample {
t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySample)
if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySync {
t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySync)
}
if cfg.Gateway.UsageRecord.OverflowSamplePercent != 10 {
t.Fatalf("overflow_sample_percent = %d, want 10", cfg.Gateway.UsageRecord.OverflowSamplePercent)
+9 -3
View File
@@ -68,6 +68,9 @@ const (
SubscriptionStatusSuspended = "suspended"
)
// AntigravityGemini31ProAgentModel is the upstream route for Gemini 3.1 Pro High.
const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
// DefaultAntigravityModelMapping 是 Antigravity 平台的默认模型映射
// 当账号未配置 model_mapping 时使用此默认值
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
@@ -103,10 +106,12 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3-flash-preview": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3-pro-high",
// Gemini 3.1 白名单
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel,
"gemini-3.1-pro": AntigravityGemini31ProAgentModel,
"gemini-3.1-pro-high": AntigravityGemini31ProAgentModel,
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
// Gemini 3.1 preview 映射
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel,
// Gemini 3.1 image 白名单
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
// Gemini 3.1 image preview 映射
@@ -136,6 +141,7 @@ var DefaultBedrockModelMapping = map[string]string{
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
// Claude Sonnet
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-5": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
+22
View File
@@ -43,6 +43,28 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
t.Parallel()
cases := map[string]string{
AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel,
"gemini-3.1-pro": AntigravityGemini31ProAgentModel,
"gemini-3.1-pro-high": AntigravityGemini31ProAgentModel,
"gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel,
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
}
for from, want := range cases {
got, ok := DefaultAntigravityModelMapping[from]
if !ok {
t.Fatalf("expected mapping for %q to exist", from)
}
if got != want {
t.Fatalf("unexpected mapping for %q: got %q want %q", from, got, want)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -106,7 +106,7 @@ type codexJWTOpenAIClaims struct {
}
type codexAccountIndex struct {
accountsByKey map[string]service.Account
accountsByKey map[string][]service.Account
}
func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
@@ -178,7 +178,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
}
skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
seenIdentity := map[string]int{}
seenIdentity := map[string]codexSeenIdentity{}
for _, entry := range entries {
item, err := normalizeCodexImportEntry(entry)
if err != nil {
@@ -225,7 +225,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
})
}
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys); ok {
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok {
message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex)
result.Skipped++
result.Items = append(result.Items, CodexSessionImportItem{
@@ -241,9 +241,29 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
})
continue
}
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index)
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID)
if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting {
existing, matchedKey := index.Find(item.IdentityKeys, item.UserID)
if existing != nil && updateExisting {
if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" &&
codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" {
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户",
})
}
preserveExistingRefresh := item.RefreshToken == "" &&
codexCredentialString(existing.Credentials, "refresh_token") != ""
if preserveExistingRefresh {
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
Index: entry.Index,
Name: accountName,
Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据",
})
effectiveExpiresAt = nil
autoPauseOnExpired = nil
}
mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item)
mergedExtra := mergeCodexImportMap(existing.Extra, extra)
updateInput := &service.UpdateAccountInput{
@@ -583,7 +603,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
fingerprint := codexTokenFingerprint(item.AccessToken)
item.Extra["access_token_sha256"] = fingerprint
item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken)
item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken)
item.Name = buildCodexImportAccountName(item, entry.Index)
return item, nil
@@ -806,13 +826,25 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
return out
}
func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string {
keys := make([]string, 0, 4)
// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时
// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token
// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。
func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string {
accessToken = strings.TrimSpace(accessToken)
refreshToken = strings.TrimSpace(refreshToken)
if refreshToken == "" && accessToken != "" {
return []string{"access:" + codexTokenFingerprint(accessToken)}
}
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
}
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
keys := make([]string, 0, 3)
accountID = strings.TrimSpace(accountID)
userID = strings.TrimSpace(userID)
if accountID != "" {
keys = append(keys, "account:"+accountID)
}
accessToken = strings.TrimSpace(accessToken)
if userID != "" {
keys = append(keys, "user:"+userID)
}
@@ -821,14 +853,17 @@ func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []stri
keys = append(keys, "email:"+email)
}
}
if accessToken = strings.TrimSpace(accessToken); accessToken != "" {
if accessToken != "" {
keys = append(keys, "access:"+codexTokenFingerprint(accessToken))
}
if accountID != "" {
keys = append(keys, "account:"+accountID)
}
return keys
}
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
index := &codexAccountIndex{accountsByKey: map[string]service.Account{}}
index := &codexAccountIndex{accountsByKey: map[string][]service.Account{}}
for _, account := range accounts {
index.Add(account)
}
@@ -840,43 +875,99 @@ func (i *codexAccountIndex) Add(account service.Account) {
return
}
if i.accountsByKey == nil {
i.accountsByKey = map[string]service.Account{}
i.accountsByKey = map[string][]service.Account{}
}
keys := buildCodexIdentityKeys(
i.remove(account.ID)
keys := buildCodexStoredIdentityKeys(
codexCredentialString(account.Credentials, "chatgpt_account_id"),
codexCredentialString(account.Credentials, "chatgpt_user_id"),
codexCredentialString(account.Credentials, "email"),
codexCredentialString(account.Credentials, "access_token"),
)
for _, key := range keys {
i.accountsByKey[key] = account
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
}
}
func (i *codexAccountIndex) Find(keys []string) *service.Account {
func (i *codexAccountIndex) remove(accountID int64) {
for key, accounts := range i.accountsByKey {
kept := accounts[:0]
for _, account := range accounts {
if account.ID != accountID {
kept = append(kept, account)
}
}
if len(kept) == 0 {
delete(i.accountsByKey, key)
continue
}
i.accountsByKey[key] = kept
}
}
// upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应
// 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。
func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
for idx := range accounts {
if accounts[idx].ID == account.ID {
accounts[idx] = account
return accounts
}
}
return append(accounts, account)
}
// Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。
func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) {
if i == nil {
return nil
return nil, ""
}
for _, key := range keys {
if account, ok := i.accountsByKey[key]; ok {
return &account
for _, account := range i.accountsByKey[key] {
if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) {
continue
}
return &account, key
}
}
return nil
return nil, ""
}
func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) {
// codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个
// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧
// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token
// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。
func codexIdentityConflicts(key, userID, storedUserID string) bool {
if !strings.HasPrefix(key, "account:") {
return false
}
userID = strings.TrimSpace(userID)
storedUserID = strings.TrimSpace(storedUserID)
return userID != "" && storedUserID != "" && userID != storedUserID
}
type codexSeenIdentity struct {
index int
userID string
}
func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) {
for _, key := range keys {
if index, ok := seen[key]; ok {
return index, true
entry, ok := seen[key]
if !ok {
continue
}
if codexIdentityConflicts(key, userID, entry.userID) {
continue
}
return entry.index, true
}
return 0, false
}
func markCodexIdentitySeen(seen map[string]int, keys []string, index int) {
func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) {
for _, key := range keys {
seen[key] = index
seen[key] = codexSeenIdentity{index: index, userID: userID}
}
}
@@ -897,8 +988,15 @@ func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexI
return out
}
if strings.TrimSpace(item.RefreshToken) == "" {
delete(out, "refresh_token")
delete(out, "client_id")
if codexCredentialString(existing, "refresh_token") == "" {
delete(out, "refresh_token")
delete(out, "client_id")
} else {
out["refresh_token"] = existing["refresh_token"]
if clientID, ok := existing["client_id"]; ok {
out["client_id"] = clientID
}
}
}
if strings.TrimSpace(item.IDToken) == "" {
delete(out, "id_token")
@@ -1,12 +1,15 @@
package admin
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) {
@@ -142,7 +145,7 @@ func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *t
}
}
func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
func TestMergeCodexImportCredentialsPreservesExistingRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
existing := map[string]any{
"access_token": "old-access-token",
"refresh_token": "old-refresh-token",
@@ -169,11 +172,11 @@ func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRef
if merged["chatgpt_account_id"] != "acct-new" {
t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"])
}
if _, ok := merged["refresh_token"]; ok {
t.Fatalf("refresh_token should be cleared")
if merged["refresh_token"] != "old-refresh-token" {
t.Fatalf("refresh_token = %v, want old-refresh-token", merged["refresh_token"])
}
if _, ok := merged["client_id"]; ok {
t.Fatalf("client_id should be cleared")
if merged["client_id"] != "old-client-id" {
t.Fatalf("client_id = %v, want old-client-id", merged["client_id"])
}
if _, ok := merged["id_token"]; ok {
t.Fatalf("id_token should be cleared")
@@ -299,14 +302,20 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te
}
func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token")
keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh")
if len(keys) == 0 || keys[0] != "user:user-1" {
t.Fatalf("user key should have highest priority when refresh token exists: %v", keys)
}
if keys[len(keys)-1] != "account:acct-1" {
t.Fatalf("shared account key should be the last fallback: %v", keys)
}
for _, key := range keys {
if strings.HasPrefix(key, "email:") {
t.Fatalf("strong identity should not include email fallback: %v", keys)
}
}
keys = buildCodexIdentityKeys("", "", "same@example.com", "token")
keys = buildCodexImportIdentityKeys("", "", "same@example.com", "token", "refresh")
hasEmail := false
for _, key := range keys {
if key == "email:same@example.com" {
@@ -316,6 +325,605 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
if !hasEmail {
t.Fatalf("weak identity should include email fallback: %v", keys)
}
keys = buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "")
if len(keys) != 1 || !strings.HasPrefix(keys[0], "access:") {
t.Fatalf("accessToken-only identity should use only access fingerprint: %v", keys)
}
}
func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) {
existing := service.Account{
ID: 10,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"chatgpt_user_id": "user-1",
"access_token": "token-1",
"refresh_token": "refresh-1",
},
}
index := buildCodexAccountIndex([]service.Account{existing})
keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
if got, _ := index.Find(keys, "user-2"); got != nil {
t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID)
}
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-2", "refresh-2")
got, _ := index.Find(keys, "user-1")
if got == nil || got.ID != existing.ID {
t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID)
}
}
func TestCodexAccountIndexFallsBackToAccountKeyWhenRefreshTokenExistsAndUserIDMissing(t *testing.T) {
// 含 refresh_token 的常规导入沿用 a5638a4e 的兼容逻辑:存量账号缺少
// chatgpt_user_id 时,携带 user id 的重新导入仍可命中并回填。
legacy := service.Account{
ID: 20,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"access_token": "token-old",
"refresh_token": "refresh-old",
},
}
index := buildCodexAccountIndex([]service.Account{legacy})
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
got, matchedKey := index.Find(keys, "user-1")
if got == nil || got.ID != legacy.ID {
t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID)
}
if matchedKey != "account:team-1" {
t.Fatalf("matched key = %q, want account:team-1", matchedKey)
}
// 反向:含 refresh_token 的导入条目无法解析出 user id 时,仍应通过
// account 键命中已有账号,保持常规导入去重行为。
full := service.Account{
ID: 21,
Credentials: map[string]any{
"chatgpt_account_id": "team-2",
"chatgpt_user_id": "user-9",
"access_token": "token-old",
"refresh_token": "refresh-old",
},
}
index = buildCodexAccountIndex([]service.Account{full})
keys = buildCodexImportIdentityKeys("team-2", "", "", "token-opaque", "refresh-new")
got, _ = index.Find(keys, "")
if got == nil || got.ID != full.ID {
t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID)
}
}
func TestCodexAccountIndexAccessTokenOnlyUsesTokenFingerprint(t *testing.T) {
existing := service.Account{
ID: 22,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"chatgpt_user_id": "user-1",
"access_token": "token-old",
},
}
index := buildCodexAccountIndex([]service.Account{existing})
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "")
if got, matchedKey := index.Find(keys, "user-1"); got != nil {
t.Fatalf("accessToken-only import matched by %q despite different token: account ID %d", matchedKey, got.ID)
}
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-old", "")
got, matchedKey := index.Find(keys, "user-1")
if got == nil || got.ID != existing.ID {
t.Fatalf("Find accessToken-only duplicate by fingerprint = %v, want account ID %d", got, existing.ID)
}
if !strings.HasPrefix(matchedKey, "access:") {
t.Fatalf("matched key = %q, want access fingerprint", matchedKey)
}
}
func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) {
legacy := service.Account{
ID: 30,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"access_token": "token-legacy",
"refresh_token": "refresh-legacy",
},
}
member := service.Account{
ID: 31,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"chatgpt_user_id": "user-2",
"access_token": "token-member",
"refresh_token": "refresh-member",
},
}
// 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号,
// 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。
for _, accounts := range [][]service.Account{
{member, legacy},
{legacy, member},
} {
index := buildCodexAccountIndex(accounts)
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
got, matchedKey := index.Find(keys, "user-1")
if got == nil || got.ID != legacy.ID {
t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID)
}
if matchedKey != "account:team-1" {
t.Fatalf("matched key = %q, want account:team-1", matchedKey)
}
keys = buildCodexImportIdentityKeys("team-1", "user-2", "", "token-new", "refresh-new")
got, matchedKey = index.Find(keys, "user-2")
if got == nil || got.ID != member.ID {
t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID)
}
if matchedKey != "user:user-2" {
t.Fatalf("matched key = %q, want user:user-2", matchedKey)
}
}
}
func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) {
legacy := service.Account{
ID: 40,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"access_token": "token-old",
},
}
index := buildCodexAccountIndex([]service.Account{legacy})
backfilled := service.Account{
ID: 40,
Credentials: map[string]any{
"chatgpt_account_id": "team-1",
"chatgpt_user_id": "user-1",
"access_token": "token-new",
"refresh_token": "refresh-new",
},
}
index.Add(backfilled)
// 回填后同一账号在 account 键下应被原位替换而非残留旧副本:
// 其他成员的条目不应再通过旧副本(无 user id)命中该账号。
keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-other", "refresh-other")
if got, matchedKey := index.Find(keys, "user-2"); got != nil {
t.Fatalf("stale candidate matched after upsert by %q: account ID %d", matchedKey, got.ID)
}
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-other", "refresh-other")
got, _ := index.Find(keys, "user-1")
if got == nil || got.ID != backfilled.ID {
t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID)
}
if uid := codexCredentialString(got.Credentials, "chatgpt_user_id"); uid != "user-1" {
t.Fatalf("upsert did not replace credentials, chatgpt_user_id = %q", uid)
}
}
func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) {
seen := map[string]codexSeenIdentity{}
member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1")
markCodexIdentitySeen(seen, member1, 1, "user-1")
member2 := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok {
t.Fatalf("different team member treated as duplicate of entry %d", index)
}
again := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-3", "refresh-3")
index, ok := firstSeenCodexIdentity(seen, again, "user-1")
if !ok || index != 1 {
t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok)
}
// 无 user id 的条目不应因共享 account id 与已见团队成员互相去重;
// 只有相同 access token 指纹才视为重复。
opaque := buildCodexImportIdentityKeys("team-1", "", "", "token-4", "")
index, ok = firstSeenCodexIdentity(seen, opaque, "")
if ok {
t.Fatalf("entry without user id dedup = (%d, %v), want no match", index, ok)
}
}
func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T) {
accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
"sub": "user-from-access-token",
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "workspace-1",
},
})
item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: accessToken})
if err != nil {
t.Fatalf("normalizeCodexImportEntry error = %v", err)
}
if item.UserID != "user-from-access-token" {
t.Fatalf("UserID = %q, want JWT sub", item.UserID)
}
if len(item.IdentityKeys) != 1 || !strings.HasPrefix(item.IdentityKeys[0], "access:") {
t.Fatalf("IdentityKeys = %v, want access fingerprint only for accessToken-only import", item.IdentityKeys)
}
if got := item.Credentials["chatgpt_user_id"]; got != "user-from-access-token" {
t.Fatalf("credential chatgpt_user_id = %v, want JWT sub", got)
}
}
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) {
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")},
{Index: 2, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-2")},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
t.Fatalf("result = %+v, want two created accounts", result)
}
if len(svc.createdAccounts) != 2 {
t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
}
if svc.createdAccounts[0].Credentials["chatgpt_user_id"] == svc.createdAccounts[1].Credentials["chatgpt_user_id"] {
t.Fatalf("created accounts share user id: %v", svc.createdAccounts)
}
}
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) {
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: map[string]any{
"access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
"sub": "shared-user",
"jti": "token-1",
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "workspace-1",
},
}),
}},
{Index: 2, Value: map[string]any{
"access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
"sub": "shared-user",
"jti": "token-2",
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "workspace-1",
},
}),
}},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
t.Fatalf("result = %+v, want two created accounts", result)
}
if len(svc.createdAccounts) != 2 {
t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
}
}
func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T) {
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
svc := newCodexImportMemoryAdminService([]service.Account{{
ID: 10,
Name: "existing",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "workspace-1",
"chatgpt_user_id": "user-1",
"access_token": existingToken,
},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: map[string]any{"access_token": existingToken}},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
t.Fatalf("result = %+v, want one updated account", result)
}
if len(svc.createdAccounts) != 0 {
t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts))
}
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts)
}
}
func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) {
oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
svc := newCodexImportMemoryAdminService([]service.Account{{
ID: 12,
Name: "existing",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "workspace-1",
"chatgpt_user_id": "user-1",
"access_token": oldToken,
},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: map[string]any{
"access_token": newToken,
"refresh_token": "refresh-new",
}},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
t.Fatalf("result = %+v, want one updated account", result)
}
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 12 {
t.Fatalf("updated accounts = %+v, want account 12", svc.updatedAccounts)
}
if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
t.Fatalf("updated refresh_token = %v, want refresh-new", got)
}
}
func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *testing.T) {
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
svc := newCodexImportMemoryAdminService([]service.Account{{
ID: 13,
Name: "existing",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "workspace-1",
"chatgpt_user_id": "user-1",
"access_token": existingToken,
"refresh_token": "refresh-old",
"client_id": "client-old",
},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: map[string]any{"access_token": existingToken}},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
t.Fatalf("result = %+v, want one updated account", result)
}
update := svc.updatedAccounts[0].input
if got := update.Credentials["refresh_token"]; got != "refresh-old" {
t.Fatalf("refresh_token = %v, want refresh-old", got)
}
if got := update.Credentials["client_id"]; got != "client-old" {
t.Fatalf("client_id = %v, want client-old", got)
}
if update.ExpiresAt != nil {
t.Fatalf("ExpiresAt = %v, want nil to preserve OAuth account expiry", *update.ExpiresAt)
}
if update.AutoPauseOnExpired != nil {
t.Fatalf("AutoPauseOnExpired = %v, want nil to preserve OAuth account scheduling", *update.AutoPauseOnExpired)
}
}
func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *testing.T) {
oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
svc := newCodexImportMemoryAdminService([]service.Account{{
ID: 14,
Name: "existing",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "workspace-1",
"chatgpt_user_id": "user-1",
"access_token": oldToken,
"refresh_token": "refresh-old",
},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: map[string]any{
"access_token": newToken,
"refresh_token": "refresh-new",
}},
{Index: 2, Value: map[string]any{"access_token": oldToken}},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Updated != 1 || result.Created != 1 || result.Failed != 0 {
t.Fatalf("result = %+v, want first item updated and stale access token created separately", result)
}
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 14 {
t.Fatalf("updated accounts = %+v, want account 14 updated once", svc.updatedAccounts)
}
stored, err := svc.GetAccount(context.Background(), 14)
if err != nil {
t.Fatalf("GetAccount error = %v", err)
}
if got := stored.Credentials["access_token"]; got != newToken {
t.Fatalf("stored access_token rolled back = %v, want new token", got)
}
if got := stored.Credentials["refresh_token"]; got != "refresh-new" {
t.Fatalf("stored refresh_token = %v, want refresh-new", got)
}
}
func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) {
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
svc := newCodexImportMemoryAdminService([]service.Account{{
ID: 11,
Name: "existing",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "workspace-1",
"chatgpt_user_id": "user-1",
"access_token": existingToken,
"refresh_token": "refresh-old",
},
}})
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
t.Fatalf("result = %+v, want one updated account", result)
}
if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
t.Fatalf("updated refresh_token = %v, want refresh-new", got)
}
}
type codexImportMemoryAdminService struct {
*stubAdminService
nextID int64
updatedAccounts []struct {
id int64
input *service.UpdateAccountInput
}
}
func newCodexImportMemoryAdminService(accounts []service.Account) *codexImportMemoryAdminService {
stub := newStubAdminService()
stub.accounts = append([]service.Account(nil), accounts...)
return &codexImportMemoryAdminService{
stubAdminService: stub,
nextID: 100,
}
}
func (s *codexImportMemoryAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
s.createdAccounts = append(s.createdAccounts, input)
if s.createAccountErr != nil {
return nil, s.createAccountErr
}
account := service.Account{
ID: s.nextID,
Name: input.Name,
Platform: input.Platform,
Type: input.Type,
Status: service.StatusActive,
Credentials: cloneCodexImportTestMap(input.Credentials),
Extra: cloneCodexImportTestMap(input.Extra),
}
s.nextID++
s.accounts = append(s.accounts, account)
return &account, nil
}
func (s *codexImportMemoryAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
s.updatedAccounts = append(s.updatedAccounts, struct {
id int64
input *service.UpdateAccountInput
}{id: id, input: input})
if s.updateAccountErr != nil {
return nil, s.updateAccountErr
}
for idx := range s.accounts {
if s.accounts[idx].ID == id {
s.accounts[idx].Credentials = cloneCodexImportTestMap(input.Credentials)
s.accounts[idx].Extra = cloneCodexImportTestMap(input.Extra)
return &s.accounts[idx], nil
}
}
account := service.Account{ID: id, Status: service.StatusActive, Credentials: cloneCodexImportTestMap(input.Credentials)}
return &account, nil
}
func (s *codexImportMemoryAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
for idx := range s.accounts {
if s.accounts[idx].ID == id {
return &s.accounts[idx], nil
}
}
return s.stubAdminService.GetAccount(ctx, id)
}
func buildCodexAccessOnlyImportValue(t *testing.T, accountID, userID string) map[string]any {
t.Helper()
return map[string]any{
"access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
}
}
func buildCodexRefreshImportValue(t *testing.T, accountID, userID, refreshToken string) map[string]any {
t.Helper()
return map[string]any{
"access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
"refresh_token": refreshToken,
}
}
func buildCodexAccessToken(t *testing.T, accountID, userID string, exp time.Time) string {
t.Helper()
return buildCodexAccessTokenWithJTI(t, accountID, userID, "", exp)
}
func buildCodexAccessTokenWithJTI(t *testing.T, accountID, userID, jti string, exp time.Time) string {
t.Helper()
claims := map[string]any{
"sub": userID,
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": accountID,
},
}
if jti != "" {
claims["jti"] = jti
}
return buildCodexImportTestJWT(t, exp, claims)
}
func cloneCodexImportTestMap(input map[string]any) map[string]any {
if input == nil {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = value
}
return out
}
func boolPtr(v bool) *bool {
return &v
}
func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string {
+29 -3
View File
@@ -30,6 +30,9 @@ type DataPayload struct {
ExportedAt string `json:"exported_at"`
Proxies []DataProxy `json:"proxies"`
Accounts []DataAccount `json:"accounts"`
// SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
// 导入侧忽略该字段;omitempty 保持向后兼容。
SkippedShadows int `json:"skipped_shadows,omitempty"`
}
type DataProxy struct {
@@ -50,6 +53,10 @@ type DataProxy struct {
// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
// 应新增独立结构而非修改这里。
// 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
// 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
// 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
// (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
type DataAccount struct {
Name string `json:"name"`
Notes *string `json:"notes,omitempty"`
@@ -105,6 +112,24 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
return
}
// 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
// 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
// status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
// 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
skippedShadows := 0
exportable := make([]service.Account, 0, len(accounts))
for i := range accounts {
if accounts[i].IsCredentialShadow() {
skippedShadows++
continue
}
exportable = append(exportable, accounts[i])
}
accounts = exportable
if skippedShadows > 0 {
slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
}
includeProxies, err := parseIncludeProxies(c)
if err != nil {
response.BadRequest(c, err.Error())
@@ -191,9 +216,10 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
}
payload := DataPayload{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
SkippedShadows: skippedShadows,
}
response.Success(c, payload)
@@ -18,10 +18,11 @@ type dataResponse struct {
}
type dataPayload struct {
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
SkippedShadows int `json:"skipped_shadows"`
}
type dataProxy struct {
@@ -172,6 +173,46 @@ func TestExportDataWithoutProxies(t *testing.T) {
require.Nil(t, resp.Data.Accounts[0].ProxyKey)
}
// TestExportDataExcludesSparkShadow 验证外审第5轮 P1/P2:导出时排除 spark 影子账号
// (影子无凭据、导入侧强制 credentials 非空,混入会产出无法还原的坏备份),并透出跳过计数。
func TestExportDataExcludesSparkShadow(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
parentID := int64(21)
adminSvc.accounts = []service.Account{
{
ID: parentID,
Name: "mother",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{"token": "secret"},
Status: service.StatusActive,
},
{
ID: 22,
Name: "mother (Spark)",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{}, // 影子恒空凭据
ParentAccountID: &parentID, // 影子标记
QuotaDimension: service.QuotaDimensionSpark,
Status: service.StatusActive,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp dataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.Accounts, 1, "影子应被排除,仅导出母账号")
require.Equal(t, "mother", resp.Data.Accounts[0].Name)
require.Equal(t, 1, resp.Data.SkippedShadows, "跳过的影子数量应透出")
}
func TestExportDataPassesAccountFiltersAndSort(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
adminSvc.accounts = []service.Account{
@@ -11,6 +11,7 @@ import (
"log"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -25,6 +26,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -169,13 +171,29 @@ type CheckMixedChannelRequest struct {
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
CurrentConcurrency int `json:"current_concurrency"`
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
}
type AccountSchedulerScore struct {
BaseScore float64 `json:"base_score"`
StickyScore float64 `json:"sticky_score"`
StickyScoreInfinity bool `json:"sticky_score_infinity"`
StickyWeightedEnabled bool `json:"sticky_weighted_enabled"`
}
type AccountSchedulerGroupScore struct {
GroupID *int64 `json:"group_id"`
GroupName string `json:"group_name,omitempty"`
GroupPriority *int `json:"group_priority,omitempty"`
AccountSchedulerScore
}
const accountListGroupUngroupedQueryValue = "ungrouped"
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
@@ -219,9 +237,237 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
}
}
h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
return item
}
// scoreOpenAIAccountSchedulerPool 对池内 OpenAI 账号计算调度分数快照。
// loadMap 为共享的账号负载数据(含池内全部账号即可,多余条目无害);传 nil 时自行批查。
func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account, loadMap map[int64]*service.AccountLoadInfo) map[int64]AccountSchedulerScore {
if len(accounts) == 0 {
return nil
}
openAIAccounts := make([]*service.Account, 0, len(accounts))
for i := range accounts {
account := &accounts[i]
if account.Platform != service.PlatformOpenAI {
continue
}
openAIAccounts = append(openAIAccounts, account)
}
if len(openAIAccounts) == 0 {
return nil
}
if loadMap == nil {
loadMap = h.fetchOpenAIAccountLoadMap(ctx, openAIAccounts)
}
var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot
if h.rateLimitService != nil {
scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap)
} else {
scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap)
}
result := make(map[int64]AccountSchedulerScore, len(scores))
for accountID, score := range scores {
result[accountID] = AccountSchedulerScore{
BaseScore: score.BaseScore,
StickyScore: score.StickyScore,
StickyScoreInfinity: score.StickyScoreInfinity,
StickyWeightedEnabled: score.StickyWeightedEnabled,
}
}
return result
}
// fetchOpenAIAccountLoadMap 一次性批查给定 OpenAI 账号的负载数据;
// 失败时记录日志并返回空表(分数按零负载计算,属可接受降级)。
func (h *AccountHandler) fetchOpenAIAccountLoadMap(ctx context.Context, openAIAccounts []*service.Account) map[int64]*service.AccountLoadInfo {
loadMap := map[int64]*service.AccountLoadInfo{}
if h.concurrencyService == nil || len(openAIAccounts) == 0 {
return loadMap
}
seen := make(map[int64]struct{}, len(openAIAccounts))
loadReq := make([]service.AccountWithConcurrency, 0, len(openAIAccounts))
for _, account := range openAIAccounts {
if account == nil {
continue
}
if _, ok := seen[account.ID]; ok {
continue
}
seen[account.ID] = struct{}{}
loadReq = append(loadReq, service.AccountWithConcurrency{
ID: account.ID,
MaxConcurrency: account.EffectiveLoadFactor(),
})
}
if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err != nil {
slog.Warn("openai_scheduler_score_load_batch_failed", "error", err)
} else if batchLoad != nil {
loadMap = batchLoad
}
return loadMap
}
func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
ctx context.Context,
accounts []service.Account,
filterPool []service.Account,
) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) {
if len(accounts) == 0 {
return nil, nil
}
if len(filterPool) == 0 {
filterPool = accounts
}
pageOpenAIAccountIDs := make(map[int64]struct{})
groupIDs := make(map[int64]struct{})
for i := range accounts {
account := &accounts[i]
if account.Platform != service.PlatformOpenAI {
continue
}
pageOpenAIAccountIDs[account.ID] = struct{}{}
if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
continue
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.GroupID > 0 {
groupIDs[accountGroup.GroupID] = struct{}{}
}
}
for _, groupID := range account.GroupIDs {
if groupID > 0 {
groupIDs[groupID] = struct{}{}
}
}
}
if len(pageOpenAIAccountIDs) == 0 {
return nil, nil
}
// 先取各分组池,再对"过滤池 ∪ 分组池"的账号并集做一次负载批查,
// 避免每个池各查一次 Redis 的 N+1。
groupIDList := make([]int64, 0, len(groupIDs))
for groupID := range groupIDs {
groupIDList = append(groupIDList, groupID)
}
sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
groupPools := make(map[int64][]service.Account, len(groupIDList))
if h.adminService != nil {
for _, groupID := range groupIDList {
gid := groupID
pool, err := h.adminService.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
if err != nil {
slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
continue
}
groupPools[gid] = pool
}
}
loadUnion := make([]*service.Account, 0, len(filterPool))
collectOpenAIAccounts := func(pool []service.Account) {
for i := range pool {
if pool[i].Platform == service.PlatformOpenAI {
loadUnion = append(loadUnion, &pool[i])
}
}
}
collectOpenAIAccounts(filterPool)
for _, pool := range groupPools {
collectOpenAIAccounts(pool)
}
loadMap := h.fetchOpenAIAccountLoadMap(ctx, loadUnion)
baseScores := make(map[int64]*AccountSchedulerScore)
for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool, loadMap) {
copiedScore := score
baseScores[accountID] = &copiedScore
}
groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore)
scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) {
if len(pool) == 0 {
return
}
scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool, loadMap)
for accountID, schedulerScore := range scores {
if _, ok := pageOpenAIAccountIDs[accountID]; !ok {
continue
}
groupScore := AccountSchedulerGroupScore{
GroupID: groupID,
AccountSchedulerScore: schedulerScore,
}
if groupID != nil {
groupScore.GroupName = groupNameByID[*groupID]
if priority, ok := groupPriorityByAccount[accountID]; ok {
groupScore.GroupPriority = &priority
}
}
groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore)
}
}
for _, groupID := range groupIDList {
gid := groupID
pool, ok := groupPools[gid]
if !ok {
continue
}
groupNameByID := make(map[int64]string)
groupPriorityByAccount := make(map[int64]int)
for i := range pool {
account := &pool[i]
for _, accountGroup := range account.AccountGroups {
if accountGroup.GroupID != gid {
continue
}
groupPriorityByAccount[account.ID] = accountGroup.Priority
if accountGroup.Group != nil {
groupNameByID[gid] = accountGroup.Group.Name
}
}
}
scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
}
for accountID := range groupScoresByAccount {
sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool {
left := groupScoresByAccount[accountID][i]
right := groupScoresByAccount[accountID][j]
return *left.GroupID < *right.GroupID
})
}
return baseScores, groupScoresByAccount
}
func (h *AccountHandler) listAccountSchedulerScoreFilterPool(
ctx context.Context,
platform, accountType, status, search string,
groupID int64,
privacyMode string,
) []service.Account {
if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) {
return nil
}
// 池只用于 OpenAI 分数计算(非 OpenAI 账号会在打分时被丢弃),
// 无论列表页平台过滤为何,查询一律限定 openai,避免无过滤时全表扫描。
accounts, err := h.adminService.ListAccountsForSchedulerScoreFilter(ctx, service.PlatformOpenAI, accountType, status, search, groupID, privacyMode)
if err != nil {
slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err)
return nil
}
return accounts
}
// List handles listing all accounts with pagination
// GET /api/v1/admin/accounts
func (h *AccountHandler) List(c *gin.Context) {
@@ -274,6 +520,20 @@ func (h *AccountHandler) List(c *gin.Context) {
var windowCosts map[int64]float64
var activeSessions map[int64]int
var rpmCounts map[int64]int
// 仅当前页存在 OpenAI 账号时才计算调度分数,避免为空结果付出池查询开销。
var schedulerScores map[int64]*AccountSchedulerScore
var schedulerGroupScores map[int64][]AccountSchedulerGroupScore
pageHasOpenAIAccounts := false
for i := range accounts {
if accounts[i].Platform == service.PlatformOpenAI {
pageHasOpenAIAccounts = true
break
}
}
if pageHasOpenAIAccounts {
schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
}
// 始终获取并发数(Redis ZCARD,极低开销)
if h.concurrencyService != nil {
@@ -354,6 +614,8 @@ func (h *AccountHandler) List(c *gin.Context) {
item := AccountWithConcurrency{
Account: dto.AccountFromService(acc),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
}
// 添加窗口费用(仅当启用时)
@@ -380,6 +642,8 @@ func (h *AccountHandler) List(c *gin.Context) {
result[i] = item
}
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if etag != "" {
c.Header("ETag", etag)
@@ -832,6 +1096,12 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
if !account.IsOAuth() {
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
}
// spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
// (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
if account.IsCredentialShadow() {
return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
"cannot refresh spark shadow account; its credentials are managed by the parent account")
}
var newCredentials map[string]any
@@ -1812,7 +2082,7 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
}
if err := h.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
response.InternalError(c, "Failed to reset account quota: "+err.Error())
response.ErrorFrom(c, err)
return
}
@@ -2064,6 +2334,56 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
// Handle Grok accounts
if account.Platform == service.PlatformGrok {
defaultModels := xai.DefaultModels()
hasExplicitMapping := false
switch rawMapping := account.Credentials["model_mapping"].(type) {
case map[string]any:
hasExplicitMapping = len(rawMapping) > 0
case map[string]string:
hasExplicitMapping = len(rawMapping) > 0
}
if !hasExplicitMapping {
response.Success(c, defaultModels)
return
}
mapping := account.GetModelMapping()
if len(mapping) == 0 {
response.Success(c, defaultModels)
return
}
defaultByID := make(map[string]xai.Model, len(defaultModels))
for _, model := range defaultModels {
defaultByID[model.ID] = model
}
requestedModels := make([]string, 0, len(mapping))
for requestedModel := range mapping {
requestedModels = append(requestedModels, requestedModel)
}
sort.Strings(requestedModels)
var models []xai.Model
for _, requestedModel := range requestedModels {
if defaultModel, found := defaultByID[requestedModel]; found {
models = append(models, defaultModel)
continue
}
models = append(models, xai.Model{
ID: requestedModel,
Object: "model",
OwnedBy: "xai",
DisplayName: requestedModel,
})
}
response.Success(c, models)
return
}
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
@@ -61,6 +61,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
nil,
nil,
nil,
nil,
upstream,
&config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
nil,
@@ -70,6 +71,77 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
return router
}
func TestAccountHandlerGetAvailableModels_GrokUsesXAIModels(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "grok-oauth",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{
"model_mapping": map[string]any{
"grok-4.3": "grok-4.3",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 1)
require.Equal(t, "grok-4.3", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_GrokDefaultsToXAIModelsWithoutMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 45,
Name: "grok-oauth-defaults",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Data)
var ids []string
for _, model := range resp.Data {
id := model.ID
ids = append(ids, id)
require.NotContains(t, strings.ToLower(id), "claude")
}
require.Contains(t, ids, "grok-4.3")
require.Contains(t, ids, "grok-build-0.1")
}
func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -141,6 +213,48 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
parentID := int64(100)
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "openai-spark-shadow",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
ids := make([]string, 0, len(resp.Data))
for _, m := range resp.Data {
ids = append(ids, m.ID)
}
require.ElementsMatch(t, []string{
"gpt-5.3-codex-spark",
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
}
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -50,3 +51,222 @@ func TestAccountHandlerListIncludesCreatedAt(t *testing.T) {
_, offset := parsed.Zone()
require.Equal(t, 0, offset)
}
func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
groupID := int64(41)
adminSvc.accounts = []service.Account{
{
ID: 101,
Name: "account-high-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 1,
AccountGroups: []service.AccountGroup{
{AccountID: 101, GroupID: groupID, Priority: 100, Group: &service.Group{ID: groupID, Name: "openai"}},
},
GroupIDs: []int64{groupID},
CreatedAt: now,
UpdatedAt: now,
},
{
ID: 102,
Name: "account-low-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 100000,
AccountGroups: []service.AccountGroup{
{AccountID: 102, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
},
GroupIDs: []int64{groupID},
CreatedAt: now,
UpdatedAt: now,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
Data struct {
Items []struct {
ID int64 `json:"id"`
SchedulerScore struct {
BaseScore float64 `json:"base_score"`
} `json:"scheduler_score"`
SchedulerScores []struct {
GroupID *int64 `json:"group_id"`
GroupName string `json:"group_name"`
GroupPriority *int `json:"group_priority"`
BaseScore float64 `json:"base_score"`
} `json:"scheduler_scores"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 2)
var high, low *struct {
ID int64 `json:"id"`
SchedulerScore struct {
BaseScore float64 `json:"base_score"`
} `json:"scheduler_score"`
SchedulerScores []struct {
GroupID *int64 `json:"group_id"`
GroupName string `json:"group_name"`
GroupPriority *int `json:"group_priority"`
BaseScore float64 `json:"base_score"`
} `json:"scheduler_scores"`
}
for i := range payload.Data.Items {
item := &payload.Data.Items[i]
switch item.ID {
case 101:
high = item
case 102:
low = item
}
}
require.NotNil(t, high)
require.NotNil(t, low)
require.Len(t, high.SchedulerScores, 1)
require.Len(t, low.SchedulerScores, 1)
require.Equal(t, groupID, *high.SchedulerScores[0].GroupID)
require.Equal(t, "openai", high.SchedulerScores[0].GroupName)
require.Equal(t, 100, *high.SchedulerScores[0].GroupPriority)
require.Equal(t, 1, *low.SchedulerScores[0].GroupPriority)
require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore)
}
func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
groupID := int64(42)
visibleAccount := service.Account{
ID: 201,
Name: "visible-low-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 100000,
AccountGroups: []service.AccountGroup{
{AccountID: 201, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
},
GroupIDs: []int64{groupID},
CreatedAt: now,
UpdatedAt: now,
}
hiddenGroupPeer := service.Account{
ID: 202,
Name: "hidden-high-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 1,
AccountGroups: []service.AccountGroup{
{AccountID: 202, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "openai"}},
},
GroupIDs: []int64{groupID},
CreatedAt: now,
UpdatedAt: now,
}
adminSvc.accounts = []service.Account{visibleAccount}
adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
Data struct {
Items []struct {
ID int64 `json:"id"`
SchedulerScore struct {
BaseScore float64 `json:"base_score"`
} `json:"scheduler_score"`
SchedulerScores []struct {
GroupID *int64 `json:"group_id"`
BaseScore float64 `json:"base_score"`
} `json:"scheduler_scores"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
item := payload.Data.Items[0]
require.Equal(t, int64(201), item.ID)
require.Len(t, item.SchedulerScores, 1)
require.Equal(t, groupID, *item.SchedulerScores[0].GroupID)
require.Equal(t, item.SchedulerScores[0].BaseScore, item.SchedulerScore.BaseScore)
}
func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
visibleAccount := service.Account{
ID: 301,
Name: "visible-low-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 100000,
CreatedAt: now,
UpdatedAt: now,
}
hiddenFilterPeer := service.Account{
ID: 302,
Name: "hidden-high-priority",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 10,
Priority: 1,
CreatedAt: now,
UpdatedAt: now,
}
adminSvc.accounts = []service.Account{visibleAccount}
adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
Data struct {
Items []struct {
ID int64 `json:"id"`
SchedulerScore struct {
BaseScore float64 `json:"base_score"`
} `json:"scheduler_score"`
SchedulerScores []struct {
GroupID *int64 `json:"group_id"`
BaseScore float64 `json:"base_score"`
} `json:"scheduler_scores"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
require.Equal(t, int64(301), payload.Data.Items[0].ID)
require.Less(t, payload.Data.Items[0].SchedulerScore.BaseScore, 3.75)
require.Empty(t, payload.Data.Items[0].SchedulerScores)
}
@@ -0,0 +1,29 @@
package admin
import (
"context"
"net/http"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestRefreshSingleAccount_RejectsShadow 验证外审第6轮:手动刷新对 spark 影子在调用上游前早拒
// (影子凭据由母账号管理、自身恒空,刷新无意义)。该守卫同时覆盖单账号与批量刷新两入口。
func TestRefreshSingleAccount_RejectsShadow(t *testing.T) {
h := &AccountHandler{} // 影子在使用任何依赖前即返回,无需注入
parentID := int64(5)
shadow := &service.Account{
ID: 9,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, // IsOAuth()=true,确保不是先撞 NOT_OAUTH
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
}
_, _, err := h.refreshSingleAccount(context.Background(), shadow)
require.Error(t, err, "影子刷新应被早拒")
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// enrichShadowParentInfo 把母账号的展示信息回填到影子行的 parent_* 字段。
// 纯函数:仅依赖传入的母账号 map,便于单测;非影子或母账号缺失时优雅留空。
func enrichShadowParentInfo(items []AccountWithConcurrency, parents map[int64]*service.Account) {
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
p := parents[*a.ParentAccountID]
if p == nil {
continue
}
a.ParentEmail = p.GetCredential("email")
a.ParentPlanType = p.GetCredential("plan_type")
a.ParentSubscriptionExpiresAt = p.GetCredential("subscription_expires_at")
a.ParentChatGPTAccountID = p.GetCredential("chatgpt_account_id")
a.ParentPrivacyMode = p.GetExtraString("privacy_mode")
}
}
// enrichShadowParents 收集本批影子行的母账号 ID、一次批量解析(避免 N+1),再回填。
// 解析失败时不报错(parent_* 留空,降级)。
func (h *AccountHandler) enrichShadowParents(ctx context.Context, items []AccountWithConcurrency) {
seen := make(map[int64]struct{})
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
seen[*a.ParentAccountID] = struct{}{}
}
if len(seen) == 0 {
return
}
parentIDs := make([]int64, 0, len(seen))
for pid := range seen {
parentIDs = append(parentIDs, pid)
}
parents, err := h.adminService.GetAccountsByIDs(ctx, parentIDs)
if err != nil {
return
}
pmap := make(map[int64]*service.Account, len(parents))
for _, p := range parents {
pmap[p.ID] = p
}
enrichShadowParentInfo(items, pmap)
}
@@ -0,0 +1,42 @@
package admin
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestEnrichShadowParentInfo(t *testing.T) {
pid := int64(100)
parent := &service.Account{
ID: 100,
Credentials: map[string]any{
"email": "owner@example.com",
"plan_type": "pro",
"subscription_expires_at": "2026-12-31T00:00:00Z",
"chatgpt_account_id": "acct_123",
},
Extra: map[string]any{"privacy_mode": "training_off"},
}
parents := map[int64]*service.Account{100: parent}
shadow := AccountWithConcurrency{Account: &dto.Account{ID: 200, ParentAccountID: &pid}}
normal := AccountWithConcurrency{Account: &dto.Account{ID: 1}}
orphan := AccountWithConcurrency{Account: &dto.Account{ID: 201, ParentAccountID: ptrInt64(999)}}
items := []AccountWithConcurrency{shadow, normal, orphan}
enrichShadowParentInfo(items, parents)
require.Equal(t, "owner@example.com", items[0].ParentEmail, "影子回填母账号邮箱")
require.Equal(t, "pro", items[0].ParentPlanType)
require.Equal(t, "training_off", items[0].ParentPrivacyMode)
require.Equal(t, "2026-12-31T00:00:00Z", items[0].ParentSubscriptionExpiresAt)
require.Equal(t, "acct_123", items[0].ParentChatGPTAccountID)
require.Empty(t, items[1].ParentEmail, "非影子不回填")
require.Empty(t, items[2].ParentEmail, "母账号缺失时优雅留空")
}
func ptrInt64(v int64) *int64 { return &v }
@@ -10,26 +10,29 @@ import (
)
type stubAdminService struct {
users []service.User
apiKeys []service.APIKey
groups []service.Group
accounts []service.Account
proxies []service.Proxy
proxyCounts []service.ProxyWithAccountCount
redeems []service.RedeemCode
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
testedProxyIDs []int64
getUserErr error
createAccountErr error
updateAccountErr error
bulkUpdateAccountErr error
checkMixedErr error
lastMixedCheck struct {
users []service.User
apiKeys []service.APIKey
groups []service.Group
accounts []service.Account
accountSchedulerScoreFilterAccounts []service.Account
openAISchedulerScorePoolAccounts []service.Account
proxies []service.Proxy
proxyCounts []service.ProxyWithAccountCount
redeems []service.RedeemCode
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
testedProxyIDs []int64
getUserErr error
createAccountErr error
createSparkShadowErr error
updateAccountErr error
bulkUpdateAccountErr error
checkMixedErr error
lastMixedCheck struct {
accountID int64
platform string
groupIDs []int64
@@ -328,7 +331,56 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int,
s.lastListAccounts.sortBy = sortBy
s.lastListAccounts.sortOrder = sortOrder
s.lastListAccounts.calls++
return s.accounts, int64(len(s.accounts)), nil
accounts := s.accounts
total := len(accounts)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = total
}
start := (page - 1) * pageSize
if start >= total {
return []service.Account{}, int64(total), nil
}
end := start + pageSize
if end > total {
end = total
}
return accounts[start:end], int64(total), nil
}
func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) {
if s.accountSchedulerScoreFilterAccounts != nil {
return s.accountSchedulerScoreFilterAccounts, nil
}
return s.accounts, nil
}
func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ context.Context, groupID *int64) ([]service.Account, error) {
accounts := s.openAISchedulerScorePoolAccounts
if accounts == nil {
accounts = s.accounts
}
out := make([]service.Account, 0, len(accounts))
for _, account := range accounts {
if account.Platform != service.PlatformOpenAI || !account.IsSchedulable() {
continue
}
if groupID == nil {
if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
out = append(out, account)
}
continue
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.GroupID == *groupID {
out = append(out, account)
break
}
}
}
return out, nil
}
func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
@@ -636,5 +688,25 @@ func (s *stubAdminService) RevertAccountProxyFallback(ctx context.Context, id in
return nil
}
func (s *stubAdminService) CreateShadow(ctx context.Context, parentID int64, opts service.ShadowOptions) (*service.Account, error) {
if s.createSparkShadowErr != nil {
return nil, s.createSparkShadowErr
}
pid := parentID
return &service.Account{
ID: 9001,
Name: opts.Name,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Priority: opts.Priority,
Concurrency: opts.Concurrency,
GroupIDs: opts.GroupIDs,
ParentAccountID: &pid,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{},
Extra: map[string]any{},
}, nil
}
// Ensure stub implements interface.
var _ service.AdminService = (*stubAdminService)(nil)
@@ -95,6 +95,10 @@ type CreateGroupRequest struct {
AllowImageGeneration bool `json:"allow_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -136,6 +140,10 @@ type UpdateGroupRequest struct {
AllowImageGeneration *bool `json:"allow_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -277,6 +285,11 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
response.BadRequest(c, err.Error())
return
}
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
Name: req.Name,
Description: req.Description,
@@ -290,6 +303,10 @@ func (h *GroupHandler) Create(c *gin.Context) {
AllowImageGeneration: req.AllowImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
ImageRateMultiplier: req.ImageRateMultiplier,
PeakRateEnabled: req.PeakRateEnabled,
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
PeakRateMultiplier: req.PeakRateMultiplier,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -346,6 +363,10 @@ func (h *GroupHandler) Update(c *gin.Context) {
AllowImageGeneration: req.AllowImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
ImageRateMultiplier: req.ImageRateMultiplier,
PeakRateEnabled: req.PeakRateEnabled,
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
PeakRateMultiplier: req.PeakRateMultiplier,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -194,6 +194,13 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
return
}
// spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
// 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
if account.IsCredentialShadow() {
response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
return
}
// Use OpenAI OAuth service to refresh token
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
if err != nil {
@@ -417,6 +424,43 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
response.Success(c, usage)
}
// CreateShadowRequest is the request body for CreateShadow.
type CreateShadowRequest struct {
Name string `json:"name"`
Priority int `json:"priority"`
Concurrency int `json:"concurrency"`
GroupIDs []int64 `json:"group_ids"`
}
// CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
// POST /api/v1/admin/accounts/:id/shadow
func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
var req CreateShadowRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
Name: req.Name,
Priority: req.Priority,
Concurrency: req.Concurrency,
GroupIDs: req.GroupIDs,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromServiceShallow(shadow))
}
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
// POST /api/v1/admin/openai/accounts/:id/reset-quota
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
@@ -0,0 +1,106 @@
//go:build unit
package admin
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50,"concurrency":2,"group_ids":[10,20]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp["data"].(map[string]any)
require.True(t, ok, "response should have data field")
// parent_account_id must be present and equal to the path param
pid, ok := data["parent_account_id"].(float64)
require.True(t, ok, "parent_account_id should be present")
require.Equal(t, float64(42), pid)
// quota_dimension must be "spark"
require.Equal(t, service.QuotaDimensionSpark, data["quota_dimension"])
// name round-trips
require.Equal(t, "p-spark", data["name"])
}
func TestCreateShadow_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/shadow",
strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestCreateShadow_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
// A generic (non-ApplicationError) service error maps to 500 via response.ErrorFrom.
require.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestCreateShadow_BadBody(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow",
strings.NewReader(`{not valid json`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
+41 -8
View File
@@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
}
// GetErrorLogs lists ops error logs.
// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
// Column whitelist and order normalization live in the repository; unknown
// values degrade to the default (created_at DESC), mirroring the usage list.
func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
}
// GET /api/v1/admin/ops/errors
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
if h.opsService == nil {
@@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
}
filter.View = "all"
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
filter.ClientRequestID = clientRequestID
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.View = parseOpsViewParam(c)
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -1,6 +1,8 @@
package admin
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@@ -47,6 +49,9 @@ func (h *OpsHandler) GetConcurrencyStats(c *gin.Context) {
platform, group, account, collectedAt, err := h.opsService.GetConcurrencyStats(c.Request.Context(), platformFilter, groupID)
if err != nil {
if isOpsRealtimeRequestCanceled(c, err) {
return
}
response.ErrorFrom(c, err)
return
}
@@ -86,6 +91,9 @@ func (h *OpsHandler) GetUserConcurrencyStats(c *gin.Context) {
users, collectedAt, err := h.opsService.GetUserConcurrencyStats(c.Request.Context())
if err != nil {
if isOpsRealtimeRequestCanceled(c, err) {
return
}
response.ErrorFrom(c, err)
return
}
@@ -140,6 +148,9 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) {
platformStats, groupStats, accountStats, collectedAt, err := h.opsService.GetAccountAvailabilityStats(c.Request.Context(), platform, groupID)
if err != nil {
if isOpsRealtimeRequestCanceled(c, err) {
return
}
response.ErrorFrom(c, err)
return
}
@@ -156,6 +167,19 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) {
response.Success(c, payload)
}
func isOpsRealtimeRequestCanceled(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return true
}
if c != nil && c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
return true
}
return strings.Contains(err.Error(), "canceling statement due to user request")
}
func parseOpsRealtimeWindow(v string) (time.Duration, string, bool) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "1min", "1m":
@@ -236,6 +260,9 @@ func (h *OpsHandler) GetRealtimeTrafficSummary(c *gin.Context) {
summary, err := h.opsService.GetRealtimeTrafficSummary(c.Request.Context(), filter)
if err != nil {
if isOpsRealtimeRequestCanceled(c, err) {
return
}
response.ErrorFrom(c, err)
return
}
@@ -21,6 +21,7 @@ type opsSystemLogCleanupRequest struct {
RequestID string `json:"request_id"`
ClientRequestID string `json:"client_request_id"`
UserID *int64 `json:"user_id"`
APIKeyID *int64 `json:"api_key_id"`
AccountID *int64 `json:"account_id"`
Platform string `json:"platform"`
Model string `json:"model"`
@@ -71,6 +72,14 @@ func (h *OpsHandler) ListSystemLogs(c *gin.Context) {
}
filter.UserID = &id
}
if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
id, parseErr := strconv.ParseInt(v, 10, 64)
if parseErr != nil || id <= 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
filter.APIKeyID = &id
}
if v := strings.TrimSpace(c.Query("account_id")); v != "" {
id, parseErr := strconv.ParseInt(v, 10, 64)
if parseErr != nil || id <= 0 {
@@ -136,6 +145,10 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
response.BadRequest(c, "Invalid end_time")
return
}
if req.APIKeyID != nil && *req.APIKeyID <= 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
filter := &service.OpsSystemLogCleanupFilter{
StartTime: start,
@@ -145,6 +158,7 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
RequestID: strings.TrimSpace(req.RequestID),
ClientRequestID: strings.TrimSpace(req.ClientRequestID),
UserID: req.UserID,
APIKeyID: req.APIKeyID,
AccountID: req.AccountID,
Platform: strings.TrimSpace(req.Platform),
Model: strings.TrimSpace(req.Model),
@@ -72,6 +72,19 @@ func TestOpsSystemLogHandler_ListInvalidAccountID(t *testing.T) {
}
}
func TestOpsSystemLogHandler_ListInvalidAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, false)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/logs?api_key_id=abc", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_ListMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
@@ -178,6 +191,34 @@ func TestOpsSystemLogHandler_CleanupServiceUnavailable(t *testing.T) {
}
}
func TestOpsSystemLogHandler_CleanupAcceptsAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":123}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupInvalidAPIKeyID(t *testing.T) {
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h := NewOpsHandler(svc)
r := newOpsSystemLogTestRouter(h, true)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":0}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", w.Code)
}
}
func TestOpsSystemLogHandler_CleanupMonitoringDisabled(t *testing.T) {
svc := service.NewOpsService(nil, nil, &config.Config{
Ops: config.OpsConfig{Enabled: false},
@@ -257,6 +257,22 @@ func (h *PaymentHandler) ProcessRefund(c *gin.Context) {
response.Success(c, result)
}
// QueryAndFinalizeRefund queries the provider refund status and finalizes a pending refund.
// POST /api/v1/admin/payment/orders/:id/refund/query
func (h *PaymentHandler) QueryAndFinalizeRefund(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
result, err := h.paymentService.QueryAndFinalizeRefund(c.Request.Context(), orderID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// --- Subscription Plans ---
// ListPlans returns all subscription plans.
+499 -361
View File
@@ -119,187 +119,211 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
}
payload := dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
FrontendURL: settings.FrontendURL,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
SMTPHost: settings.SMTPHost,
SMTPPort: settings.SMTPPort,
SMTPUsername: settings.SMTPUsername,
SMTPPasswordConfigured: settings.SMTPPasswordConfigured,
SMTPFrom: settings.SMTPFrom,
SMTPFromName: settings.SMTPFromName,
SMTPUseTLS: settings.SMTPUseTLS,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: settings.DingTalkConnectEnabled,
DingTalkConnectClientID: settings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: settings.WeChatConnectEnabled,
WeChatConnectAppID: settings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured,
WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured,
WeChatConnectMPAppID: settings.WeChatConnectMPAppID,
WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured,
WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured,
WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled,
WeChatConnectMode: settings.WeChatConnectMode,
WeChatConnectScopes: settings.WeChatConnectScopes,
WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: settings.OIDCConnectEnabled,
OIDCConnectProviderName: settings.OIDCConnectProviderName,
OIDCConnectClientID: settings.OIDCConnectClientID,
OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured,
OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: settings.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL,
OIDCConnectScopes: settings.OIDCConnectScopes,
OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE,
OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken,
OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GitHubOAuthClientID: settings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
GoogleOAuthClientID: settings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback,
FallbackModelAnthropic: settings.FallbackModelAnthropic,
FallbackModelOpenAI: settings.FallbackModelOpenAI,
FallbackModelGemini: settings.FallbackModelGemini,
FallbackModelAntigravity: settings.FallbackModelAntigravity,
EnableIdentityPatch: settings.EnableIdentityPatch,
IdentityPatchPrompt: settings.IdentityPatchPrompt,
OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled,
OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled,
OpsQueryModeDefault: settings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled,
OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
PaymentDailyLimit: paymentCfg.DailyLimit,
PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders,
PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
PaymentHelpImageURL: paymentCfg.HelpImageURL,
PaymentHelpText: paymentCfg.HelpText,
PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode,
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
FrontendURL: settings.FrontendURL,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
SMTPHost: settings.SMTPHost,
SMTPPort: settings.SMTPPort,
SMTPUsername: settings.SMTPUsername,
SMTPPasswordConfigured: settings.SMTPPasswordConfigured,
SMTPFrom: settings.SMTPFrom,
SMTPFromName: settings.SMTPFromName,
SMTPUseTLS: settings.SMTPUseTLS,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: settings.DingTalkConnectEnabled,
DingTalkConnectClientID: settings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: settings.WeChatConnectEnabled,
WeChatConnectAppID: settings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured,
WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured,
WeChatConnectMPAppID: settings.WeChatConnectMPAppID,
WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured,
WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured,
WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled,
WeChatConnectMode: settings.WeChatConnectMode,
WeChatConnectScopes: settings.WeChatConnectScopes,
WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: settings.OIDCConnectEnabled,
OIDCConnectProviderName: settings.OIDCConnectProviderName,
OIDCConnectClientID: settings.OIDCConnectClientID,
OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured,
OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: settings.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL,
OIDCConnectScopes: settings.OIDCConnectScopes,
OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE,
OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken,
OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GitHubOAuthClientID: settings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
GoogleOAuthClientID: settings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback,
FallbackModelAnthropic: settings.FallbackModelAnthropic,
FallbackModelOpenAI: settings.FallbackModelOpenAI,
FallbackModelGemini: settings.FallbackModelGemini,
FallbackModelAntigravity: settings.FallbackModelAntigravity,
EnableIdentityPatch: settings.EnableIdentityPatch,
IdentityPatchPrompt: settings.IdentityPatchPrompt,
OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled,
OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled,
OpsQueryModeDefault: settings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled,
OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled,
OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
OpenAIAdvancedSchedulerLBTopK: settings.OpenAIAdvancedSchedulerLBTopK,
OpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority,
OpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad,
OpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue,
OpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate,
OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK,
OpenAIAdvancedSchedulerEffectiveWeightPriority: settings.OpenAIAdvancedSchedulerEffectiveWeightPriority,
OpenAIAdvancedSchedulerEffectiveWeightLoad: settings.OpenAIAdvancedSchedulerEffectiveWeightLoad,
OpenAIAdvancedSchedulerEffectiveWeightQueue: settings.OpenAIAdvancedSchedulerEffectiveWeightQueue,
OpenAIAdvancedSchedulerEffectiveWeightErrorRate: settings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate,
OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset,
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
PaymentDailyLimit: paymentCfg.DailyLimit,
PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders,
PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
PaymentHelpImageURL: paymentCfg.HelpImageURL,
PaymentHelpText: paymentCfg.HelpText,
PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
@@ -598,6 +622,7 @@ type UpdateSettingsRequest struct {
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
@@ -616,7 +641,19 @@ type UpdateSettingsRequest struct {
PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
// OpenAI account scheduling
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
// 余额不足提醒
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
@@ -636,6 +673,7 @@ type UpdateSettingsRequest struct {
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
@@ -1731,6 +1769,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RewriteMessageCacheControl
}(),
EnableClientDatelineNormalization: func() bool {
if req.EnableClientDatelineNormalization != nil {
return *req.EnableClientDatelineNormalization
}
return previousSettings.EnableClientDatelineNormalization
}(),
AntigravityUserAgentVersion: func() string {
if req.AntigravityUserAgentVersion != nil {
return *req.AntigravityUserAgentVersion
@@ -1784,6 +1828,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAIAdvancedSchedulerEnabled
}(),
OpenAIAdvancedSchedulerStickyWeightedEnabled: func() bool {
if req.OpenAIAdvancedSchedulerStickyWeightedEnabled != nil {
return *req.OpenAIAdvancedSchedulerStickyWeightedEnabled
}
return previousSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled
}(),
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: func() bool {
if req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != nil {
return *req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled
}
return previousSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled
}(),
OpenAIAdvancedSchedulerLBTopK: stringSetting(req.OpenAIAdvancedSchedulerLBTopK, previousSettings.OpenAIAdvancedSchedulerLBTopK),
OpenAIAdvancedSchedulerWeightPriority: stringSetting(req.OpenAIAdvancedSchedulerWeightPriority, previousSettings.OpenAIAdvancedSchedulerWeightPriority),
OpenAIAdvancedSchedulerWeightLoad: stringSetting(req.OpenAIAdvancedSchedulerWeightLoad, previousSettings.OpenAIAdvancedSchedulerWeightLoad),
OpenAIAdvancedSchedulerWeightQueue: stringSetting(req.OpenAIAdvancedSchedulerWeightQueue, previousSettings.OpenAIAdvancedSchedulerWeightQueue),
OpenAIAdvancedSchedulerWeightErrorRate: stringSetting(req.OpenAIAdvancedSchedulerWeightErrorRate, previousSettings.OpenAIAdvancedSchedulerWeightErrorRate),
OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT),
OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset),
OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom),
OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse),
OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky),
BalanceLowNotifyEnabled: func() bool {
if req.BalanceLowNotifyEnabled != nil {
return *req.BalanceLowNotifyEnabled
@@ -1951,6 +2017,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
@@ -2006,183 +2073,207 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
payload := dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
FrontendURL: updatedSettings.FrontendURL,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
LoginAgreementMode: updatedSettings.LoginAgreementMode,
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
SMTPHost: updatedSettings.SMTPHost,
SMTPPort: updatedSettings.SMTPPort,
SMTPUsername: updatedSettings.SMTPUsername,
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
SMTPFrom: updatedSettings.SMTPFrom,
SMTPFromName: updatedSettings.SMTPFromName,
SMTPUseTLS: updatedSettings.SMTPUseTLS,
TurnstileEnabled: updatedSettings.TurnstileEnabled,
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled,
DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled,
WeChatConnectAppID: updatedSettings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured,
WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured,
WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID,
WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured,
WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured,
WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled,
WeChatConnectMode: updatedSettings.WeChatConnectMode,
WeChatConnectScopes: updatedSettings.WeChatConnectScopes,
WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled,
OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName,
OIDCConnectClientID: updatedSettings.OIDCConnectClientID,
OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured,
OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL,
OIDCConnectScopes: updatedSettings.OIDCConnectScopes,
OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE,
OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken,
OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
SiteName: updatedSettings.SiteName,
SiteLogo: updatedSettings.SiteLogo,
SiteSubtitle: updatedSettings.SiteSubtitle,
APIBaseURL: updatedSettings.APIBaseURL,
ContactInfo: updatedSettings.ContactInfo,
DocURL: updatedSettings.DocURL,
HomeContent: updatedSettings.HomeContent,
HideCcsImportButton: updatedSettings.HideCcsImportButton,
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
TableDefaultPageSize: updatedSettings.TableDefaultPageSize,
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback,
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
FallbackModelGemini: updatedSettings.FallbackModelGemini,
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled,
OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled,
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled,
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled,
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
PaymentEnabled: updatedPaymentCfg.Enabled,
PaymentMinAmount: updatedPaymentCfg.MinAmount,
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
PaymentDailyLimit: updatedPaymentCfg.DailyLimit,
PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders,
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL,
PaymentHelpText: updatedPaymentCfg.HelpText,
PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode,
RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
FrontendURL: updatedSettings.FrontendURL,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
LoginAgreementMode: updatedSettings.LoginAgreementMode,
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
SMTPHost: updatedSettings.SMTPHost,
SMTPPort: updatedSettings.SMTPPort,
SMTPUsername: updatedSettings.SMTPUsername,
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
SMTPFrom: updatedSettings.SMTPFrom,
SMTPFromName: updatedSettings.SMTPFromName,
SMTPUseTLS: updatedSettings.SMTPUseTLS,
TurnstileEnabled: updatedSettings.TurnstileEnabled,
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled,
DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled,
WeChatConnectAppID: updatedSettings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured,
WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured,
WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID,
WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured,
WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured,
WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled,
WeChatConnectMode: updatedSettings.WeChatConnectMode,
WeChatConnectScopes: updatedSettings.WeChatConnectScopes,
WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled,
OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName,
OIDCConnectClientID: updatedSettings.OIDCConnectClientID,
OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured,
OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL,
OIDCConnectScopes: updatedSettings.OIDCConnectScopes,
OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE,
OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken,
OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
SiteName: updatedSettings.SiteName,
SiteLogo: updatedSettings.SiteLogo,
SiteSubtitle: updatedSettings.SiteSubtitle,
APIBaseURL: updatedSettings.APIBaseURL,
ContactInfo: updatedSettings.ContactInfo,
DocURL: updatedSettings.DocURL,
HomeContent: updatedSettings.HomeContent,
HideCcsImportButton: updatedSettings.HideCcsImportButton,
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
TableDefaultPageSize: updatedSettings.TableDefaultPageSize,
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback,
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
FallbackModelGemini: updatedSettings.FallbackModelGemini,
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled,
OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled,
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled,
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
OpenAIAdvancedSchedulerLBTopK: updatedSettings.OpenAIAdvancedSchedulerLBTopK,
OpenAIAdvancedSchedulerWeightPriority: updatedSettings.OpenAIAdvancedSchedulerWeightPriority,
OpenAIAdvancedSchedulerWeightLoad: updatedSettings.OpenAIAdvancedSchedulerWeightLoad,
OpenAIAdvancedSchedulerWeightQueue: updatedSettings.OpenAIAdvancedSchedulerWeightQueue,
OpenAIAdvancedSchedulerWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerWeightErrorRate,
OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT,
OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset,
OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse,
OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky,
OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK,
OpenAIAdvancedSchedulerEffectiveWeightPriority: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPriority,
OpenAIAdvancedSchedulerEffectiveWeightLoad: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightLoad,
OpenAIAdvancedSchedulerEffectiveWeightQueue: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQueue,
OpenAIAdvancedSchedulerEffectiveWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate,
OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset,
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled,
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
PaymentEnabled: updatedPaymentCfg.Enabled,
PaymentMinAmount: updatedPaymentCfg.MinAmount,
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
PaymentDailyLimit: updatedPaymentCfg.DailyLimit,
PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders,
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL,
PaymentHelpText: updatedPaymentCfg.HelpText,
PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode,
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
@@ -2229,7 +2320,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentRechargeFeeRate != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
req.PaymentRechargeFeeRate != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
@@ -2644,6 +2736,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
changed = append(changed, "rewrite_message_cache_control")
}
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
changed = append(changed, "enable_client_dateline_normalization")
}
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
@@ -2665,6 +2760,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
changed = append(changed, "openai_advanced_scheduler_enabled")
}
if before.OpenAIAdvancedSchedulerStickyWeightedEnabled != after.OpenAIAdvancedSchedulerStickyWeightedEnabled {
changed = append(changed, "openai_advanced_scheduler_sticky_weighted_enabled")
}
if before.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != after.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled {
changed = append(changed, "openai_advanced_scheduler_subscription_priority_enabled")
}
if before.OpenAIAdvancedSchedulerLBTopK != after.OpenAIAdvancedSchedulerLBTopK {
changed = append(changed, "openai_advanced_scheduler_lb_top_k")
}
if before.OpenAIAdvancedSchedulerWeightPriority != after.OpenAIAdvancedSchedulerWeightPriority {
changed = append(changed, "openai_advanced_scheduler_weight_priority")
}
if before.OpenAIAdvancedSchedulerWeightLoad != after.OpenAIAdvancedSchedulerWeightLoad {
changed = append(changed, "openai_advanced_scheduler_weight_load")
}
if before.OpenAIAdvancedSchedulerWeightQueue != after.OpenAIAdvancedSchedulerWeightQueue {
changed = append(changed, "openai_advanced_scheduler_weight_queue")
}
if before.OpenAIAdvancedSchedulerWeightErrorRate != after.OpenAIAdvancedSchedulerWeightErrorRate {
changed = append(changed, "openai_advanced_scheduler_weight_error_rate")
}
if before.OpenAIAdvancedSchedulerWeightTTFT != after.OpenAIAdvancedSchedulerWeightTTFT {
changed = append(changed, "openai_advanced_scheduler_weight_ttft")
}
if before.OpenAIAdvancedSchedulerWeightReset != after.OpenAIAdvancedSchedulerWeightReset {
changed = append(changed, "openai_advanced_scheduler_weight_reset")
}
if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom {
changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom")
}
if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse {
changed = append(changed, "openai_advanced_scheduler_weight_previous_response")
}
if before.OpenAIAdvancedSchedulerWeightSessionSticky != after.OpenAIAdvancedSchedulerWeightSessionSticky {
changed = append(changed, "openai_advanced_scheduler_weight_session_sticky")
}
// 余额、订阅到期与账号限额通知
if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled {
changed = append(changed, "balance_low_notify_enabled")
@@ -3800,7 +3931,7 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
return nil
}
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all 12 slots.
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
for _, platform := range service.AllowedQuotaPlatforms {
b := before[platform]
@@ -3817,3 +3948,10 @@ func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatfor
}
return true
}
func stringSetting(value *string, fallback string) string {
if value == nil {
return fallback
}
return *value
}
@@ -217,12 +217,13 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": true,
"payment_visible_method_alipay_source": "easypay",
"payment_visible_method_wxpay_source": "wxpay",
"payment_visible_method_alipay_enabled": true,
"payment_visible_method_wxpay_enabled": false,
"openai_advanced_scheduler_enabled": true,
"promo_code_enabled": true,
"payment_visible_method_alipay_source": "easypay",
"payment_visible_method_wxpay_source": "wxpay",
"payment_visible_method_alipay_enabled": true,
"payment_visible_method_wxpay_enabled": false,
"openai_advanced_scheduler_enabled": true,
"openai_advanced_scheduler_subscription_priority_enabled": true,
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)
@@ -240,6 +241,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled])
require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"])
require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled])
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
@@ -250,6 +252,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, true, data["payment_visible_method_alipay_enabled"])
require.Equal(t, false, data["payment_visible_method_wxpay_enabled"])
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"])
}
func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) {
@@ -249,8 +249,9 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub))
}
// Revoke handles revoking a subscription
// DELETE /api/v1/admin/subscriptions/:id
// Revoke handles revoking a subscription.
// POST /api/v1/admin/subscriptions/:id/revoke
// DELETE /api/v1/admin/subscriptions/:id is kept for backward compatibility.
func (h *SubscriptionHandler) Revoke(c *gin.Context) {
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -267,6 +268,24 @@ func (h *SubscriptionHandler) Revoke(c *gin.Context) {
response.Success(c, gin.H{"message": "Subscription revoked successfully"})
}
// Restore handles restoring a revoked subscription.
// POST /api/v1/admin/subscriptions/:id/restore
func (h *SubscriptionHandler) Restore(c *gin.Context) {
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid subscription ID")
return
}
subscription, err := h.subscriptionService.RestoreSubscription(c.Request.Context(), subscriptionID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription))
}
// ListByGroup handles listing subscriptions for a specific group
// GET /api/v1/admin/groups/:id/subscriptions
func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
@@ -3,6 +3,7 @@ package admin
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
@@ -629,8 +630,8 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
return
}
if len(req.Quotas) > 4 {
response.BadRequest(c, "quotas length must be <= 4")
if len(req.Quotas) > len(service.AllowedQuotaPlatforms) {
response.BadRequest(c, fmt.Sprintf("quotas length must be <= %d", len(service.AllowedQuotaPlatforms)))
return
}
seen := make(map[string]struct{}, len(req.Quotas))
@@ -749,7 +750,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
// 失效 cache:对全部允许的 platform 统一 invalidate。
// Trade-off:精确失效(仅 req 涉及平台 + 被软删平台)需 upsert 前额外 ListByUser,
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 只有 4 个元素,
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 数量很少,
// 全量 invalidate 的额外开销可接受,且能可靠覆盖软删除场景。
if h.billingCache != nil {
for _, p := range service.AllowedQuotaPlatforms {
@@ -98,7 +98,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
body := `{"quotas":[
{"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0},
{"platform":"openai","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
{"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null},
{"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
@@ -109,10 +112,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
// 缓存失效:请求中 2 个 platform + 软删除的 3 个 platform(gemini, antigravity, grok)= 5 次
// 缓存失效:按全部允许平台统一失效。
if len(cache.deleteCalls) != 5 {
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
}
@@ -154,7 +157,7 @@ func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) {
func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) {
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
body := `{"quotas":[
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"anthropic"}
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
@@ -383,6 +383,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
upstreamClaims,
compatEmail,
compatEmailUser,
emailVerificationRequired,
forceEmailOnSignup,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
@@ -433,6 +434,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
emailVerificationRequired bool,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
@@ -467,6 +469,17 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
if forceEmailOnSignup && compatEmailUser == nil {
completionResponse["choice_reason"] = "force_email_on_signup"
}
if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
completionResponse["step"] = "create_account_required"
completionResponse["email_binding_required"] = true
completionResponse["force_email_on_signup"] = true
if emailVerificationRequired {
completionResponse["choice_reason"] = "email_verification_required"
}
delete(completionResponse, "email")
delete(completionResponse, "resolved_email")
resolvedChoiceEmail = ""
}
var targetUserID *int64
if compatEmailUser != nil && compatEmailUser.ID > 0 {
@@ -631,6 +631,108 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
require.Equal(t, "third_party_signup", completion["choice_reason"])
}
func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
case "/userinfo":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", config.LinuxDoConnectConfig{
Enabled: true,
ClientID: "linuxdo-client",
ClientSecret: "linuxdo-secret",
AuthorizeURL: upstream.URL + "/authorize",
TokenURL: upstream.URL + "/token",
UserInfoURL: upstream.URL + "/userinfo",
Scopes: "read",
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
FrontendRedirectURL: "/auth/linuxdo/callback",
TokenAuthMethod: "client_secret_post",
UsePKCE: true,
})
t.Cleanup(func() { _ = client.Close() })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-email&state=state-email", nil)
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email"))
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email"))
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email"))
c.Request = req
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
require.NotNil(t, sessionCookie)
ctx := context.Background()
session, err := client.PendingAuthSession.Query().
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
Only(ctx)
require.NoError(t, err)
require.Equal(t, oauthIntentLogin, session.Intent)
require.Nil(t, session.TargetUserID)
require.Empty(t, session.ResolvedEmail)
require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"])
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
require.True(t, ok)
require.Equal(t, "create_account_required", completion["step"])
require.Equal(t, true, completion["email_binding_required"])
require.Equal(t, true, completion["force_email_on_signup"])
require.Equal(t, "email_verification_required", completion["choice_reason"])
require.NotContains(t, completion, "email")
require.NotContains(t, completion, "resolved_email")
createRecorder := httptest.NewRecorder()
createCtx, _ := gin.CreateTestContext(createRecorder)
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body)
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(sessionCookie)
createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")})
createCtx.Request = createReq
handler.CreatePendingOAuthAccount(createCtx)
require.Equal(t, http.StatusOK, createRecorder.Code)
responseData := decodeJSONBody(t, createRecorder)
require.NotEmpty(t, responseData["access_token"])
userEntity, err := client.User.Query().
Where(dbuser.EmailEQ("fresh@example.com")).
Only(ctx)
require.NoError(t, err)
require.Equal(t, "linuxdo", userEntity.SignupSource)
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("email-verify-123"),
).
Only(ctx)
require.NoError(t, err)
require.Equal(t, userEntity.ID, identity.UserID)
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, storedSession.ConsumedAt)
}
func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -1103,6 +1205,24 @@ func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg c
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func newLinuxDoOAuthHandlerAndClientWithEmailVerification(
t *testing.T,
invitationEnabled bool,
email string,
code string,
oauthCfg config.LinuxDoConnectConfig,
) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) {
handler.settingSvc = nil
handler.cfg = &config.Config{
JWT: config.JWTConfig{
@@ -1113,5 +1233,4 @@ func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauth
},
LinuxDo: oauthCfg,
}
return handler, client
}
@@ -50,15 +50,19 @@ func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool {
// userAvailableGroup 用户可见的分组概要(白名单字段)。
//
// 前端据此区分专属 vs 公开分组(IsExclusive)、订阅 vs 标准分组(SubscriptionType,
// 订阅视觉加深),并用 RateMultiplier 作为默认倍率;用户专属倍率前端走
// 订阅视觉加深),并展示默认倍率与高峰倍率规则;用户专属倍率前端走
// /groups/rates,和 API 密钥页面保持一致。
type userAvailableGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
}
// userSupportedModelPricing 用户可见的定价字段白名单。
@@ -213,12 +217,16 @@ func filterUserVisibleGroups(
continue
}
visible = append(visible, userAvailableGroup{
ID: g.ID,
Name: g.Name,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
IsExclusive: g.IsExclusive,
ID: g.ID,
Name: g.Name,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
})
}
return visible
@@ -101,13 +101,13 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
require.Truef(t, exists, "platform section must expose %q", key)
}
// Group DTO 暴露区分专属/公开、订阅类型、默认倍率所需的字段,
// Group DTO 暴露区分专属/公开、订阅类型、默认倍率和高峰倍率规则所需的字段,
// 前端据此渲染 GroupBadge 并与 API 密钥页保持一致的视觉。
rawGroup, err := json.Marshal(row.Platforms[0].Groups[0])
require.NoError(t, err)
var groupDecoded map[string]any
require.NoError(t, json.Unmarshal(rawGroup, &groupDecoded))
for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "is_exclusive"} {
for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "is_exclusive"} {
_, exists := groupDecoded[key]
require.Truef(t, exists, "group DTO must expose %q", key)
}
+13 -4
View File
@@ -181,6 +181,10 @@ func groupFromServiceBase(g *service.Group) Group {
AllowImageGeneration: g.AllowImageGeneration,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
ImagePrice1K: g.ImagePrice1K,
ImagePrice2K: g.ImagePrice2K,
ImagePrice4K: g.ImagePrice4K,
@@ -234,6 +238,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
SessionWindowEnd: a.SessionWindowEnd,
SessionWindowStatus: a.SessionWindowStatus,
GroupIDs: a.GroupIDs,
ParentAccountID: a.ParentAccountID,
QuotaDimension: a.QuotaDimension,
}
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
@@ -573,7 +579,7 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、account、upstream_model)。
requestType := l.EffectiveRequestType()
stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode)
requestedModel := l.RequestedModel
@@ -590,7 +596,6 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
InboundEndpoint: l.InboundEndpoint,
UpstreamEndpoint: l.UpstreamEndpoint,
GroupID: l.GroupID,
SubscriptionID: l.SubscriptionID,
InputTokens: l.InputTokens,
@@ -622,6 +627,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
UserAgent: l.UserAgent,
IPAddress: l.IPAddress,
CacheTTLOverridden: l.CacheTTLOverridden,
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
@@ -633,7 +639,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
// It excludes admin-only account/upstream internals while keeping user billing and request metadata.
func UsageLogFromService(l *service.UsageLog) *UsageLog {
if l == nil {
return nil
@@ -648,8 +654,10 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
if l == nil {
return nil
}
usageLog := usageLogFromServiceUser(l)
usageLog.UpstreamEndpoint = l.UpstreamEndpoint
return &AdminUsageLog{
UsageLog: usageLogFromServiceUser(l),
UsageLog: usageLog,
UpstreamModel: l.UpstreamModel,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
@@ -751,6 +759,7 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
MonthlyUsageUSD: sub.MonthlyUsageUSD,
CreatedAt: sub.CreatedAt,
UpdatedAt: sub.UpdatedAt,
RevokedAt: sub.DeletedAt,
User: UserFromServiceShallow(sub.User),
Group: GroupFromServiceShallow(sub.Group),
}
@@ -95,8 +95,7 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
require.Equal(t, serviceTier, *userDTO.ServiceTier)
require.NotNil(t, userDTO.InboundEndpoint)
require.Equal(t, inboundEndpoint, *userDTO.InboundEndpoint)
require.NotNil(t, userDTO.UpstreamEndpoint)
require.Equal(t, upstreamEndpoint, *userDTO.UpstreamEndpoint)
require.Nil(t, userDTO.UpstreamEndpoint)
require.NotNil(t, adminDTO.ServiceTier)
require.Equal(t, serviceTier, *adminDTO.ServiceTier)
require.NotNil(t, adminDTO.InboundEndpoint)
@@ -133,6 +132,45 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
}
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
t.Parallel()
ipAddress := "203.0.113.10"
accountRateMultiplier := 1.5
accountStatsCost := 0.21
log := &service.UsageLog{
RequestID: "req_user_visible_billing",
Model: "gpt-5.4",
InputCost: 0.01,
OutputCost: 0.02,
CacheCreationCost: 0.03,
CacheReadCost: 0.04,
TotalCost: 0.10,
ActualCost: 0.08,
RateMultiplier: 0.8,
IPAddress: &ipAddress,
AccountRateMultiplier: &accountRateMultiplier,
AccountStatsCost: &accountStatsCost,
}
userDTO := UsageLogFromService(log)
require.Equal(t, 0.01, userDTO.InputCost)
require.Equal(t, 0.02, userDTO.OutputCost)
require.Equal(t, 0.03, userDTO.CacheCreationCost)
require.Equal(t, 0.04, userDTO.CacheReadCost)
require.Equal(t, 0.10, userDTO.TotalCost)
require.Equal(t, 0.08, userDTO.ActualCost)
require.Equal(t, 0.8, userDTO.RateMultiplier)
require.NotNil(t, userDTO.IPAddress)
require.Equal(t, ipAddress, *userDTO.IPAddress)
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
require.NotContains(t, string(userJSON), "account_rate_multiplier")
require.NotContains(t, string(userJSON), "account_stats_cost")
require.NotContains(t, string(userJSON), "account_cost")
}
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
t.Parallel()
+33 -5
View File
@@ -186,6 +186,7 @@ type SystemSettings struct {
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
@@ -207,7 +208,29 @@ type SystemSettings struct {
PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"`
// OpenAI account scheduling
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
// Payment configuration
PaymentEnabled bool `json:"payment_enabled"`
@@ -219,6 +242,7 @@ type SystemSettings struct {
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
@@ -320,10 +344,14 @@ type PublicSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
ServerTimezone string `json:"server_timezone"`
ServerUTCOffset string `json:"server_utc_offset"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
+28 -9
View File
@@ -97,12 +97,17 @@ type Group struct {
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
// 图片生成计费配置(仅 antigravity 平台使用)
AllowImageGeneration bool `json:"allow_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
AllowImageGeneration bool `json:"allow_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
// 高峰时段倍率配置
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
// Claude Code 客户端限制
ClaudeCodeOnly bool `json:"claude_code_only"`
@@ -253,6 +258,17 @@ type Account struct {
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
// 影子账号关系(spark 维度影子)
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
QuotaDimension string `json:"quota_dimension,omitempty"`
// 影子账号回填的母账号信息(仅影子非空,源自母账号 Credentials/Extra)
ParentEmail string `json:"parent_email,omitempty"`
ParentPlanType string `json:"parent_plan_type,omitempty"`
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
Proxy *Proxy `json:"proxy,omitempty"`
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
@@ -480,6 +496,8 @@ type UsageLog struct {
// User-Agent
UserAgent *string `json:"user_agent"`
// IPAddress is visible to the owner of the usage record.
IPAddress *string `json:"ip_address,omitempty"`
// Cache TTL Override 标记
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
@@ -515,7 +533,7 @@ type AdminUsageLog struct {
// AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式)
AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
// IPAddress 用户请求 IP(仅管理员可见)
// IPAddress 用户请求 IP
IPAddress *string `json:"ip_address,omitempty"`
// Account 最小账号信息(避免泄露敏感字段)
@@ -581,8 +599,9 @@ type UserSubscription struct {
WeeklyUsageUSD float64 `json:"weekly_usage_usd"`
MonthlyUsageUSD float64 `json:"monthly_usage_usd"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
User *User `json:"user,omitempty"`
Group *Group `json:"group,omitempty"`
+7 -1
View File
@@ -21,6 +21,8 @@ const (
EndpointResponses = "/v1/responses"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideos = "/v1/videos"
EndpointGeminiModels = "/v1beta/models"
)
@@ -53,6 +55,10 @@ func NormalizeInboundEndpoint(path string) string {
return EndpointImagesGenerations
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
return EndpointImagesEdits
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
return EndpointVideosGenerations
case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
return EndpointVideos
case strings.Contains(path, EndpointResponses):
return EndpointResponses
case strings.Contains(path, EndpointGeminiModels):
@@ -78,7 +84,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
switch platform {
case service.PlatformOpenAI, service.PlatformGrok:
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits {
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
return inbound
}
// OpenAI forwards everything to the Responses API.
@@ -28,6 +28,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/v1/responses", EndpointResponses},
{"/v1/images/generations", EndpointImagesGenerations},
{"/v1/images/edits", EndpointImagesEdits},
{"/v1/videos/generations", EndpointVideosGenerations},
{"/v1/videos/req_123", EndpointVideos},
{"/v1beta/models", EndpointGeminiModels},
// Prefixed paths (antigravity, openai).
@@ -81,6 +83,8 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
{"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
{"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
+37 -1
View File
@@ -1006,7 +1006,8 @@ func (h *GatewayHandler) Models(c *gin.Context) {
// Get available models from account configurations for the selected group platform.
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(platform), apiKey.Group.ModelsListConfig.Models)
fallbackModels := defaultModelIDsForPlatform(platform)
availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, platform, availableModels)
return
}
@@ -1090,6 +1091,13 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
})
}
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
return mergeModelIDs(availableModels, fallbackModels)
}
return availableModels
}
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
if len(selectedModels) == 0 {
return availableModels
@@ -1158,6 +1166,15 @@ func defaultModelIDsForPlatform(platform string) []string {
ids = append(ids, model.ID)
}
return ids
case service.PlatformAnthropic:
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
for _, model := range claude.DefaultModels {
ids = append(ids, model.ID)
}
for _, model := range antigravity.DefaultModels() {
ids = append(ids, model.ID)
}
return mergeModelIDs(ids, nil)
case service.PlatformGrok:
return xai.DefaultModelIDs()
default:
@@ -1169,6 +1186,25 @@ func defaultModelIDsForPlatform(platform string) []string {
}
}
func mergeModelIDs(primary, secondary []string) []string {
seen := make(map[string]struct{}, len(primary)+len(secondary))
merged := make([]string, 0, len(primary)+len(secondary))
for _, models := range [][]string{primary, secondary} {
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
merged = append(merged, model)
}
}
return merged
}
// AntigravityModels 返回 Antigravity 支持的全部模型
// GET /antigravity/models
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
@@ -137,6 +137,7 @@ func (f *fakeConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acc
return result, nil
}
func (f *fakeConcurrencyCache) CleanupExpiredAccountSlots(context.Context, int64) error { return nil }
func (f *fakeConcurrencyCache) CleanupExpiredAccountSlotKeys(context.Context) error { return nil }
func (f *fakeConcurrencyCache) CleanupStaleProcessSlots(context.Context, string) error { return nil }
func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*service.Account) (*GatewayHandler, func()) {
@@ -89,6 +89,10 @@ func (m *concurrencyCacheMock) CleanupExpiredAccountSlots(ctx context.Context, a
return nil
}
func (m *concurrencyCacheMock) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
return nil
}
func (m *concurrencyCacheMock) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
return nil
}
@@ -140,6 +140,10 @@ func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Cont
return nil
}
func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
return nil
}
func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
return nil
}
@@ -269,6 +269,149 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
require.Equal(t, []string{"claude-sonnet-4-6"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeepSeek(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(28)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformAnthropic,
Type: service.AccountTypeOAuth,
},
{
ID: 2,
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{
"model_mapping": map[string]any{
"deepseek-v4-pro": "deepseek-v4-pro",
},
},
},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
},
},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(29)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformAnthropic,
Type: service.AccountTypeOAuth,
},
{
ID: 2,
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{
"model_mapping": map[string]any{
"deepseek-v4-pro": "deepseek-v4-pro",
},
},
},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: false,
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
},
},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"deepseek-v4-pro"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappings(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(30)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformAnthropic,
Type: service.AccountTypeOAuth,
},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
},
},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
+378
View File
@@ -0,0 +1,378 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GrokImages handles xAI image generation/editing through Grok groups.
func (h *OpenAIGatewayHandler) GrokImages(c *gin.Context) {
endpoint := service.GrokMediaEndpointImagesGenerations
if strings.Contains(c.Request.URL.Path, "/images/edits") {
endpoint = service.GrokMediaEndpointImagesEdits
}
h.handleGrokMedia(c, endpoint, "")
}
// GrokVideoGeneration handles xAI video generation through Grok groups.
func (h *OpenAIGatewayHandler) GrokVideoGeneration(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideosGenerations, "")
}
// GrokVideoStatus handles xAI video status retrieval through Grok groups.
func (h *OpenAIGatewayHandler) GrokVideoStatus(c *gin.Context) {
h.handleGrokMedia(c, service.GrokMediaEndpointVideoStatus, c.Param("request_id"))
}
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
requestStart := time.Now()
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
reqLog := requestLogger(
c,
"handler.openai_gateway.grok_media",
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("endpoint", string(endpoint)),
)
if !h.ensureResponsesDependencies(c, reqLog) {
return
}
var body []byte
var err error
if endpoint.RequiresRequestBody() {
body, err = pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
}
contentType := c.GetHeader("Content-Type")
requestInfo := service.ParseGrokMediaRequest(contentType, body)
requestModel := requestInfo.Model
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if endpoint == service.GrokMediaEndpointVideoStatus && strings.TrimSpace(requestID) == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "request_id is required")
return
}
reqLog = reqLog.With(zap.String("model", requestModel))
setOpsRequestContext(c, requestModel, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
if endpoint.IsGenerationRequest() {
if !service.GroupAllowsImageGeneration(apiKey.Group) {
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
return
}
if moderationBody := requestInfo.ModerationBody(); len(moderationBody) > 0 {
decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody)
if decision != nil && decision.Blocked {
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
return
}
}
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
if !acquired {
return
}
if imageReleaseFunc != nil {
defer imageReleaseFunc()
}
}
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
if !acquired {
return
}
if userReleaseFunc != nil {
defer userReleaseFunc()
}
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("grok_media.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
sessionSeed := body
if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" {
sessionSeed = []byte(requestID)
}
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed)
if endpoint == service.GrokMediaEndpointVideoStatus {
sessionHash = service.GrokMediaVideoRequestSessionHash(requestID)
}
requestCtx := c.Request.Context()
failedAccountIDs := make(map[int64]struct{})
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
switchCount := 0
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
maxAccountSwitches = 3
}
routingStart := time.Now()
for {
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
requestModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
"",
false,
false,
service.PlatformGrok,
)
if err != nil {
reqLog.Warn("grok_media.account_select_failed",
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if lastFailoverErr != nil {
h.handleFailoverExhausted(c, lastFailoverErr, false)
} else {
h.errorResponse(c, http.StatusBadGateway, "api_error", "Upstream request failed")
}
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
reqLog.Debug("grok_media.account_schedule_decision",
zap.String("layer", scheduleDecision.Layer),
zap.Bool("sticky_session_hit", scheduleDecision.StickySessionHit),
zap.Int("candidate_count", scheduleDecision.CandidateCount),
zap.Int("top_k", scheduleDecision.TopK),
zap.Int64("latency_ms", scheduleDecision.LatencyMs),
zap.Float64("load_skew", scheduleDecision.LoadSkew),
)
account := selection.Account
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
setOpsSelectedAccount(c, account.ID, account.Platform)
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
if !accountAcquired {
return
}
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
forwardDurationMs := time.Since(forwardStart).Milliseconds()
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
responseLatencyMs := forwardDurationMs
if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs {
responseLatencyMs = forwardDurationMs - upstreamLatencyMs
}
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs)
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() != writerSizeBeforeForward {
h.handleFailoverExhausted(c, failoverErr, true)
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
sameAccountRetryCount[account.ID]++
reqLog.Warn("grok_media.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
)
select {
case <-requestCtx.Done():
return
case <-time.After(sameAccountRetryDelay):
}
continue
}
}
h.gatewayService.RecordOpenAIAccountSwitch()
failedAccountIDs[account.ID] = struct{}{}
lastFailoverErr = failoverErr
if switchCount >= maxAccountSwitches {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
switchCount++
reqLog.Warn("grok_media.upstream_failover_switching",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("switch_count", switchCount),
zap.Int("max_switches", maxAccountSwitches),
)
continue
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
reqLog.Warn("grok_media.forward_failed",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
if endpoint == service.GrokMediaEndpointVideosGenerations && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil {
reqLog.Warn("grok_media.bind_video_request_account_failed",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.ResponseID),
zap.Error(err),
)
}
}
if shouldRecordGrokMediaUsage(endpoint, requestModel) {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
}
reqLog.Debug("grok_media.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
)
return
}
}
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
}
func recordGrokMediaUsage(
c *gin.Context,
h *OpenAIGatewayHandler,
reqLog *zap.Logger,
apiKey *service.APIKey,
subject middleware2.AuthSubject,
subscription *service.UserSubscription,
account *service.Account,
result *service.OpenAIForwardResult,
requestModel string,
body []byte,
requestID string,
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
payloadForHash := body
if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" {
payloadForHash = []byte(requestID)
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
channelUsageFields := service.ChannelUsageFields{
OriginalModel: requestModel,
ChannelMappedModel: requestModel,
}
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash),
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelUsageFields,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.grok_media"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", requestModel),
zap.Int64("account_id", account.ID),
).Error("grok_media.record_usage_failed", zap.Error(err))
reqLog.Debug("grok_media.record_usage_failed", zap.Error(err))
}
})
}
@@ -0,0 +1,54 @@
package handler
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestShouldRecordGrokMediaUsage(t *testing.T) {
tests := []struct {
name string
endpoint service.GrokMediaEndpoint
model string
want bool
}{
{
name: "image generation records usage",
endpoint: service.GrokMediaEndpointImagesGenerations,
model: "grok-imagine",
want: true,
},
{
name: "image edit records usage",
endpoint: service.GrokMediaEndpointImagesEdits,
model: "grok-imagine-edit",
want: true,
},
{
name: "video generation records usage",
endpoint: service.GrokMediaEndpointVideosGenerations,
model: "grok-imagine-video-1.5",
want: true,
},
{
name: "video status skips empty model usage",
endpoint: service.GrokMediaEndpointVideoStatus,
model: "",
want: false,
},
{
name: "generation skips usage without model",
endpoint: service.GrokMediaEndpointImagesGenerations,
model: " ",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model))
})
}
}
@@ -145,6 +145,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
requestPlatform,
)
if err != nil {
@@ -298,6 +299,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -312,6 +314,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -117,6 +117,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityEmbeddings,
false,
false,
)
if err != nil {
reqLog.Warn("openai_embeddings.account_select_failed",
@@ -219,6 +220,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
@@ -232,6 +234,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
@@ -0,0 +1,146 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// CountTokens handles Anthropic-compatible POST /v1/messages/count_tokens for OpenAI groups.
// It validates billing and routes to an OpenAI token-count bridge without taking concurrency slots
// or recording usage.
func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.anthropicErrorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.anthropicErrorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
reqLog := requestLogger(
c,
"handler.openai_gateway.count_tokens",
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
)
if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch {
h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error",
"This group does not allow /v1/messages dispatch")
return
}
if !h.ensureResponsesDependencies(c, reqLog) {
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
bodyRef := service.NewRequestBodyRef(body)
parsedReq, err := service.ParseGatewayRequest(bodyRef, domain.PlatformAnthropic)
if err != nil {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
if parsedReq.Model == "" {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
reqModel := parsedReq.Model
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream))
setOpsRequestContext(c, reqModel, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
mappedBodyForMessages := newOpenAIModelMappedBodyCache(body, h.gatewayService.ReplaceModelInBody)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("openai_count_tokens.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.anthropicErrorResponse(c, status, code, message)
return
}
requestStart := time.Now()
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
currentRoutingModel := routingModel
if preferredMappedModel != "" {
currentRoutingModel = preferredMappedModel
}
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
c.Request.Context(),
apiKey.GroupID,
"",
sessionHash,
currentRoutingModel,
nil,
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
openAICompatibleRequestPlatform(apiKey),
)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
if err != nil {
reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(err))
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.anthropicErrorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.anthropicErrorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
account := selection.Account
setOpsSelectedAccount(c, account.ID, account.Platform)
if selection.Acquired && selection.ReleaseFunc != nil {
defer selection.ReleaseFunc()
}
forwardBody := mappedBodyForMessages(channelMapping.Mapped, channelMapping.MappedModel)
defaultMappedModel := preferredMappedModel
if err := h.gatewayService.ForwardCountTokensAsAnthropic(c.Request.Context(), c, account, forwardBody, defaultMappedModel); err != nil {
reqLog.Error("openai_count_tokens.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
@@ -104,6 +104,16 @@ func openAICompatibleRequestPlatform(apiKey *service.APIKey) string {
return service.PlatformOpenAI
}
func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool {
if apiKey == nil || apiKey.Group == nil {
return true
}
if apiKey.Group.Platform == service.PlatformGrok {
return true
}
return apiKey.Group.AllowMessagesDispatch
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
@@ -340,6 +350,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
requireCompact,
false,
requestPlatform,
)
if err != nil {
@@ -499,7 +510,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
}
if result != nil {
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
@@ -513,6 +525,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
@@ -529,6 +542,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -658,7 +672,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
)
// 检查分组是否允许 /v1/messages 调度
if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch {
if !allowOpenAICompatibleMessagesDispatch(apiKey) {
h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error",
"This group does not allow /v1/messages dispatch")
return
@@ -770,6 +784,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
requestPlatform,
)
if err != nil {
@@ -926,6 +941,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -941,6 +957,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1251,6 +1268,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "previous_response_id must be a response.id (resp_*), not a message id")
return
}
firstMessageToolCoverage := service.AnalyzeToolCallOutputContextCoverageBytes(firstMessage)
previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs
reqLog = reqLog.With(
zap.Bool("ws_ingress", true),
zap.String("model", reqModel),
@@ -1334,6 +1353,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
subscription, _ := middleware2.GetSubscriptionFromContext(c)
requestPlatform := openAICompatibleRequestPlatform(apiKey)
requiredTransport := service.OpenAIUpstreamTransportResponsesWebsocketV2Ingress
if requestPlatform == service.PlatformGrok {
requiredTransport = service.OpenAIUpstreamTransportHTTPSSE
}
if err := h.billingCacheService.CheckBillingEligibility(ctx, apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("openai.websocket_billing_eligibility_check_failed", zap.Error(err))
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
@@ -1359,9 +1382,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
sessionHash,
reqModel,
failedAccountIDs,
service.OpenAIUpstreamTransportResponsesWebsocketV2,
requiredTransport,
service.OpenAIEndpointCapabilityChatCompletions,
false,
previousResponseCanMove,
requestPlatform,
)
if err != nil {
@@ -1517,12 +1541,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if result == nil {
return
}
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
@@ -1537,6 +1563,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMappingWS.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1559,8 +1586,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。
// 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的
// 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。
if previousResponseID != "" && !scheduleDecision.StickyPreviousHit &&
!service.ValidateFunctionCallOutputContextBytes(wsFirstMessage).HasFunctionCallOutput {
if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && previousResponseCanMove {
wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage)
reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group",
zap.Int64("account_id", account.ID),
@@ -434,6 +434,16 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4"))
})
t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) {
apiKey := &service.APIKey{
Group: &service.Group{
Platform: service.PlatformGrok,
},
}
require.Equal(t, "grok-4.3", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5"))
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok"))
})
t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) {
apiKey := &service.APIKey{
Group: &service.Group{
@@ -445,6 +455,60 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
})
}
func TestOpenAIGatewayMessagesDispatchGateAllowsGrokGroups(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("openai_group_without_dispatch_flag_is_rejected", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}`))
groupID := int64(4101)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 5101,
GroupID: &groupID,
User: &service.User{ID: 6101},
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
AllowMessagesDispatch: false,
},
})
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 6101, Concurrency: 1})
h := &OpenAIGatewayHandler{}
h.Messages(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.Equal(t, "permission_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
require.Contains(t, rec.Body.String(), "This group does not allow /v1/messages dispatch")
})
t.Run("grok_group_without_dispatch_flag_reaches_gateway_dependencies", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`))
groupID := int64(4102)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 5102,
GroupID: &groupID,
User: &service.User{ID: 6102},
Group: &service.Group{
ID: groupID,
Platform: service.PlatformGrok,
AllowMessagesDispatch: false,
},
})
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 6102, Concurrency: 1})
h := &OpenAIGatewayHandler{}
h.Messages(c)
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
require.Equal(t, "api_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
require.NotContains(t, rec.Body.String(), "This group does not allow /v1/messages dispatch")
})
}
func TestOpenAIModelMappedBody(t *testing.T) {
body := []byte(`{"model":"alias","input":"hello"}`)
calls := 0
+4 -1
View File
@@ -325,7 +325,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
}
}
if result != nil {
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
@@ -341,6 +342,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
upstreamModel := ""
if result != nil {
@@ -359,6 +361,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: channelMapping.ToUsageFields(requestModel, upstreamModel),
}); err != nil {
logger.L().With(
@@ -0,0 +1,65 @@
package handler
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestOpenAIRecordUsageInputsCarryQuotaPlatform(t *testing.T) {
files := []string{
"openai_gateway_handler.go",
"openai_chat_completions.go",
"openai_embeddings.go",
"openai_images.go",
}
for _, name := range files {
t.Run(name, func(t *testing.T) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0)
require.NoError(t, err)
var missing []token.Position
ast.Inspect(file, func(node ast.Node) bool {
literal, ok := node.(*ast.CompositeLit)
if !ok || !isOpenAIRecordUsageInputLiteral(literal.Type) {
return true
}
if !compositeLiteralHasKey(literal, "QuotaPlatform") {
missing = append(missing, fset.Position(literal.Lbrace))
}
return true
})
require.Empty(t, missing, "OpenAI usage post-billing must receive request-time QuotaPlatform")
})
}
}
func isOpenAIRecordUsageInputLiteral(expr ast.Expr) bool {
selector, ok := expr.(*ast.SelectorExpr)
if !ok {
return false
}
pkg, ok := selector.X.(*ast.Ident)
return ok && pkg.Name == "service" && selector.Sel.Name == "OpenAIRecordUsageInput"
}
func compositeLiteralHasKey(literal *ast.CompositeLit, key string) bool {
for _, elt := range literal.Elts {
pair, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
ident, ok := pair.Key.(*ast.Ident)
if ok && ident.Name == key {
return true
}
}
return false
}
+96 -36
View File
@@ -54,25 +54,35 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) {
}
// Enrich plans with group platform for frontend color coding
type planWithPlatform struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features string `json:"features"`
ProductName string `json:"product_name"`
ForSale bool `json:"for_sale"`
SortOrder int `json:"sort_order"`
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
GroupName string `json:"group_name"`
RateMultiplier float64 `json:"rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features string `json:"features"`
ProductName string `json:"product_name"`
ForSale bool `json:"for_sale"`
SortOrder int `json:"sort_order"`
}
platformMap := h.configService.GetGroupPlatformMap(c.Request.Context(), plans)
groupInfo := h.configService.GetGroupInfoMap(c.Request.Context(), plans)
result := make([]planWithPlatform, 0, len(plans))
for _, p := range plans {
gi := groupInfo[p.GroupID]
result = append(result, planWithPlatform{
ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID],
ID: int64(p.ID), GroupID: p.GroupID,
GroupPlatform: gi.Platform, GroupName: gi.Name,
RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled,
PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features,
ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder,
@@ -121,7 +131,10 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
planList = append(planList, checkoutPlan{
ID: int64(p.ID), GroupID: p.GroupID,
GroupPlatform: gi.Platform, GroupName: gi.Name,
RateMultiplier: gi.RateMultiplier, DailyLimitUSD: gi.DailyLimitUSD,
RateMultiplier: gi.RateMultiplier,
PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart,
PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
DailyLimitUSD: gi.DailyLimitUSD,
WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD,
ModelScopes: gi.ModelScopes,
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
@@ -137,6 +150,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
Plans: planList,
BalanceDisabled: cfg.BalanceDisabled,
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
RechargeFeeRate: cfg.RechargeFeeRate,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
@@ -152,6 +166,7 @@ type checkoutInfoResponse struct {
Plans []checkoutPlan `json:"plans"`
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
@@ -160,23 +175,27 @@ type checkoutInfoResponse struct {
}
type checkoutPlan struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
GroupName string `json:"group_name"`
RateMultiplier float64 `json:"rate_multiplier"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
ModelScopes []string `json:"supported_model_scopes"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features []string `json:"features"`
ProductName string `json:"product_name"`
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
GroupName string `json:"group_name"`
RateMultiplier float64 `json:"rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
ModelScopes []string `json:"supported_model_scopes"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features []string `json:"features"`
ProductName string `json:"product_name"`
}
// parseFeatures splits a newline-separated features string into a string slice.
@@ -454,8 +473,9 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
response.Success(c, sanitizePaymentOrderForResponse(order))
}
// PublicOrderResult is the limited order info returned by the public verify endpoint.
// No user details are exposed — only payment status information.
// PublicOrderResult is returned after a signed resume-token lookup. The token
// proves possession of the checkout session, so the result keeps the legacy
// frontend contract needed by payment result pages.
type PublicOrderResult struct {
ID int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"`
@@ -478,6 +498,18 @@ type PublicOrderResult struct {
PlanID *int64 `json:"plan_id,omitempty"`
}
// PublicOrderVerifyResult is returned by the legacy anonymous out_trade_no
// lookup. Keep this intentionally minimal because out_trade_no is not secret.
type PublicOrderVerifyResult struct {
OutTradeNo string `json:"out_trade_no"`
Status string `json:"status"`
Paid bool `json:"paid"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
return PublicOrderResult{
ID: order.ID,
@@ -502,6 +534,34 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
}
}
func buildPublicOrderVerifyResult(order *dbent.PaymentOrder) PublicOrderVerifyResult {
return PublicOrderVerifyResult{
OutTradeNo: order.OutTradeNo,
Status: order.Status,
Paid: publicOrderStatusPaid(order.Status),
CreatedAt: order.CreatedAt,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
}
}
func publicOrderStatusPaid(status string) bool {
switch status {
case service.OrderStatusPaid,
service.OrderStatusCompleted,
service.OrderStatusRefundRequested,
service.OrderStatusRefunding,
service.OrderStatusRefundPending,
service.OrderStatusPartiallyRefunded,
service.OrderStatusRefunded,
service.OrderStatusRefundFailed:
return true
default:
return false
}
}
// VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as
// a compatibility path for older result pages and staggered deploys.
// POST /api/v1/payment/public/orders/verify
@@ -517,7 +577,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildPublicOrderResult(order))
response.Success(c, buildPublicOrderVerifyResult(order))
}
// ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token.
@@ -135,35 +135,34 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code)
var resp struct {
Code int `json:"code"`
Data struct {
ID int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
Code int `json:"code"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Equal(t, order.ID, resp.Data.ID)
require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo)
require.Equal(t, 90.64, resp.Data.PayAmount)
require.Equal(t, 0.03, resp.Data.FeeRate)
require.Equal(t, "HKD", resp.Data.Currency)
require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType)
require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType)
require.Equal(t, service.OrderStatusPending, resp.Data.Status)
require.Equal(t, 0.0, resp.Data.RefundAmount)
require.NotEmpty(t, resp.Data.CreatedAt)
require.NotEmpty(t, resp.Data.ExpiresAt)
require.Equal(t, "legacy-order-no", resp.Data["out_trade_no"])
require.Equal(t, service.OrderStatusPending, resp.Data["status"])
require.Equal(t, false, resp.Data["paid"])
require.NotEmpty(t, resp.Data["created_at"])
require.NotEmpty(t, resp.Data["expires_at"])
for _, field := range []string{
"id",
"amount",
"pay_amount",
"fee_rate",
"currency",
"payment_type",
"order_type",
"refund_amount",
"refund_reason",
"refund_requested_at",
"refund_requested_by",
"refund_request_reason",
"plan_id",
} {
require.NotContains(t, resp.Data, field)
}
require.NotZero(t, order.ID)
}
func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) {
@@ -7,6 +7,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -85,6 +86,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,
ServerTimezone: timezone.Name(),
ServerUTCOffset: timezone.UTCOffset(),
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
+260 -161
View File
@@ -17,6 +17,33 @@ import (
"github.com/gin-gonic/gin"
)
type userUsageFilters struct {
Filters usagestats.UsageLogFilters
StartTime time.Time
EndTime time.Time
}
type userModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
type userGroupStat struct {
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
Requests int64 `json:"requests"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
// UsageHandler handles usage-related requests
type UsageHandler struct {
usageService *service.UsageService
@@ -40,41 +67,45 @@ func NewUsageHandler(
}
}
// List handles listing usage records with pagination
// GET /api/v1/usage
func (h *UsageHandler) List(c *gin.Context) {
func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool) (*userUsageFilters, bool) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
return nil, false
}
page, pageSize := response.ParsePagination(c)
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
if apiKeyIDStr := strings.TrimSpace(c.Query("api_key_id")); apiKeyIDStr != "" {
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid api_key_id")
return
return nil, false
}
if h.apiKeyService == nil {
response.InternalError(c, "API key service not available")
return nil, false
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
return nil, false
}
if apiKey.UserID != subject.UserID {
response.Forbidden(c, "Not authorized to access this API key's usage records")
return
return nil, false
}
apiKeyID = id
}
// Parse additional filters
model := c.Query("model")
var groupID int64
if groupIDStr := strings.TrimSpace(c.Query("group_id")); groupIDStr != "" {
id, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group_id")
return nil, false
}
groupID = id
}
var requestType *int16
var stream *bool
@@ -82,51 +113,119 @@ func (h *UsageHandler) List(c *gin.Context) {
parsed, err := service.ParseUsageRequestType(requestTypeStr)
if err != nil {
response.BadRequest(c, err.Error())
return
return nil, false
}
value := int16(parsed)
requestType = &value
} else if streamStr := c.Query("stream"); streamStr != "" {
} else if streamStr := strings.TrimSpace(c.Query("stream")); streamStr != "" {
val, err := strconv.ParseBool(streamStr)
if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
return nil, false
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
return nil, false
}
bt := int8(val)
billingType = &bt
}
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
if startDateStr := c.Query("start_date"); startDateStr != "" {
billingMode := strings.TrimSpace(c.Query("billing_mode"))
if billingMode != "" && !service.BillingMode(billingMode).IsValid() {
response.BadRequest(c, "Invalid billing_mode")
return nil, false
}
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
var startPtr, endPtr *time.Time
startDateStr := strings.TrimSpace(c.Query("start_date"))
endDateStr := strings.TrimSpace(c.Query("end_date"))
if startDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
return nil, false
}
startTime = &t
startTime = t
startPtr = &startTime
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if endDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
return nil, false
}
// Use half-open range [start, end), move to next calendar day start (DST-safe).
t = t.AddDate(0, 0, 1)
endTime = &t
endTime = t.AddDate(0, 0, 1)
endPtr = &endTime
}
if requireRange {
if startPtr == nil {
switch c.DefaultQuery("period", "") {
case "today":
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
startPtr = &startTime
}
if endPtr == nil {
if strings.TrimSpace(c.Query("period")) != "" {
endTime = now
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
endPtr = &endTime
}
}
return &userUsageFilters{
Filters: usagestats.UsageLogFilters{
UserID: subject.UserID,
APIKeyID: apiKeyID,
GroupID: groupID,
Model: strings.TrimSpace(c.Query("model")),
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: startPtr,
EndTime: endPtr,
},
StartTime: derefTime(startPtr),
EndTime: derefTime(endPtr),
}, true
}
func derefTime(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}
// List handles listing usage records with pagination
// GET /api/v1/usage
func (h *UsageHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
parsed, ok := h.parseUserUsageFilters(c, false)
if !ok {
return
}
params := pagination.PaginationParams{
@@ -135,18 +234,8 @@ func (h *UsageHandler) List(c *gin.Context) {
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{
UserID: subject.UserID, // Always filter by current user for security
APIKeyID: apiKeyID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -233,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) {
filter.ErrorTypesAny = types
}
// 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -303,122 +395,23 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
// Stats handles getting usage statistics
// GET /api/v1/usage/stats
func (h *UsageHandler) Stats(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid api_key_id")
return
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
if err != nil {
response.NotFound(c, "API key not found")
return
}
if apiKey.UserID != subject.UserID {
response.Forbidden(c, "Not authorized to access this API key's statistics")
return
}
apiKeyID = id
}
// 获取时间范围参数
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
// 优先使用 start_date 和 end_date 参数
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
if startDateStr != "" && endDateStr != "" {
// 使用自定义日期范围
var err error
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
endTime = endTime.AddDate(0, 0, 1)
} else {
// 使用 period 参数
period := c.DefaultQuery("period", "today")
switch period {
case "today":
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
}
endTime = now
}
var stats *service.UsageStats
var err error
if apiKeyID > 0 {
stats, err = h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
} else {
stats, err = h.usageService.GetStatsByUser(c.Request.Context(), subject.UserID, startTime, endTime)
}
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats.TotalAccountCost = nil
stats.UpstreamEndpoints = nil
stats.EndpointPaths = nil
response.Success(c, stats)
}
// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
// Uses user's timezone if provided, otherwise falls back to server timezone
func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var startTime, endTime time.Time
if startDate != "" {
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
startTime = t
} else {
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
} else {
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
if endDate != "" {
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
endTime = t.Add(24 * time.Hour) // Include the end date
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
return startTime, endTime
}
const (
defaultAPIKeyDailyUsageDays = 30
maxAPIKeyDailyUsageDays = 90
@@ -463,16 +456,13 @@ func (h *UsageHandler) DashboardStats(c *gin.Context) {
// DashboardTrend handles getting user usage trend data
// GET /api/v1/usage/dashboard/trend
func (h *UsageHandler) DashboardTrend(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
startTime, endTime := parseUserTimeRange(c)
granularity := c.DefaultQuery("granularity", "day")
trend, err := h.usageService.GetUserUsageTrendByUserID(c.Request.Context(), subject.UserID, startTime, endTime, granularity)
trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -480,8 +470,8 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
response.Success(c, gin.H{
"trend": trend,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
"granularity": granularity,
})
}
@@ -489,27 +479,136 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
// DashboardModels handles getting user model usage statistics
// GET /api/v1/usage/dashboard/models
func (h *UsageHandler) DashboardModels(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
startTime, endTime := parseUserTimeRange(c)
modelSource := strings.TrimSpace(c.Query("model_source"))
if modelSource != "" && modelSource != usagestats.ModelSourceRequested {
response.BadRequest(c, "Invalid model_source, user usage only supports requested")
return
}
stats, err := h.usageService.GetUserModelStats(c.Request.Context(), subject.UserID, startTime, endTime)
stats, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{
"models": stats,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
"models": userModelStatsFromUsageStats(stats),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
})
}
// DashboardSnapshotV2 returns usage-page chart data scoped to the current user.
// GET /api/v1/usage/dashboard/snapshot-v2
func (h *UsageHandler) DashboardSnapshotV2(c *gin.Context) {
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
return
}
granularity := strings.TrimSpace(c.DefaultQuery("granularity", "day"))
if granularity != "hour" {
granularity = "day"
}
includeTrend, ok := parseBoolQueryWithDefault(c, "include_trend", true)
if !ok {
return
}
includeModels, ok := parseBoolQueryWithDefault(c, "include_model_stats", true)
if !ok {
return
}
includeGroups, ok := parseBoolQueryWithDefault(c, "include_group_stats", false)
if !ok {
return
}
resp := gin.H{
"generated_at": time.Now().UTC().Format(time.RFC3339),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
"granularity": granularity,
}
if includeTrend {
trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["trend"] = trend
}
if includeModels {
models, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["models"] = userModelStatsFromUsageStats(models)
}
if includeGroups {
groups, err := h.usageService.GetGroupStatsWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["groups"] = userGroupStatsFromUsageStats(groups)
}
response.Success(c, resp)
}
func userModelStatsFromUsageStats(stats []usagestats.ModelStat) []userModelStat {
out := make([]userModelStat, 0, len(stats))
for _, stat := range stats {
out = append(out, userModelStat{
Model: stat.Model,
Requests: stat.Requests,
InputTokens: stat.InputTokens,
OutputTokens: stat.OutputTokens,
CacheCreationTokens: stat.CacheCreationTokens,
CacheReadTokens: stat.CacheReadTokens,
TotalTokens: stat.TotalTokens,
Cost: stat.Cost,
ActualCost: stat.ActualCost,
})
}
return out
}
func userGroupStatsFromUsageStats(stats []usagestats.GroupStat) []userGroupStat {
out := make([]userGroupStat, 0, len(stats))
for _, stat := range stats {
out = append(out, userGroupStat{
GroupID: stat.GroupID,
GroupName: stat.GroupName,
Requests: stat.Requests,
TotalTokens: stat.TotalTokens,
Cost: stat.Cost,
ActualCost: stat.ActualCost,
})
}
return out
}
func parseBoolQueryWithDefault(c *gin.Context, key string, fallback bool) (bool, bool) {
raw := c.Query(key)
if strings.TrimSpace(raw) == "" {
return fallback, true
}
parsed, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid "+key+" value, use true or false")
return false, false
}
return parsed, true
}
// BatchAPIKeysUsageRequest represents the request for batch API keys usage
type BatchAPIKeysUsageRequest struct {
APIKeyIDs []int64 `json:"api_key_ids" binding:"required"`
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
@@ -16,21 +17,67 @@ import (
type userUsageRepoCapture struct {
service.UsageLogRepository
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters
statsFilters usagestats.UsageLogFilters
trendFilters usagestats.UsageLogFilters
groupFilters usagestats.UsageLogFilters
listRows []service.UsageLog
stats *usagestats.UsageStats
modelStats []usagestats.ModelStat
groupStats []usagestats.GroupStat
}
func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
s.listParams = params
s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{
Total: 0,
return s.listRows, &pagination.PaginationResult{
Total: int64(len(s.listRows)),
Page: params.Page,
PageSize: params.PageSize,
Pages: 0,
}, nil
}
func (s *userUsageRepoCapture) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
s.statsFilters = filters
if s.stats != nil {
return s.stats, nil
}
return &usagestats.UsageStats{}, nil
}
func (s *userUsageRepoCapture) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error) {
s.trendFilters = usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
}
return []usagestats.TrendDataPoint{}, nil
}
func (s *userUsageRepoCapture) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
return s.modelStats, nil
}
func (s *userUsageRepoCapture) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
s.groupFilters = usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
}
return s.groupStats, nil
}
func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine {
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(repo, nil, nil, nil)
@@ -41,6 +88,9 @@ func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine {
c.Next()
})
router.GET("/usage", handler.List)
router.GET("/usage/stats", handler.Stats)
router.GET("/usage/dashboard/models", handler.DashboardModels)
router.GET("/usage/dashboard/snapshot-v2", handler.DashboardSnapshotV2)
return router
}
@@ -80,3 +130,197 @@ func TestUserUsageListInvalidStream(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageListAdvancedFilters(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?group_id=7&model=gpt-5&billing_type=1&billing_mode=image&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.listFilters.UserID)
require.Equal(t, int64(7), repo.listFilters.GroupID)
require.Equal(t, "gpt-5", repo.listFilters.Model)
require.Equal(t, usagestats.ModelSourceRequested, repo.listFilters.ModelFilterSource)
require.NotNil(t, repo.listFilters.BillingType)
require.Equal(t, int8(1), *repo.listFilters.BillingType)
require.Equal(t, "image", repo.listFilters.BillingMode)
require.NotNil(t, repo.listFilters.StartTime)
require.NotNil(t, repo.listFilters.EndTime)
}
func TestUserUsageListInvalidBillingMode(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?billing_mode=bad", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
ipAddress := "203.0.113.10"
upstreamModel := "upstream-private-model"
billingTier := "internal-tier"
channelID := int64(99)
accountRateMultiplier := 1.7
accountStatsCost := 0.12
repo := &userUsageRepoCapture{
listRows: []service.UsageLog{{
ID: 1,
UserID: 42,
APIKeyID: 7,
AccountID: 5,
RequestID: "req_user_billing",
Model: "gpt-5",
InputCost: 0.01,
OutputCost: 0.02,
CacheCreationCost: 0.03,
CacheReadCost: 0.04,
TotalCost: 0.10,
ActualCost: 0.08,
RateMultiplier: 0.8,
IPAddress: &ipAddress,
UpstreamModel: &upstreamModel,
BillingTier: &billingTier,
ChannelID: &channelID,
AccountRateMultiplier: &accountRateMultiplier,
AccountStatsCost: &accountStatsCost,
}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
require.Contains(t, body, `"input_cost":0.01`)
require.Contains(t, body, `"output_cost":0.02`)
require.Contains(t, body, `"cache_creation_cost":0.03`)
require.Contains(t, body, `"cache_read_cost":0.04`)
require.Contains(t, body, `"total_cost":0.1`)
require.Contains(t, body, `"actual_cost":0.08`)
require.Contains(t, body, `"rate_multiplier":0.8`)
require.Contains(t, body, `"ip_address":"203.0.113.10"`)
require.NotContains(t, body, "upstream_endpoint")
require.NotContains(t, body, "account_rate_multiplier")
require.NotContains(t, body, "account_stats_cost")
require.NotContains(t, body, "upstream_model")
require.NotContains(t, body, "billing_tier")
require.NotContains(t, body, "channel_id")
require.NotContains(t, body, `"account":`)
}
func TestUserUsageStatsUsesScopedFilters(t *testing.T) {
accountCost := 0.12
repo := &userUsageRepoCapture{
stats: &usagestats.UsageStats{
TotalCost: 0.10,
TotalActualCost: 0.08,
TotalAccountCost: &accountCost,
UpstreamEndpoints: []usagestats.EndpointStat{{
Endpoint: "/v1/responses",
}},
EndpointPaths: []usagestats.EndpointStat{{
Endpoint: "/v1/chat/completions -> /v1/responses",
}},
},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/stats?group_id=9&request_type=sync&billing_mode=token&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.statsFilters.UserID)
require.Equal(t, int64(9), repo.statsFilters.GroupID)
require.Equal(t, usagestats.ModelSourceRequested, repo.statsFilters.ModelFilterSource)
require.NotNil(t, repo.statsFilters.RequestType)
require.Equal(t, int16(service.RequestTypeSync), *repo.statsFilters.RequestType)
require.Equal(t, "token", repo.statsFilters.BillingMode)
require.Contains(t, rec.Body.String(), `"total_cost":0.1`)
require.Contains(t, rec.Body.String(), `"total_actual_cost":0.08`)
require.NotContains(t, rec.Body.String(), "total_account_cost")
require.NotContains(t, rec.Body.String(), "upstream_endpoints")
require.NotContains(t, rec.Body.String(), "endpoint_paths")
}
func TestUserUsageDashboardModelsOmitsAccountCost(t *testing.T) {
repo := &userUsageRepoCapture{
modelStats: []usagestats.ModelStat{{
Model: "gpt-5",
Requests: 2,
TotalTokens: 30,
Cost: 0.10,
ActualCost: 0.08,
AccountCost: 0.07,
}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/models?start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
require.Contains(t, body, `"cost":0.1`)
require.Contains(t, body, `"actual_cost":0.08`)
require.NotContains(t, body, "account_cost")
}
func TestUserUsageDashboardModelsRejectsAdminModelSources(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/models?model_source=upstream&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageSnapshotUsesScopedFilters(t *testing.T) {
repo := &userUsageRepoCapture{
modelStats: []usagestats.ModelStat{{Model: "gpt-5", AccountCost: 0.07}},
groupStats: []usagestats.GroupStat{{GroupID: 1, GroupName: "default", AccountCost: 0.06}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/snapshot-v2?include_trend=true&include_model_stats=true&include_group_stats=true&group_id=11&request_type=stream&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.trendFilters.UserID)
require.Equal(t, int64(11), repo.trendFilters.GroupID)
require.NotNil(t, repo.trendFilters.RequestType)
require.Equal(t, int16(service.RequestTypeStream), *repo.trendFilters.RequestType)
require.Equal(t, int64(42), repo.groupFilters.UserID)
require.Equal(t, int64(11), repo.groupFilters.GroupID)
require.NotContains(t, rec.Body.String(), "account_cost")
}
func TestUserUsageSnapshotRejectsInvalidIncludeFlags(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
for _, query := range []string{
"include_trend=bad",
"include_model_stats=bad",
"include_group_stats=bad",
} {
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/snapshot-v2?start_date=2026-03-01&end_date=2026-03-02&"+query, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, query)
}
}
@@ -300,6 +300,25 @@ func (a *Airwallex) Refund(ctx context.Context, req payment.RefundRequest) (*pay
return refundResp, nil
}
func (a *Airwallex) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
refundID := strings.TrimSpace(req.RefundID)
if refundID == "" {
return nil, fmt.Errorf("airwallex query refund: missing refund id")
}
token, err := a.accessToken(ctx)
if err != nil {
return nil, fmt.Errorf("airwallex auth: %w", err)
}
var resp airwallexRefund
if err := a.doJSON(ctx, http.MethodGet, "/pa/refunds/"+url.PathEscape(refundID), token, nil, &resp); err != nil {
return nil, fmt.Errorf("airwallex query refund: %w", err)
}
if strings.TrimSpace(resp.ID) == "" {
resp.ID = refundID
}
return &payment.RefundResponse{RefundID: resp.ID, Status: airwallexRefundProviderStatus(resp.Status)}, nil
}
func (a *Airwallex) CancelPayment(ctx context.Context, tradeNo string) error {
intentID := strings.TrimSpace(tradeNo)
if intentID == "" {
+54 -5
View File
@@ -39,6 +39,12 @@ type EasyPay struct {
httpClient *http.Client
}
type easyPayCustomMethod struct {
Type string `json:"type"`
UpstreamType string `json:"upstreamType"`
DisplayName string `json:"displayName"`
}
// NewEasyPay creates a new EasyPay provider.
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
@@ -95,7 +101,13 @@ func (e *EasyPay) apiBase() string {
func (e *EasyPay) Name() string { return "EasyPay" }
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
for _, method := range e.customMethods() {
if method.Type != "" {
types = append(types, method.Type)
}
}
return types
}
func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
@@ -124,13 +136,14 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe
// TradeNo is empty; it arrives via the notify callback after payment.
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
@@ -150,13 +163,14 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount, "clientip": req.ClientIP,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
@@ -204,6 +218,41 @@ func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string)
return notifyURL, returnURL
}
func (e *EasyPay) customMethods() []easyPayCustomMethod {
if e == nil {
return nil
}
raw := strings.TrimSpace(e.config["customMethods"])
if raw == "" {
return nil
}
var methods []easyPayCustomMethod
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
return nil
}
result := make([]easyPayCustomMethod, 0, len(methods))
for _, method := range methods {
method.Type = strings.TrimSpace(method.Type)
method.UpstreamType = strings.TrimSpace(method.UpstreamType)
method.DisplayName = strings.TrimSpace(method.DisplayName)
if method.Type == "" || method.UpstreamType == "" {
continue
}
result = append(result, method)
}
return result
}
func (e *EasyPay) upstreamPaymentType(paymentType string) string {
paymentType = strings.TrimSpace(paymentType)
for _, method := range e.customMethods() {
if paymentType == method.Type {
return method.UpstreamType
}
}
return paymentType
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
@@ -179,6 +179,102 @@ func TestEasyPayRefundResponseErrors(t *testing.T) {
}
}
func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"paymentMode": paymentModePopup,
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-custom-1",
Amount: "1.00",
PaymentType: "usdt_trc20",
Subject: "Custom EasyPay",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
payURL, err := url.Parse(resp.PayURL)
if err != nil {
t.Fatalf("parse pay url: %v", err)
}
if got := payURL.Query().Get("type"); got != "usdt" {
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
}
}
func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"paymentMode": paymentModePopup,
"cidAlipay": "cid-alipay",
"cidWxpay": "cid-wxpay",
"customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-custom-cid",
Amount: "1.00",
PaymentType: "ldc",
Subject: "Custom EasyPay CID",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
payURL, err := url.Parse(resp.PayURL)
if err != nil {
t.Fatalf("parse pay url: %v", err)
}
if got := payURL.Query().Get("type"); got != "alipay" {
t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL)
}
if got := payURL.Query().Get("cid"); got != "cid-alipay" {
t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL)
}
}
func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
got := strings.Join(provider.SupportedTypes(), ",")
for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} {
if !strings.Contains(got, want) {
t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want)
}
}
}
func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
t.Helper()
@@ -248,6 +248,50 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
}, nil
}
// QueryRefund retrieves a Stripe refund by refund ID when available, otherwise
// falls back to the latest refund for the PaymentIntent.
func (s *Stripe) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
s.ensureInit()
var r *stripe.Refund
var err error
if refundID := strings.TrimSpace(req.RefundID); refundID != "" {
r, err = s.sc.V1Refunds.Retrieve(ctx, refundID, nil)
if err != nil {
return nil, fmt.Errorf("stripe query refund: %w", err)
}
} else {
tradeNo := strings.TrimSpace(req.TradeNo)
if tradeNo == "" {
return nil, fmt.Errorf("stripe query refund: missing payment intent id")
}
params := &stripe.RefundListParams{PaymentIntent: stripe.String(tradeNo)}
params.Limit = stripe.Int64(1)
list := s.sc.V1Refunds.List(ctx, params)
if list.Err() != nil {
return nil, fmt.Errorf("stripe query refund: %w", list.Err())
}
refunds := list.Data()
if len(refunds) == 0 {
return nil, fmt.Errorf("stripe query refund: no refund found")
}
r = refunds[0]
}
return &payment.RefundResponse{RefundID: r.ID, Status: stripeRefundProviderStatus(r.Status)}, nil
}
func stripeRefundProviderStatus(status stripe.RefundStatus) string {
switch status {
case stripe.RefundStatusSucceeded:
return payment.ProviderStatusSuccess
case stripe.RefundStatusFailed, stripe.RefundStatusCanceled:
return payment.ProviderStatusFailed
default:
return payment.ProviderStatusPending
}
}
func stripeIntentCurrency(raw stripe.Currency, fallback string) string {
currency, err := payment.NormalizePaymentCurrency(string(raw))
if err != nil || currency == payment.DefaultPaymentCurrency && strings.TrimSpace(string(raw)) == "" {
+48 -7
View File
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -471,24 +470,66 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment
}
rs := refunddomestic.RefundsApiService{Client: c}
cur := wxpayCurrency
outRefundNo := wxpayRefundID(req.OrderID, req.Amount)
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(req.OrderID),
OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
OutRefundNo: core.String(outRefundNo),
Reason: core.String(req.Reason),
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
})
if err != nil {
return nil, fmt.Errorf("wxpay refund: %w", err)
}
rid := wxSV(res.RefundId)
if rid == "" {
rid = fmt.Sprintf("%s-refund", req.OrderID)
}
st := payment.ProviderStatusPending
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
st = payment.ProviderStatusSuccess
}
return &payment.RefundResponse{RefundID: rid, Status: st}, nil
return &payment.RefundResponse{RefundID: outRefundNo, Status: st}, nil
}
func (w *Wxpay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
c, err := w.ensureClient()
if err != nil {
return nil, err
}
outRefundNo := strings.TrimSpace(req.RefundID)
if outRefundNo == "" {
outRefundNo = wxpayRefundID(req.OrderID, req.Amount)
}
if outRefundNo == "" {
return nil, fmt.Errorf("wxpay query refund: missing refund id")
}
rs := refunddomestic.RefundsApiService{Client: c}
res, _, err := rs.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{
OutRefundNo: core.String(outRefundNo),
})
if err != nil {
return nil, fmt.Errorf("wxpay query refund: %w", err)
}
status := payment.ProviderStatusPending
if res != nil && res.Status != nil {
switch *res.Status {
case refunddomestic.STATUS_SUCCESS:
status = payment.ProviderStatusSuccess
case refunddomestic.STATUS_CLOSED, refunddomestic.STATUS_ABNORMAL:
status = payment.ProviderStatusFailed
default:
status = payment.ProviderStatusPending
}
}
return &payment.RefundResponse{RefundID: outRefundNo, Status: status}, nil
}
func wxpayRefundID(orderID, amount string) string {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return ""
}
amount = strings.NewReplacer(".", "", "-", "").Replace(strings.TrimSpace(amount))
if amount == "" {
return orderID + "-refund"
}
return orderID + "-refund-" + amount
}
func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) {
+16
View File
@@ -31,6 +31,7 @@ const (
OrderStatusFailed = "FAILED"
OrderStatusRefundRequested = "REFUND_REQUESTED"
OrderStatusRefunding = "REFUNDING"
OrderStatusRefundPending = "REFUND_PENDING"
OrderStatusPartiallyRefunded = "PARTIALLY_REFUNDED"
OrderStatusRefunded = "REFUNDED"
OrderStatusRefundFailed = "REFUND_FAILED"
@@ -181,6 +182,15 @@ type RefundRequest struct {
Reason string
}
// RefundQueryRequest contains identifiers needed to query a previously
// requested refund.
type RefundQueryRequest struct {
TradeNo string
OrderID string
RefundID string
Amount string
}
// RefundResponse is returned after a refund request.
type RefundResponse struct {
RefundID string
@@ -215,6 +225,12 @@ type Provider interface {
Refund(ctx context.Context, req RefundRequest) (*RefundResponse, error)
}
// RefundQueryProvider extends Provider with refund status querying.
type RefundQueryProvider interface {
Provider
QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error)
}
// CancelableProvider extends Provider with the ability to cancel pending payments.
type CancelableProvider interface {
Provider
@@ -0,0 +1,288 @@
// Package anthropicfp provides pure helpers for suppressing client-side
// fingerprints that would otherwise be visible to upstream Anthropic when a
// forwarding gateway sits between the client and api.anthropic.com.
//
// Currently exposes NormalizeDateline: it rewrites the "Today's date is
// YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
// erasing three bits of steganographic signal (four apostrophe code points and
// a date-separator variant) that some clients embed in that sentence when
// they detect a non-official base URL.
package anthropicfp
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// datelineRegexes matches the fingerprinted sentence with any of the four
// apostrophe code points seen in the wild and either separator. Two regexes
// are used because Go's RE2-based regexp package does not support
// backreferences: matching `-` and `/` in two passes keeps the two separators
// inside YYYY?MM?DD forced to agree, so mixed-separator strings like
// "Today's date is 2026-07/01." never match. This is what filters out
// user-authored prose like "Today is foo." or "His date is 2026-06-30." from
// being touched.
var (
datelineRegexHyphen = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})-(\d{2})-(\d{2})\.`)
datelineRegexSlash = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})/(\d{2})/(\d{2})\.`)
)
// systemReminderRegex matches a <system-reminder> block. The dateline lives in
// this block once the conversation has advanced past the first turn (system
// prompt caching hides the top-level system block for subsequent turns), so
// the messages[].content[] scan is confined to what lives inside these tags.
var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
// DatelineHit records what a single rewrite normalized, for observability.
type DatelineHit struct {
// ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
ApostropheVariant string
// DateSeparator is either "-" or "/" as seen before normalization.
DateSeparator string
}
// canonicalize returns the canonical form of a matched dateline sentence.
// The output always uses ASCII apostrophe and hyphen separators.
func canonicalize(year, month, day string) string {
return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
}
func apostropheVariant(r rune) string {
switch r {
case '’':
return "u2019"
case 'ʼ':
return "u02bc"
case 'ʹ':
return "u02b9"
default:
return "ascii"
}
}
type datelineMatch struct {
start, end int
apoRune rune
sep string
year, month, day string
}
func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
locs := re.FindAllStringSubmatchIndex(text, -1)
if len(locs) == 0 {
return nil
}
out := make([]datelineMatch, 0, len(locs))
for _, m := range locs {
var apoRune rune
for _, r := range text[m[2]:m[3]] {
apoRune = r
break
}
out = append(out, datelineMatch{
start: m[0],
end: m[1],
apoRune: apoRune,
sep: sep,
year: text[m[4]:m[5]],
month: text[m[6]:m[7]],
day: text[m[8]:m[9]],
})
}
return out
}
// NormalizeText replaces every fingerprinted dateline sentence in text with
// its canonical form. It returns the possibly-rewritten text and the list of
// hits observed. When no match is found the original string is returned
// verbatim (byte-identical), and the returned hit slice is nil.
func NormalizeText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "date is ") {
return text, nil
}
matches := collectMatches(text, datelineRegexHyphen, "-")
matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
if len(matches) == 0 {
return text, nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
var b strings.Builder
b.Grow(len(text))
prev := 0
hits := make([]DatelineHit, 0, len(matches))
changed := false
for _, m := range matches {
full := text[m.start:m.end]
canonical := canonicalize(m.year, m.month, m.day)
if canonical == full {
// Already canonical: no rewrite, no hit.
continue
}
_, _ = b.WriteString(text[prev:m.start])
_, _ = b.WriteString(canonical)
prev = m.end
changed = true
hits = append(hits, DatelineHit{
ApostropheVariant: apostropheVariant(m.apoRune),
DateSeparator: m.sep,
})
}
if !changed {
return text, nil
}
_, _ = b.WriteString(text[prev:])
return b.String(), hits
}
// normalizeSystemReminderScopedText scans only the <system-reminder> blocks
// inside text and normalizes datelines inside them. Text outside the blocks is
// preserved byte-for-byte, so user prose, tool_result content, code blocks,
// or shell commands that happen to contain an apostrophe or a slash date are
// never touched.
func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "<system-reminder>") {
return text, nil
}
locs := systemReminderRegex.FindAllStringIndex(text, -1)
if len(locs) == 0 {
return text, nil
}
var b strings.Builder
b.Grow(len(text))
prev := 0
var hits []DatelineHit
changed := false
for _, loc := range locs {
_, _ = b.WriteString(text[prev:loc[0]])
block := text[loc[0]:loc[1]]
normalized, blockHits := NormalizeText(block)
if normalized != block {
changed = true
}
_, _ = b.WriteString(normalized)
hits = append(hits, blockHits...)
prev = loc[1]
}
if !changed {
return text, nil
}
_, _ = b.WriteString(text[prev:])
return b.String(), hits
}
// NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
// every fingerprinted dateline sentence back to its canonical ASCII form.
//
// Scope (mirroring where genuine clients place the sentence):
// - `system` string, or `.text` field of each text-typed block in `system`.
// - Text bodies inside `messages[i].content` — but ONLY the substrings that
// appear inside `<system-reminder>...</system-reminder>` tags. Free user
// prose, tool_use.input, tool_result.content, and other block types are
// never scanned, guaranteeing that legitimate text like a code block, a
// shell command, or a chat message that mentions today's date is never
// accidentally rewritten.
//
// The function is a pure transform: it never modifies the input slice, and if
// no rewrite is needed it returns the original slice (identity), a nil hit
// slice, and changed=false.
func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
if len(body) == 0 {
return body, nil, false
}
out := body
var hits []DatelineHit
changed := false
sys := gjson.GetBytes(out, "system")
if sys.Exists() {
switch {
case sys.Type == gjson.String:
normalized, sysHits := NormalizeText(sys.String())
if normalized != sys.String() {
if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
out = next
changed = true
hits = append(hits, sysHits...)
}
}
case sys.IsArray():
idx := 0
sys.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "text" {
t := item.Get("text")
if t.Exists() && t.Type == gjson.String {
normalized, textHits := NormalizeText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("system.%d.text", idx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
}
}
idx++
return true
})
}
}
messages := gjson.GetBytes(out, "messages")
if messages.IsArray() {
msgIdx := -1
messages.ForEach(func(_, msg gjson.Result) bool {
msgIdx++
content := msg.Get("content")
if !content.Exists() {
return true
}
switch {
case content.Type == gjson.String:
normalized, contentHits := normalizeSystemReminderScopedText(content.String())
if normalized != content.String() {
path := fmt.Sprintf("messages.%d.content", msgIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, contentHits...)
}
}
case content.IsArray():
contentIdx := -1
content.ForEach(func(_, block gjson.Result) bool {
contentIdx++
if block.Get("type").String() != "text" {
return true
}
t := block.Get("text")
if !t.Exists() || t.Type != gjson.String {
return true
}
normalized, textHits := normalizeSystemReminderScopedText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
return true
})
}
return true
})
}
if !changed {
return body, nil, false
}
return out, hits, true
}
@@ -0,0 +1,248 @@
package anthropicfp
import (
"bytes"
"strings"
"testing"
)
func TestNormalizeText_ASCIIHyphenIsIdentity(t *testing.T) {
in := "Today's date is 2026-07-01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("canonical form should be returned identity, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("no hits expected on canonical input, got %d", len(hits))
}
}
func TestNormalizeText_SlashSeparatorASCIIApostrophe(t *testing.T) {
in := "Today's date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "ascii" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U2019Apostrophe(t *testing.T) {
in := "Today’s date is 2026-07-01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u2019" || hits[0].DateSeparator != "-" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02BCApostropheWithSlash(t *testing.T) {
in := "Todayʼs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02B9Apostrophe(t *testing.T) {
in := "Todayʹs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02b9" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_MixedSeparatorsNoMatch(t *testing.T) {
// backreference \3 forces the two separators to agree
in := "Today's date is 2026-07/01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("mixed-separator input should not be matched, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("expected no hits, got %d", len(hits))
}
}
func TestNormalizeText_NegativeLookalike(t *testing.T) {
cases := []string{
"Today is a great day.",
"His date is 2026-07-01.",
"Yesterday's date was 2026-06-30.",
"'s date is 2026-07-01.",
}
for _, c := range cases {
out, hits := NormalizeText(c)
if out != c {
t.Fatalf("input %q should not be modified, got %q", c, out)
}
if len(hits) != 0 {
t.Fatalf("input %q should produce no hits", c)
}
}
}
func TestNormalizeText_Idempotent(t *testing.T) {
in := "Today’s date is 2026/07/01."
out1, _ := NormalizeText(in)
out2, hits2 := NormalizeText(out1)
if out1 != out2 {
t.Fatalf("normalization not idempotent: %q vs %q", out1, out2)
}
if len(hits2) != 0 {
t.Fatalf("second pass should produce no hits, got %d", len(hits2))
}
}
func TestNormalizeText_MultipleOccurrences(t *testing.T) {
in := "First line.\nToday’s date is 2026/07/01.\nMore text.\nTodayʼs date is 2026-07-01.\nEnd."
out, hits := NormalizeText(in)
if strings.Count(out, "Today's date is") != 2 {
t.Fatalf("expected two canonicalized sentences, got: %q", out)
}
if strings.Contains(out, "’") || strings.Contains(out, "ʼ") || strings.Contains(out, "2026/07/01") {
t.Fatalf("fingerprint characters must be gone, got: %q", out)
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
}
func TestNormalizeDateline_SystemString(t *testing.T) {
body := []byte(`{"system":"You are helpful.\nToday’s date is 2026/07/01.\nBe brief.","messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("output should not contain slash date: %s", string(out))
}
}
func TestNormalizeDateline_SystemBlocksArray(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"You are helpful."},{"type":"text","text":"Todayʼs date is 2026/07/01."}],"messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" {
t.Fatalf("unexpected hits: %+v", hits)
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentStringInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\n# currentDate\nToday’s date is 2026/07/01.\n</system-reminder>\nHello, please help."}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentBlocksInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"},{"type":"text","text":"do X"}]}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_LeavesOutOfScopeUntouched(t *testing.T) {
// User prose outside <system-reminder> that mentions today's date must
// not be modified. tool_use.input / tool_result.content are never scanned.
body := []byte(`{"messages":[` +
`{"role":"user","content":"Today’s date is 2026/07/01. Please help."},` +
`{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"y","input":{"note":"Today’s date is 2026/07/01."}}]},` +
`{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"log: Today’s date is 2026/07/01."}]}` +
`]}`)
out, hits, changed := NormalizeDateline(body)
if changed {
t.Fatalf("expected changed=false, hits=%v out=%s", hits, string(out))
}
if !bytes.Equal(out, body) {
t.Fatalf("output should equal input byte-for-byte")
}
}
func TestNormalizeDateline_Idempotent(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
first, _, changed1 := NormalizeDateline(body)
if !changed1 {
t.Fatalf("expected first pass to change body")
}
second, _, changed2 := NormalizeDateline(first)
if changed2 {
t.Fatalf("second pass should not report changes")
}
if !bytes.Equal(first, second) {
t.Fatalf("second pass diverged: %s vs %s", string(first), string(second))
}
}
func TestNormalizeDateline_EmptyBody(t *testing.T) {
out, hits, changed := NormalizeDateline(nil)
if changed || out != nil || hits != nil {
t.Fatalf("empty body should be no-op")
}
}
func TestNormalizeDateline_NoDateline(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"hello"}],"system":"just a system prompt"}`)
out, hits, changed := NormalizeDateline(body)
if changed || len(hits) != 0 {
t.Fatalf("expected no changes; changed=%v hits=%v", changed, hits)
}
if &out[0] != &body[0] {
// Identity is a bonus but not strict; verify content equality at minimum
if !bytes.Equal(out, body) {
t.Fatalf("output must byte-match input when no changes needed")
}
}
}
func TestNormalizeDateline_MultipleSystemReminderBlocksInSameText(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>\nsome prose\n<system-reminder>\nAlso Todayʼs date is 2026/07/01.\n</system-reminder>"}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("slash separator must be gone: %s", string(out))
}
}
@@ -1,6 +1,9 @@
package antigravity
import "encoding/json"
import (
"encoding/json"
"strings"
)
// Claude 请求/响应类型定义
@@ -145,6 +148,7 @@ type modelDef struct {
ID string
DisplayName string
CreatedAt string // 仅 Claude API 格式使用
IsReasoning bool
}
// Antigravity 支持的 Claude 模型
@@ -166,15 +170,15 @@ var geminiModels = []modelDef{
{ID: "gemini-2.5-flash-image", DisplayName: "Gemini 2.5 Flash Image", CreatedAt: "2025-01-01T00:00:00Z"},
{ID: "gemini-2.5-flash-image-preview", DisplayName: "Gemini 2.5 Flash Image Preview", CreatedAt: "2025-01-01T00:00:00Z"},
{ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash Lite", CreatedAt: "2025-01-01T00:00:00Z"},
{ID: "gemini-2.5-flash-thinking", DisplayName: "Gemini 2.5 Flash Thinking", CreatedAt: "2025-01-01T00:00:00Z"},
{ID: "gemini-2.5-flash-thinking", DisplayName: "Gemini 2.5 Flash Thinking", CreatedAt: "2025-01-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-flash", DisplayName: "Gemini 3 Flash", CreatedAt: "2025-06-01T00:00:00Z"},
{ID: "gemini-3-pro-low", DisplayName: "Gemini 3 Pro Low", CreatedAt: "2025-06-01T00:00:00Z"},
{ID: "gemini-3-pro-high", DisplayName: "Gemini 3 Pro High", CreatedAt: "2025-06-01T00:00:00Z"},
{ID: "gemini-3-pro-high", DisplayName: "Gemini 3 Pro High", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.1-pro-low", DisplayName: "Gemini 3.1 Pro Low", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z"},
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
}
@@ -239,3 +243,14 @@ func FallbackGeminiModel(model string) GeminiModel {
}
return GeminiModel{Name: name, SupportedGenerationMethods: defaultGeminiMethods}
}
// IsGeminiReasoningModel 判断是否为不支持参数和强制 ToolConfig 的 Gemini 推理模型
func IsGeminiReasoningModel(modelID string) bool {
lowerID := strings.ToLower(modelID)
for _, m := range geminiModels {
if strings.Contains(lowerID, m.ID) && m.IsReasoning {
return true
}
}
return false
}
@@ -143,14 +143,19 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
// 5. 构建内部请求
innerRequest := GeminiRequest{
Contents: contents,
// 总是生成 sessionId,基于用户消息内容
SessionID: generateStableSessionID(contents),
}
// 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig
isReasoning := IsGeminiReasoningModel(targetModel)
if !isReasoning || len(tools) > 0 {
// 总是设置 toolConfig,与官方客户端一致
ToolConfig: &GeminiToolConfig{
innerRequest.ToolConfig = &GeminiToolConfig{
FunctionCallingConfig: &GeminiFunctionCallingConfig{
Mode: "VALIDATED",
},
},
// 总是生成 sessionId,基于用户消息内容
SessionID: generateStableSessionID(contents),
}
}
if systemInstruction != nil {
@@ -610,7 +615,11 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
maxLimit := maxOutputTokensLimit(req.Model)
config := &GeminiGenerationConfig{
MaxOutputTokens: defaultMaxOutputTokens, // 默认最大输出
StopSequences: DefaultStopSequences,
}
isReasoning := IsGeminiReasoningModel(req.Model)
if !isReasoning {
config.StopSequences = DefaultStopSequences
}
// 如果请求中指定了 MaxTokens,使用请求值
@@ -656,14 +665,16 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
}
// 其他参数
if req.Temperature != nil {
config.Temperature = req.Temperature
}
if req.TopP != nil {
config.TopP = req.TopP
}
if req.TopK != nil {
config.TopK = req.TopK
if !isReasoning {
if req.Temperature != nil {
config.Temperature = req.Temperature
}
if req.TopP != nil {
config.TopP = req.TopP
}
if req.TopK != nil {
config.TopK = req.TopK
}
}
return config
+6
View File
@@ -146,6 +146,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Opus 4.8",
CreatedAt: "2026-05-29T00:00:00Z",
},
{
ID: "claude-sonnet-5",
Type: "model",
DisplayName: "Claude Sonnet 5",
CreatedAt: "2026-07-01T00:00:00Z",
},
{
ID: "claude-sonnet-4-6",
Type: "model",

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