Compare commits

...
1236 Commits
Author SHA1 Message Date
Wesley Liddick 0363b8cdba Merge pull request #7893 from Wei-Shaw/fix/issue-7850-admin-bootstrap-hardening
fix(setup): stop shipping guessable admin credentials on fresh installs (fixes #7850)
2026-10-07 09:50:45 +08:00
shaw 0f9d460dc6 fix(deps): resolve frontend audit failures (vue, source-map-js, xlsx exceptions)
- Bump vue to ^3.5.43 so @vue/server-renderer is >= 3.5.42
  (GHSA-g2v6-rqmx-r4w6).
- Override source-map-js to >= 1.2.2 (GHSA-68fv-2mgg-jv7q).
- Renew the expired xlsx exceptions until 2027-01-06: xlsx has no patched
  release on npm and is only used for XLSX.write export in the admin usage
  view; both advisories require parsing a crafted file and no read path exists.

Lockfile regenerated with pnpm 9 to match CI.
2026-10-07 09:44:01 +08:00
shaw 6e315616ed fix(setup): stop shipping guessable admin credentials on fresh installs (fixes #7850)
Fresh Docker installs created the admin as admin@sub2api.local and accepted
any ADMIN_PASSWORD (e.g. "123456"), making them easy brute-force targets.

- Generate a random admin-<12 hex>@sub2api.local login email when
  ADMIN_EMAIL is empty; drop the fixed default from .env.example and the
  compose files, and have docker-deploy.sh write a random ADMIN_EMAIL.
- Validate user-supplied ADMIN_PASSWORD (8-72 bytes) and ADMIN_EMAIL on the
  auto-setup path, only when an admin is actually about to be created, so
  existing deployments are never blocked at startup.
- Align validateEmail with the login endpoint's email binding so setup can no
  longer create an admin that can never log in (e.g. "a@b", "Name <a@b.com>").
- Cap validatePassword at bcrypt's 72-byte limit; the CLI reuses it and now
  defaults to a random admin email instead of admin@example.com.
- Update READMEs/deploy docs to stop suggesting guessable credentials.
2026-10-07 09:27:36 +08:00
Wesley Liddick d97ccc952f Merge pull request #7888 from honestTai/fix/easypay-callback-forgery
fix(payment): 修复易支付回调签名复用伪造入账漏洞 (fixes #7881)
2026-10-07 08:33:53 +08:00
Wesley Liddick 33d9e72f90 Merge pull request #7851 from ZeroClover/fix/remote-model-catalog-discovery
fix: enable API-key discovery for remote Codex catalogs
2026-10-07 08:27:17 +08:00
honest.tai d1aac6b989 fix(payment): close EasyPay forged-callback sign reuse (fixes #7881)
An order-creation signature (visible to the payer in popup submit.php
URLs) could be replayed as a payment-success notification, with no
merchant key required. Two compounding defects:

- CanonicalizeReturnURL kept client-supplied query parameters, so a
  return_url ending in '&trade_status=TRADE_SUCCESS' entered the signed
  order string verbatim;
- the sign base string concatenates values unescaped, so a callback that
  decodes return_url only up to the smuggled pair re-sorts to a
  byte-identical sign base and passes verification.

Fixes:

- CanonicalizeReturnURL now drops client query parameters entirely; the
  server builds its own signed set and the client query has no
  legitimate use;
- VerifyNotification rejects any parameter outside the genuine async
  notify set (pid/trade_no/out_trade_no/type/name/money/trade_status/
  param/sign/sign_type), failing closed against the whole smuggling
  class regardless of the injection point.

Regression tests cover the PoC payload, full order-URL replay, genuine
callback acceptance, unknown-parameter rejection (including empty
values), and the return_url smuggling strip. Genuinely paid orders that
an exotic upstream variant might reject are still recovered through the
existing upstream QueryOrder reconcile path.
2026-10-06 23:14:56 +08:00
Zero Clover bbba01dae9 fix: enable API-key discovery for remote Codex catalogs 2026-10-04 05:12:40 +08:00
github-actions[bot] b8dece9000 chore: sync VERSION to 0.2.13 [skip ci] 2026-10-02 12:16:57 +00:00
Wesley Liddick 3040209f20 Merge pull request #7813 from kingsleydon/fix/typesafe-upstream-billing-probe
fix(billing): allow upstream billing probe for TypeSafe API-key accounts
2026-10-02 20:02:28 +08:00
Wesley Liddick f5aca74623 Merge pull request #7816 from Wei-Shaw/fix/usage-billing-deleted-key
fix(billing): settle usage when the API key is deleted before billing completes
2026-10-02 20:01:45 +08:00
shaw 432a6a461f chore(billing): simplify comment 2026-10-02 19:52:00 +08:00
shaw c2d5bbd93e fix(billing): settle usage when the API key is deleted before billing completes
When an API key with quota or rate limits was deleted while a request was
still in flight, the key counter update matched no rows and returned
ErrAPIKeyNotFound, rolling back the whole billing transaction including the
balance/subscription charge. Skip the key-scoped counters in that case and
keep settling the user and account side as usual.
2026-10-02 19:51:06 +08:00
Wesley Liddick ae501cc220 Merge pull request #7814 from Wei-Shaw/docs/security-policy
docs: add security policy with private vulnerability reporting
2026-10-02 19:06:07 +08:00
shaw 99efaa477f docs(security): add dev@sub2api.org as fallback reporting email 2026-10-02 19:04:36 +08:00
shaw d9d4333795 docs: add security policy with private vulnerability reporting
Closes #7812
2026-10-02 18:59:29 +08:00
KingsleyandClaude Opus 5.5 8b9788eeb4 test(billing): drop added comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:48:37 +00:00
KingsleyandClaude Opus 5.5 66a9e2e6bb fix(billing): allow upstream billing probe for TypeSafe API-key accounts
The create form shows the upstream billing auto-probe toggle for every
API-key platform and enables it by default. TypeSafe was added as an
API-key platform but not to IsUpstreamBillingProbeIdentity, so creating a
TypeSafe account with the default form state fails with
400 UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID ("account is not an API key
account").

TypeSafe accounts store credentials.api_key/base_url like every other
API-key platform, which is all the probe reads. Add TypeSafe to the probe
identity set, and add typesafe.ai to the official API domains so accounts
on the default https://api.typesafe.ai base URL record "unsupported"
without sending the key to a path that cannot exist, matching the other
official providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:45:53 +00:00
github-actions[bot] 458b92abd4 chore: sync VERSION to 0.2.12 [skip ci] 2026-10-02 06:45:18 +00:00
Wesley Liddick 5106065716 Merge pull request #7630 from william-drakemond/feat/account-priority-quick-adjust
feat(admin): inline quick-adjust stepper for account priority
2026-10-02 14:33:29 +08:00
Wesley Liddick 46f5f5e8ba Merge pull request #7773 from william-drakemond/fix/custom-error-codes-warning-text
fix(frontend): correct custom error code warning and upgrade Axios
2026-10-02 14:33:03 +08:00
Wesley Liddick 2529371bab Merge pull request #7676 from william-drakemond/fix/email-code-token-atomic
fix(email): 验证码尝试次数原子化 + 重置密码 token 哈希存储且原子单次消费
2026-10-02 14:32:42 +08:00
Wesley Liddick 43c4c882fd Merge pull request #7674 from william-drakemond/fix/antigravity-sanitize-upstream-error
fix(antigravity): 返回客户端前脱敏上游错误体,避免泄露账号池身份
2026-10-02 14:32:12 +08:00
Wesley Liddick 719013053a Merge pull request #7803 from william-drakemond/feat/api-key-group-sort
feat(keys): 支持按分组名称排序 API 密钥
2026-10-02 14:31:49 +08:00
Wesley Liddick f3dc0835df Merge pull request #7673 from william-drakemond/fix/public-order-verify-hardening
fix(payment): 为匿名订单查询接口 /payment/public/orders/verify 增加 IP 限流
2026-10-02 14:31:17 +08:00
Wesley Liddick 5637f469fa Merge pull request #7802 from feeeei/feat/recharge-promotion-tiers
feat(payment): 充值优惠阶梯,支持赠金与折扣两种模式
2026-10-02 14:30:29 +08:00
Wesley Liddick 34fe845f5a Merge pull request #7425 from aofee/codex/jev-system-one
feat: add native TypeSafe Jev System One support
2026-10-02 14:16:08 +08:00
shaw fa49647c6d test(model): 错误透传平台列表守卫纳入 typesafe 2026-10-02 14:00:12 +08:00
shaw 9eb394e3e9 fix(typesafe): 堵住校验解析差异与审计盲区,收紧模型列表口径
- System One 校验拒绝重复键与大小写/Unicode 折叠变体键(请求顶层、
  questions、问题对象):encoding/json 大小写不敏感且取最后一个重复键,
  而原始 body 原样转发,此前 {"model":"x","MODEL":"jev-latest"} 可绕过
  jev-latest 限制、模型白名单与流式限制。
- Prompt 审计与旧审核同时收集对象键名、问题 ID 与未知扩展字段(不含规范
  字段名与已校验的 type),此前把内容放进键名即可完全绕过审计。
- 上游成功响应的 usage/model 宽松解码(浮点、数字字符串),避免上游已
  计费而网关整体 502 不记账;解码失败记录 response_error ops 事件。
- 上游 413 与 400/422 同视为请求错误:透传状态码、不切换、不改账号状态。
- TypeSafe base_url 末尾的 /v1 自动剥离,避免拼出 /v1/v1/systemone;
  /v1/systemone 改挂文本请求体上限。
- Composite 静态兜底模型列表、管理端候选列表恢复为不含 jev-latest;
  Codex 清单永不列出 TypeSafe 模型;TypeSafe 分组候选默认 jev-latest。
- 创建账号从 Grok 切到 TypeSafe 时重置为白名单模式;错误透传平台列表补 typesafe。
2026-10-02 13:51:58 +08:00
shaw 2d8a386811 fix(typesafe): 补齐平台迁移、审计覆盖、端点隔离与错误策略
- 新增 241 迁移:user_platform_quotas / composite_model_routes 的 CHECK 约束
  加入 typesafe。此前设置 typesafe 默认配额会让注册时的多行配额快照整体
  违约(fail-open 后新用户所有平台配额丢失),单用户配额与 Composite 路由保存 500。
- Prompt 审计新增 typesafe_systemone 协议提取(state + 各问题 instructions/
  criteria/选项标签,键排序保证哈希稳定);此前提取为空,阻断与异步审计均被放行。
  旧内容审核同样覆盖问题文本,且不再丢弃含 <system-reminder> 的 System One 文本。
- TypeSafe 分组及路由到 TypeSafe 的 Composite 请求访问 Messages / count_tokens /
  Chat Completions / Responses 时返回 404,避免以 x-api-key 打到错误上游路径并
  污染账号状态;TypeSafe 账号 base_url 为空时不再回退 api.anthropic.com。
- SystemOne 补在途余额预留、利润控制准入终检、wrapReleaseOnDone,失败切换改用
  共享 FailoverState(同账号重试 / 池模式 / 临时封禁)。
- 上游错误沿用共享账号错误策略:402/403、自定义错误码、临时不可调度规则生效,
  记录 ops 上游错误;400/422 仍不切换且永不改变账号状态。
- 账号测试改走原生 System One 探测,可用模型只返回 jev-latest;
  响应超限显式报错,响应 Content-Type 仅透传 JSON 类型。
2026-10-02 13:08:40 +08:00
aofee bb97ab364a fix(typesafe): align System One validation with upstream schema 2026-10-02 09:44:50 +08:00
aofee 7225538358 merge: sync Jev support with latest upstream main 2026-10-02 09:37:30 +08:00
William 0cecf3c367 feat(keys): support sorting API keys by group name 2026-10-01 13:01:01 -04:00
feeeei 1aa34d478d feat(payment): 充值优惠阶梯支持折扣模式,快捷金额角标改为促销价签
- 新增全局模式开关 RECHARGE_BONUS_MODE(bonus / discount),接入支付配置、后台设置接口与 checkout-info
- 折扣模式:到账不变,实付基数按百分比打折(按币种精度取整),手续费 / 每日限额 / 渠道选实例沿用折后基数;
  百分比须 < 100,运行时 ≥ 100 按无优惠处理
- 充值页角标改为单行红色价签(赠金「+20%」/ 折扣「20% OFF」),第二行按模式显示到账或折后实付;
  摘要卡折扣模式显示优惠行,渠道限额校验改用折后实付
- 后台「充值优惠阶梯」拆为独立卡片:赠金 / 折扣切换、紧凑档位表格、区间预览、活动文案
- 补前后端单测与 zh/en 文案
2026-10-02 00:25:41 +08:00
feeeei 1b1039f4fd feat(payment): 充值赠送阶梯(按金额档位额外赠送到账额度)
- 后台支付设置新增「充值赠送阶梯」(满 X 送 Y%,RECHARGE_BONUS_TIERS)与 Markdown 活动文案
  (RECHARGE_BONUS_NOTICE):阈值按用户输入的支付金额命中(取不超过金额的最大档),
  赠送按到账基数(输入 × 充值倍率)计算;严格校验写入、宽松解析读取,订阅订单不参与
- 下单时按当时配置计算赠送并落库:payment_orders 新增 bonus_amount 列(迁移 241),
  amount 仍为到账总额,到账/兑换码/退款逻辑不变;推广返利基数改为 amount - bonus_amount
- checkout-info 下发 recharge_bonus_tiers / recharge_bonus_notice;创建订单与订单查询响应带 bonus_amount
- 充值页:快捷金额按钮右上角「+N%」角标 + 第二行到账金额,金额卡顶部渲染活动文案,
  订单摘要新增赠送额度行并在有赠送时始终显示到账余额;订单列表/详情/支付结果/成功面板显示含赠送
- 后台新增 RechargeBonusTierEditor(行内校验重复/非法阈值,自动排序并显示区间预览)
- 补后端单测、契约测试与前端 vitest,zh/en 文案
2026-10-02 00:25:41 +08:00
Wesley Liddick d6adebd22d Merge pull request #7780 from lyen1688/fix/grok-cli-identity-1-0-46
修复 Grok 426 并对齐交互式 CLI 身份头
2026-10-01 17:48:26 +08:00
lyen1688 53158587a6 升级 Axios 至 1.20.0 修复前端安全审计 2026-10-01 12:17:29 +08:00
lyen1688 ad05eda157 依据正常交互式 CLI 抓包修正 Grok 身份头 2026-10-01 12:17:27 +08:00
lyen1688 fdce5b8d01 修复 Grok CLI 版本门槛并对齐官方无界面请求头 2026-10-01 12:04:39 +08:00
William Drake 00940743d2 fix(deps): upgrade axios to 1.20.0 to pass security audit 2026-09-30 14:02:28 -04:00
William DrakeandClaude Sonnet 5.5 2e3c2cf592 fix(i18n): correct the custom error codes warning text
The warning said "Other errors will return 500", but custom error codes
only gate normal account-error handling (stop scheduling, rate-limit
marking). Request retry and account failover decide independently: on the
OpenAI-compatible gateway, unselected 403/5xx still fail over to another
account and only exhaust to 502; only some non-failover paths (for example
an ordinary 400) are rewritten to a generic 500. Reword zh/en to describe
what the setting actually does, including that an empty list applies no
filtering.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 13:54:33 -04:00
github-actions[bot] 42bc7f6cff chore: sync VERSION to 0.2.11 [skip ci] 2026-09-30 07:07:11 +00:00
Wesley Liddick 96f4c115c9 Merge pull request #7726 from william-drakemond/feat/claude-reset-redeem
feat(claude): 手动兑换 Claude 重置次数 / Manually redeem Claude limit resets
2026-09-30 14:33:23 +08:00
Wesley Liddick 327c322184 Merge pull request #7730 from lyy0709/codex/gpt61-sol-codex-plans
feat(openai): support GPT-6.1 Sol, Codex plans and Astra Ultrafast
2026-09-30 14:32:59 +08:00
Wesley Liddick 48b7109588 Merge pull request #7736 from lyy0709/codex/remote-codex-model-catalog
feat(codex): 支持 model_catalog_url 远程模型目录配置
2026-09-30 14:32:34 +08:00
Wesley Liddick 4f0c079e27 Merge pull request #7681 from william-drakemond/fix/balance-inflight-reservation
fix(billing): Redis 在途预留,防止余额模式并发透支
2026-09-30 14:31:04 +08:00
Wesley Liddick b0cfff9826 Merge pull request #7752 from Wei-Shaw/fix/api-key-create-limits
feat(api-key): 增加 API Key 创建数量与频率限制
2026-09-30 14:20:00 +08:00
shaw 017bbcb901 chore(api-key): 创建频率默认值调整为每小时 60 次 2026-09-30 13:55:37 +08:00
shaw 9ecb340822 feat(api-key): 增加 API Key 创建数量与频率限制
- 新增 api_key_create.max_active_per_user(默认 200)与
  api_key_create.max_per_user_per_hour(默认 20),0 表示不限制
- 创建频率按固定一小时窗口统计,自定义与自动生成的 Key 统一计入
- 创建计数独立于 Key 生命周期,删除或修改 Key 不再重置计数
- 移除不再使用的 DeleteCreateAttemptCount 缓存方法
2026-09-30 13:44:02 +08:00
Wesley Liddick a0f41f95a0 Merge pull request #7579 from feeeei/fix/claude-code-only-fallback-openai-compat
fix(gateway): Claude Code 限制分组的 OpenAI 兼容入口改走降级分组,不再直接 403
2026-09-30 10:47:08 +08:00
lyy0709 b5efbe3f4c feat(codex): support remote model catalogs in client config 2026-09-29 13:26:44 -07:00
lyy0709 e6d191a83f fix(openai): support Astra Ultrafast capabilities and pricing 2026-09-29 11:50:08 -07:00
lyy0709 c91bb6124a feat(codex): recognize current subscription plans 2026-09-29 11:50:08 -07:00
lyy0709 9688571a83 feat(models): support GPT-6.1 Sol 2026-09-29 11:49:26 -07:00
William DrakeandClaude Opus 5.5 514ad0aa8e fix(accounts): readable window labels in Claude reset confirm (review R1-4)
- Map five_hour/seven_day/seven_day_overage_included to 5h/7d/7d overage
  (raw key fallback) in the confirm message, credit tooltip and success message.
- Align zh/en confirm copy with the Codex dialog; {count} is the count
  remaining after this use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:24:19 -04:00
William DrakeandClaude Opus 5.5 dc2efc6b9a fix(claude): tighten reset redeem outcome handling (review R1)
- R1-1: an explicit upstream "unavailable" answer now fences the organization
  for 15 minutes (CLAUDE_RESET_UPSTREAM_UNAVAILABLE) instead of 24h; ambiguous
  outcomes keep the 24h fence. Frontend tells the operator to retry later.
- R1-2: rotate the idempotency key after definite pre-claim refusals (busy,
  not available, unresolved, upstream unavailable); map IDEMPOTENCY_IN_PROGRESS
  and IDEMPOTENCY_RETRY_BACKOFF to clear zh/en messages.
- R1-3: redeem reason and cleared windows pass only through explicit
  allowlists, so upstream values can never echo a grant ID to clients.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:21:10 -04:00
1872d44b58 feat(accounts): add confirmed Claude reset button next to reset count
Orange 「重置」 button mirrors the Codex cell: enabled only after a query
shows a redeemable credit, danger confirm, one idempotency key per
confirmation reused on retry, outcome-specific messages, then refreshes
credits and the usage row. Ported from upstream PR #7591.

Co-authored-by: korkin25 <eugeny.kuzakov@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:41:33 -04:00
196b47757a feat(claude): redeem native limit resets with server-selected grant
POST /admin/accounts/:id/claude/reset-credits/redeem (Idempotency-Key
required, same middleware chain as the Codex reset-quota route). The server
re-queries eligibility and claims only the upstream next_grant_id when it is
redeemable; grant/org IDs never reach the client. Claims are serialized by
account and organization Redis leases, and a durable per-organization fence
in the idempotency table blocks further redemption after an unconfirmed
outcome (settles after 24h). Ported from upstream PR #7591.

Co-authored-by: korkin25 <eugeny.kuzakov@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:41:33 -04:00
github-actions[bot] a60a29549f chore: sync VERSION to 0.2.10 [skip ci] 2026-09-29 08:28:45 +00:00
Wesley Liddick 2f3fed2fdb Merge pull request #7678 from PMExtra/feat/claude-code-only-client-tabs
feat: Claude Code 专用分组仅展示受支持的客户端入口
2026-09-29 15:53:58 +08:00
Wesley Liddick fe9dadfc7d Merge pull request #7679 from PMExtra/feat/risk-control-allowlist
feat: 新增可信用户风控白名单,保留审计并免除本地自动处罚
2026-09-29 15:53:18 +08:00
Wesley Liddick 41dcfec489 Merge pull request #7680 from PMExtra/fix/openai-model-catalog
fix: 移除 OpenAI 分组 Codex 配置中的模型目录依赖
2026-09-29 15:52:51 +08:00
Wesley Liddick 052e08e764 Merge pull request #7684 from william-drakemond/feat/claude-reset-credits-ui
feat(claude): 查询 Claude 重置次数(只读),UI 复用 Codex 次数按钮 / Read-only Claude reset credit count, reusing Codex count UI
2026-09-29 15:50:53 +08:00
Wesley Liddick 1f955af353 Merge pull request #7380 from wucm667/fix/issue-7377-forward-stream-usage
fix(gateway): forward received chat stream usage
2026-09-29 15:44:10 +08:00
Wesley Liddick ccb027b515 Merge pull request #7378 from wucm667/fix/issue-7368-composite-ws-routing
fix(gateway): resolve composite routes for websocket aliases
2026-09-29 15:43:52 +08:00
Wesley Liddick f50b99b66c Merge pull request #7542 from wucm667/fix/model-whitelist-mapping-conflict
fix(accounts): warn when whitelist model conflicts with mapping
2026-09-29 15:43:33 +08:00
Wesley Liddick 883a53fea0 Merge pull request #7643 from wucm667/fix/issue-7637-antigravity-compat-keepalive
fix(antigravity): keep compat streams alive before first content
2026-09-29 15:43:19 +08:00
Wesley Liddick b35a611538 Merge pull request #7646 from wucm667/feat/issue-7642-dashboard-spending-trend
feat(dashboard): toggle recent usage between tokens and spending
2026-09-29 15:43:07 +08:00
Wesley Liddick 1dca86f287 Merge pull request #7701 from wucm667/fix/issue-7699-tool-rewrite
fix(anthropic): rewrite tool names in one body pass
2026-09-29 15:42:45 +08:00
Wesley Liddick dd6cb410e6 Merge pull request #7683 from StarryKira/codex/sonnet-5-5-upstream
feat: support Claude Sonnet 5.5
2026-09-29 15:42:21 +08:00
wucm667 27a9421e6a fix(anthropic): rewrite tool names in one body pass 2026-09-29 15:00:16 +08:00
William DrakeandClaude Opus 5.5 860782e6fb fix(claude): harden reset credit projection and cell state (review R2)
- Drop selection_token (unsalted hash of a short grant ID, reversible) and
  claudeGrantSelection from the read-only DTO; assert it never serializes.
- query() returns only the projected result; no raw upstream block escapes.
- Clear a cooldown_until that is already past, using the same `now` as the
  redeemable check; the cell also shows the cooldown only when in the future.
- Invalidate in-flight queries when account id, platform or type changes.
- Sort the earliest-expiry fallback by parsed timestamp, invalid/missing last.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:59:39 -04:00
William DrakeandClaude Opus 5.5 35232c3949 fix(accounts): address review of Claude reset credit count
- Show resets held (sum of resets_left) on the count button, matching the
  Codex count; flag "not usable now" when none is redeemable yet, instead
  of showing 0 while a grant exists.
- Pick the redeemable (next) grant for the expiry chip, falling back to
  the earliest expiry, rather than assuming credits[0] is next.
- Render a single ClaudeResetCreditsCell across usage states so a query
  started while usage loads is not lost on remount.
- Drop an unrelated google/subcommands go.sum entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:03:47 -04:00
William DrakeandClaude Opus 5.5 421da3f0d4 feat(accounts): align Claude reset credit query with Codex reset count button
The Claude reset credit query rendered as a bare "Claude 重置额度" link,
which reads like a redeem action, and dumped raw ISO timestamps and
window keys. Render it like OpenAIQuotaResetCell instead: a read-only
"次数 N" button sharing one row with the local query button via a
#pre-actions slot, plus an expiry chip whose tooltip carries the grant
label, cleared windows and limit requirement.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:06:23 -04:00
korkin25 48970f61b4 fix(claude): explicitly close status HTTP response 2026-09-28 18:04:14 -04:00
korkin25 f8984b8a93 feat(accounts): show Claude reset credit status on demand 2026-09-28 18:04:13 -04:00
korkin25 6a8f921650 feat(claude): expose sanitized native reset credit status 2026-09-28 18:04:13 -04:00
haruka 2cc459e24f style: satisfy Sonnet 5.5 lint checks 2026-09-29 05:20:04 +08:00
William DrakeandClaude Opus 5.5 475c0839c6 style: gofmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:08:27 -04:00
William DrakeandClaude Opus 5.5 b886fb8edb fix(billing): read account-level mappings from scheduler snapshot only when unpriced
- only consult account mappings after primary/channel candidates fail to price (or composite)
- source mappings from the scheduler snapshot (same pool as account selection), no direct DB query and no per-model cache
- ungrouped API keys use the scheduler's ungrouped pool
- no negative caching: new mappings take effect on the next request

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:08:13 -04:00
haruka 8490a81867 feat: support Claude Sonnet 5.5 2026-09-29 04:55:07 +08:00
William DrakeandClaude Opus 5.5 652a7d74f9 fix(billing): estimate unpriced aliases via billing fallback models; don't renew expired reservations
- Inflight estimate now mirrors billableModelWithFallback: when the primary
  billing model has no price, fall back to the channel-mapped model (also for
  billing_model_source=requested) and to account-level mapped models (max over
  schedulable accounts in the group, cached 30s).
- Composite groups also consider concrete account-mapped models (max) so the
  alias' family fuzzy price cannot underestimate.
- Renew Lua refuses (and purges) members whose score <= now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:49:29 -04:00
William DrakeandClaude Opus 5.5 52c123e6f4 fix(billing): hold inflight reservation until billing lands; price estimate like billing; wire remaining endpoints
Address gaps found in adversarial verification of the in-flight balance
reservation:

- Early release: the reservation is now a ref-counted handle carried on the
  request context. Billing tasks submitted via submit*UsageRecordTask take a
  ref and return it only after RecordUsage has run; with reservation enabled
  the balance cache is deducted synchronously inside the billing task, so the
  reservation is released only after admission can see the deduction. Dropped
  tasks return their ref immediately. The reservation is renewed every ttl/3
  while the handler runs; after the handler returns it is no longer renewed,
  so the post-handler hold is bounded by one TTL.
- Estimate: use the same billing model (channel mapping + BillingModelSource,
  max over requested/mapped when ambiguous) and ModelPricingResolver (group
  ModelPricing incl. wildcards, channel per_request/image/video/token
  overrides), default/group/user rate + peak multiplier, plus image, video,
  audio and search surcharge kinds. Unpriced requests fail open with a
  throttled log; new billing.inflight_reservation.fail_closed_on_unpriced
  (default false) rejects them instead.
- Wire images, embeddings, web/x search, alpha search, grok media generation,
  grok realtime/voice, OpenAI-platform /v1/messages and ResponsesWebSocket
  (session-level reservation with renewal).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:14:43 -04:00
William DrakeandClaude Opus 5.5 43f88c99aa fix(billing): reserve in-flight balance to prevent concurrent overdraft
Balance-mode admission only checked cached balance > threshold, so N
concurrent expensive requests could all pass and overdraw the user.
Add a Redis in-flight reservation: at admission estimate a conservative
per-request cost (input estimate + output price x max_tokens, times rate
multiplier) and atomically require cached_balance - inflight >= estimate
(first in-flight request always admitted). Reservations are released via
defer on every path and expire by TTL. Fails open on estimation / Redis
errors. Configurable via billing.inflight_reservation (enabled by default).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:20:49 -04:00
PMExtra 6073704fd7 fix: omit Codex model catalog for OpenAI groups 2026-09-29 01:50:43 +08:00
PMExtra 6a69dd051a feat: add risk control user allowlist 2026-09-29 01:50:21 +08:00
PMExtra 2840cdeced feat(frontend): hide unsupported clients for Claude Code-only groups 2026-09-29 01:48:23 +08:00
William DrakeandClaude Opus 5.5 b1ce3c6d59 fix(email): atomic verify-code attempts and hashed single-use reset tokens
- Verification code attempts (register + notify email) are now reserved via
  an atomic Redis INCR (Lua) before comparing, so concurrent wrong guesses
  cannot exceed the 5-attempt cap.
- Password reset tokens are stored as SHA-256 hashes and consumed with an
  atomic Lua compare-and-delete, so a token can only be used once even under
  concurrent requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:26:28 -04:00
William DrakeandClaude Opus 5.5 d359196adc fix(antigravity): sanitize upstream error body before returning to client
The antigravity Gemini forward path returned the raw upstream Google error
body to end users, which can contain consumer project numbers, GCP project
IDs and service account emails. Return a Gemini-style error body with only
code/status and a scrubbed message; raw body stays in ops logging.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:23:13 -04:00
William DrakeandClaude Opus 5.5 cf19f30d26 fix(payment): rate-limit anonymous public order verify endpoint
POST /api/v1/payment/public/orders/verify is unauthenticated and still used
by PaymentResultView as a fallback, so keep it but cap it at 20 req/min per
client IP (fail-open on Redis errors) to make out_trade_no enumeration
impractical without affecting users mid-payment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:03:17 -04:00
wucm667 aa3a3ea787 feat(dashboard): toggle recent usage between tokens and spending 2026-09-28 14:48:42 +08:00
wucm667 bd48b2b2d2 fix(antigravity): keep compat streams alive before first content 2026-09-28 12:15:27 +08:00
github-actions[bot] 9a62841fd1 chore: sync VERSION to 0.2.9 [skip ci] 2026-09-28 03:09:17 +00:00
Wesley Liddick 4c00df2e01 Merge pull request #7638 from Achordchan/fix/anthropic-structured-output-beta
fix(anthropic): preserve requested structured outputs beta
2026-09-28 10:52:52 +08:00
Wesley Liddick b5298fdde8 Merge pull request #7538 from TouHouQing/fix/claude-thinking-disabled-openai
fix(openai): honor disabled thinking in Claude Messages bridge
2026-09-28 10:51:30 +08:00
Wesley Liddick 5a350108c4 Merge pull request #7607 from ResidualBlood/fix/antigravity-malformed-function-call-retry
fix(antigravity): trigger failover retry on empty stream caused by MALFORMED_FUNCTION_CALL
2026-09-28 10:50:53 +08:00
Wesley Liddick 1df5c39311 Merge pull request #7562 from william-drakemond/fix/opencode-zen-deepseek-reasoning-placeholder
fix(openai): inject DeepSeek reasoning placeholder for OpenCode Zen upstream
2026-09-28 10:50:02 +08:00
Wesley Liddick b3c7038cf4 Merge pull request #7526 from HuntercodeT/fix/passthrough-model-discovery-merge
fix(gateway): let OpenAI passthrough accounts supplement model discovery instead of hiding mapped models
2026-09-28 10:49:01 +08:00
Wesley Liddick 8616d4e036 Merge pull request #7617 from korkin25/fix/responses-beta-upstream
fix(openai): preserve caller beta headers for HTTP Responses
2026-09-28 10:48:48 +08:00
Wesley Liddick 93c3da03eb Merge pull request #7555 from Yukinoshita-Yukino/fix/openai-auto-reset-credit-sched-projection
fix(scheduler): keep auto reset-credit fields in the scheduler projection
2026-09-28 10:27:00 +08:00
Achord Chan 68c2f5ae6d fix(anthropic): preserve requested structured outputs beta 2026-09-28 10:10:47 +08:00
Wesley Liddick ff6b85e30b Merge pull request #7615 from UnlastingR/fix/codex-ws-context-rollover-upstream
fix(openai): break websocket chain on context rollover
2026-09-28 10:05:05 +08:00
Wesley Liddick 79c18ec836 Merge pull request #7609 from feeeei/fix/client-disconnect-499
fix(gateway): 客户端断开统一标记 499,不再被记成 502/200 上游错误
2026-09-28 10:04:07 +08:00
Wesley Liddick 790723e040 Merge pull request #7610 from nhirsama/fix/redis-command-exec-form
fix(deploy): redis 启动命令改用 exec 列表,修复密码含 shell 特殊字符时失效
2026-09-28 10:01:15 +08:00
Wesley Liddick 1ff2bd6d95 Merge pull request #7573 from Yukinoshita-Yukino/fix/channel-image-price-inherit-when-unset
fix(billing): keep catalog image prices when channel pricing leaves them unset
2026-09-28 09:57:02 +08:00
Wesley Liddick f592b336a3 Merge pull request #7595 from wucm667/feat/issue-7587-allowlist-glob
feat(groups): support wildcards anywhere in model allowlists
2026-09-28 09:55:55 +08:00
Wesley Liddick 37f35b12ea Merge pull request #7524 from Achordchan/fix/plaza-video-rate
fix(model-plaza): apply independent video billing multipliers
2026-09-28 09:55:34 +08:00
Wesley Liddick e4b34446af Merge pull request #7619 from SavitarC/fix/account-stats-long-context-billing
fix(billing): 账号统计成本按 OpenAI 账号开关计入长上下文价
2026-09-28 09:55:19 +08:00
Wesley Liddick 90ae81e9f8 Merge pull request #7624 from wucm667/fix/issue-7620-future-quota-reset
fix(openai): retain quota pause until known future reset
2026-09-28 09:54:52 +08:00
Wesley Liddick 9375e288ba Merge pull request #7571 from SPHINX998/fix/openai-probe-model-unavailable-inconclusive
fix(openai): keep Responses support unknown when the probe model is missing
2026-09-28 09:54:31 +08:00
Wesley Liddick 06f70a6232 Merge pull request #7572 from SPHINX998/fix/openai-alpha-search-require-completion
fix(openai): require a successful completion before billing an alpha search fallback
2026-09-28 09:54:12 +08:00
Wesley Liddick 6906062187 Merge pull request #7597 from far5man/codex/opus55-openrouter-alias-main
fix: recognize OpenRouter Claude Opus 5.5 alias for billing
2026-09-28 09:53:49 +08:00
Wesley Liddick dc4874a2f1 Merge pull request #7613 from yujunkim/fix/openai-free-fast-missing-pricing
fix(billing): keep zero-cost usage log when Free Fast has no pricing
2026-09-28 09:53:26 +08:00
Wesley Liddick 11608c51f0 Merge pull request #7568 from SPHINX998/fix/apicompat-gpt6-reasoning-models
fix(apicompat): treat every GPT generation from 5 on as a reasoning model
2026-09-28 09:53:05 +08:00
Wesley Liddick 8aa7fb8c2d Merge pull request #7569 from SPHINX998/fix/apicompat-recover-empty-final-text
fix(apicompat): recover streamed text when the final output message is empty
2026-09-28 09:52:48 +08:00
Wesley Liddick 00ae7c38e2 Merge pull request #7570 from SPHINX998/fix/apicompat-tool-input-on-block-start
fix(apicompat): keep tool arguments sent on content_block_start
2026-09-28 09:52:13 +08:00
Wesley Liddick 51a10e42f7 Merge pull request #7585 from ResidualBlood/fix/antigravity-pdf-inline-data
fix(antigravity): forward chat PDF file parts as Gemini inlineData
2026-09-28 09:51:28 +08:00
Wesley Liddick 6655f4ec0e Merge pull request #7393 from Achordchan/fix/antigravity-string-const
fix(antigravity): intersect string const with existing enums
2026-09-28 09:51:04 +08:00
Wesley Liddick be64b553f9 Merge pull request #7635 from wucm667/fix/issue-7629-responses-message-type
fix(apicompat): mark converted Chat role items as Responses messages
2026-09-28 09:50:34 +08:00
Wesley Liddick 66f07efd26 Merge pull request #7549 from helianthus1/fix/ccswitch-usage-v1-path
fix(ccswitch): stop usage query from requesting /v1/v1/usage
2026-09-28 09:49:47 +08:00
Wesley Liddick 89e9976950 Merge pull request #7497 from ouchihao/codex/bug-group-modal-cleanup
fix(groups): clean up modal listeners and pending searches
2026-09-28 09:42:09 +08:00
Wesley Liddick 99d86e41ac Merge pull request #7611 from ZeroClover/fix/usage-bar-idle-reset-time
fix(ui): show reset countdown for idle usage windows with known reset time
2026-09-28 09:41:52 +08:00
Wesley Liddick ec334fda2c Merge pull request #7628 from Sterbennicht/fix/codex-windows-catalog-path
fix(keys): use ~/ for the Codex model catalog path on Windows
2026-09-28 09:41:35 +08:00
Wesley Liddick 35d2ef0cbd Merge pull request #7636 from wucm667/fix/issue-7631-remove-unused-rate-limit
fix(setup): omit unused rate limit fields from generated config
2026-09-28 09:41:16 +08:00
wucm667 512aa8bc93 fix(setup): omit obsolete rate limit defaults 2026-09-28 08:50:36 +08:00
Wesley Liddick f45126c8ec Merge pull request #7622 from Anti2077/fix/ccswitch-codex-root-endpoint
fix(ccswitch): preserve Codex provider root endpoint
2026-09-28 08:48:37 +08:00
wucm667 82d2552360 fix(apicompat): mark converted role items as messages 2026-09-28 08:40:41 +08:00
William DrakeandClaude Opus 5.5 58d605083e fix(admin): keep disabled priority stepper hidden until hover
disabled:opacity-30 overrode opacity-0, so the decrement button stayed
faintly visible on every row already at the minimum. Style disabled
buttons via text colour instead, and raise idle icon contrast.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:12:39 -04:00
William DrakeandClaude Opus 5.5 0da18e4a5e feat(admin): inline quick-adjust stepper for account priority
Replace the static priority number in the accounts table with a compact
stepper: hover reveals -/+ buttons, clicking the value allows typing
(Enter to save, Esc to cancel, arrow keys to nudge). Rapid clicks are
debounced into a single priority-only PUT, failures revert the value and
surface a toast, and the row is patched in place without a full reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:49:25 -04:00
Sterbennicht f652d0ed38 fix(keys): use ~/ for the Codex model catalog path on Windows
Codex expands a leading ~/ in config.toml paths on every platform but
does not expand %userprofile%. The Windows config generated by the Use
Key dialog wrote model_catalog_json = "%userprofile%\.codex\codex-models.json",
which Codex resolves relative to the config directory
(C:\Users\<user>\.codex\%userprofile%\.codex\codex-models.json), so it
fails to load the config with "The system cannot find the path
specified (os error 3)".

Always write ~/.codex/codex-models.json into config.toml. The download
hint still shows the Windows location to save the file.
2026-09-27 22:58:44 +08:00
ResidualBlood 6b40449c3f fix(antigravity): exclude Signature-only events from meaningful data check
MALFORMED_FUNCTION_CALL responses from upstream contain only
thoughtSignature without any text, thinking, or tool calls.
The previous fix still treated delta.Signature and block.Signature
as meaningful data, so the empty-stream failover was never triggered.

- Remove Signature from isMeaningfulAntigravityCompatEvent checks
- Let hasMeaningfulData() also consult processor.HasContent() as
  a secondary guard for edge cases
2026-09-27 13:15:56 +00:00
wucm667 02088be2d1 fix(openai): retain quota pause until known future reset 2026-09-27 14:56:38 +08:00
anti 14483c9254 fix(ccswitch): preserve Codex provider root endpoint 2026-09-27 13:48:39 +08:00
SavitarC fd7af1d462 fix(billing): honor long-context gate in account stats pricing 2026-09-27 12:12:25 +08:00
korkin25 8b95dc94f8 fix(openai): preserve Responses multi-agent beta 2026-09-27 09:57:51 +08:00
UnlastingR 7626110d0b fix(openai): break websocket chain on context rollover 2026-09-26 07:45:10 -04:00
Yujun KimandClaude Opus 5.5 3c5ea297ff fix(billing): keep zero-cost usage log when Free Fast has no pricing
RecordUsage treats a missing price as zero cost on the first pass, but
the Free Fast Standard re-evaluation returned the same pricing error.
Requests in a free_openai_fast group sent at priority/fast tier to a
model without pricing therefore lost their usage log entirely.

Ignore pricing-unavailable errors on the Standard pass and keep the
zero-cost entry, matching the first pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:11:03 +09:00
Zero Clover 994048e6a4 fix(ui): show reset countdown for idle usage windows with known reset time
UsageProgressBar returned 'now' whenever showNowWhenIdle was set and
utilization was 0%, ignoring resetsAt. OpenAI Codex 7d windows at 0%
usage still carry a future reset time, so the admin account list hid it.
Only fall back to 'now' when no reset time is known.
2026-09-26 18:49:51 +08:00
ling c802c45e37 fix(deploy): 用 exec 列表形式重写 redis command
参数逐项传递,不再经 sh 拼接,避免密码含 shell 特殊字符时被展开或截断;
空密码时按 redis 语义不设密码。
2026-09-26 17:56:19 +08:00
feeeei f4f8ff04db fix(gateway): 客户端断开统一标记 499,不再被记成 502/200 上游错误
客户端在响应提交前断开时,只有排队/换号阶段和 OpenAI 的兜底写入会标 499。
Claude 非流式与 Gemini 的 Chat Completions / Messages / Responses 兼容入口
在等待上游时断开,由 GatewayHandler.ensureForwardErrorResponse 补写 502;
Gemini 原生入口什么都不写,落成 200 并被 ops 记成一条 provider 的
"Recovered upstream error";Antigravity 回写 502。这些行都按上游失败计入
SLA 与上游错误数。

- GatewayHandler.ensureForwardErrorResponse 补客户端断开检查,与 OpenAI 版一致
- GatewayHandler 的 Messages / ChatCompletions / Responses 与 Gemini 原生入口:
  首次选号失败时先判断客户端断开;ChatCompletions / Responses 循环开头改用
  failoverClientGone,不再直接 return 落成 200
- Gemini 原生入口:forward 失败时补 499 标记;并发槽错误改走
  concurrencyErrorResponse 统一映射(断开 499、排队满/超时 429、其余 503)
- Antigravity 上游响应前断开回写 499;googleapi 与 Antigravity 的 Google 错误
  格式把 499 映射为 CANCELLED
- OpenAI alpha search 非 failover 错误分支补断开检查
- Claude / Gemini / OpenAI 传输层错误:请求 context 已取消时不记 ops 上游事件
- ops:IgnoreContextCanceled 开启时跳过没有上游错误上下文的 499;带上游错误的
  499(上游失败后客户端没等到换号结果就离开)仍按上游失败落库

计费不变:Gemini 流式中途断开的用量处理不在本次范围。新增 handler/service
单测覆盖各入口的断开场景与 ops 过滤规则。
2026-09-26 17:13:10 +08:00
Achord Chan 510b680fca Merge upstream main and retain upstream scheduler test fix 2026-09-26 15:47:52 +08:00
Achord Chan afbe51abf8 Merge upstream main and retain string const enum intersection 2026-09-26 15:42:47 +08:00
ResidualBlood 52c2f4cb10 fix(antigravity): trigger failover retry on empty stream caused by MALFORMED_FUNCTION_CALL 2026-09-26 05:29:18 +00:00
far5man 630639f747 test: preserve Opus 5.5 thinking for OpenRouter alias 2026-09-25 07:01:27 +02:00
far5man 11ffee5543 fix: recognize OpenRouter Claude Opus 5.5 alias for billing 2026-09-25 06:56:40 +02:00
Yuan LiuandClaude Opus 5.5 85810365a6 fix(auto-reset): stop re-querying confirmed no-credit accounts and back off after query failures
With the credit config now visible to the scheduler, every scheduling pass
notifies the auto-reset worker for paused credit-enabled accounts.
evaluateAccount forced an upstream query whenever the reset threshold was
reached, regardless of the last result. Accounts with no credits left and
7d >= 95% were therefore polled on /wham/usage every ~15-20s across
instances (previously once a minute by the scanner), and during upstream
outages the failed state caused the same storm.

- At the reset threshold, skip the forced query while a no_credit result is
  still within the state TTL (10 min); re-query once it expires so newly
  granted credits are still picked up.
- After a query-stage failure (RESET_CREDIT_QUERY_FAILED,
  USAGE_SNAPSHOT_WRITE_FAILED, RESET_CREDIT_DETAILS_UNAVAILABLE), wait one
  minute before querying again, including snapshot-staleness triggers.
- Redemption-stage failures are unaffected and still retry immediately with
  the same idempotency key; accounts with available credits still query and
  redeem immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:17:11 +08:00
wucm667 f0ebc183e1 feat(groups): allow wildcard positions in model allowlist 2026-09-25 09:12:09 +08:00
ResidualBlood ca2746f3bb fix(antigravity): forward chat PDF file parts as Gemini inlineData
Chat Completions file parts already become Responses input_file, but the
Antigravity path dropped them when converting to Anthropic and then to
Gemini. Map data-URI input_file to a document block and emit it as
inlineData so Gemini receives the PDF.

Fixes #7583
2026-09-24 15:17:25 +00:00
feeeei b31b435091 fix(gateway): Claude Code 限制分组的 OpenAI 兼容入口改走降级分组,不再直接 403
分组开启 claude_code_only 并配置降级分组时,/v1/messages 会在选号入口的
checkClaudeCodeRestriction 中把非 Claude Code 请求解析到降级分组;但
GatewayHandler.ChatCompletions / Responses 在 handler 层只要分组开了
claude_code_only 就直接返回 403,不看降级分组,请求到不了调度层:只会发
Chat Completions / Responses 的第三方客户端拿这类分组的 Key 一律 403。

- handler 层提前拒绝只在未配置降级分组时生效,拒绝的状态码与文案不变
- 配置了降级分组时请求继续,由 SelectAccountWithLoadAwareness 解析到降级分组
  选号,再经已有的 Chat Completions / Responses ⇄ Anthropic Messages 转换转发;
  service 层无改动
- 与 /v1/messages 的降级语义一致:选号与渠道限制预检按降级分组,计费与用量
  记录仍按 API Key 所属分组

新增 handler 单测覆盖两个入口在有/无降级分组时的行为。
2026-09-24 16:20:28 +08:00
SPHINX998andClaude Opus 5.5 1d126c833b fix(openai): require a successful completion before billing an alpha search fallback
The personal-access-token /v1/alpha/search fallback turned any upstream
Responses SSE body into a 200 search result and billed one web search call.
Error, response.failed and response.incomplete events, a response.completed
with a non-success status or without its response, a truncated stream and a
bare [DONE] were all accepted, returning partial text as a successful and
billable search.

Parse the stream strictly: those shapes now return an error, so nothing is
written to the client and no WebSearchCalls result is produced. A successful
response.completed keeps the existing output and citation handling. The
existing PAT metadata test now feeds a Responses SSE stream, which is what
that upstream path returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:54:15 +08:00
SPHINX998andClaude Opus 5.5 9461196864 fix(openai): keep Responses support unknown when the probe model is missing
The API-key Responses capability probe treated every 404 as conclusive and
stored responses_supported=false. The probe model is the first mapped
upstream model in lexical order, so on third-party accounts it is often an
auxiliary entry such as codex-auto-review; a model_not_found answer then
permanently routed the account away from /v1/responses although the
endpoint works.

A 400/404 whose error code/type or message says the probe model does not
exist or is unavailable is now inconclusive, so the stored capability is not
overwritten. Plain 404/405 still mean the endpoint is missing. The probe
also prefers a general gpt-* text model (not an image model) among the
mapped models before falling back to the first one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:50:00 +08:00
Yuan LiuandClaude Opus 5.5 a2007db707 fix(billing): keep catalog image prices when channel pricing leaves them unset
When a channel pricing entry left image_output_price empty,
GetModelPricingWithChannel and the pricing resolver zeroed the image
output price and marked it explicit, so image output tokens were billed
at $0. image_input_price followed the same "unset means zero" rule and
fell back to the text input price instead of the catalog image input
price. A channel that lists an image model such as gpt-image-2 in token
mode without filling in prices therefore generated images without
charging for image output tokens, while the available-channels view
(#2475) displayed the catalog image price for the same entry.

#2930 wanted an explicit 0 on the channel to stop falling back to the
catalog; the change applied the same treatment to unset values as well.

Treat the two image fields like every other token price field:
- Set on the channel (including 0): override the catalog. An explicit
  image_output_price keeps ImageOutputPriceExplicit, so 0 stays free.
- Left empty: keep the catalog price (LiteLLM or built-in fallback). If
  the catalog has none, computeTokenBreakdown still falls back to the
  text input/output price.

The admin UI saves an empty field as null and only an entered 0 as 0,
so the two cases stay distinguishable. The flat, interval and
GetModelPricingWithChannel paths now share applyChannelImagePriceOverrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:46:14 +08:00
SPHINX998andClaude Opus 5.5 97bdde3135 fix(apicompat): keep tool arguments sent on content_block_start
The canonical Anthropic stream leaves tool_use input empty on
content_block_start and streams the arguments as input_json_delta.
Anthropic-compatible relays may instead put the complete arguments on
content_block_start and send no delta. The Anthropic-to-Responses stream
converter ignored that input, so function_call_arguments.done carried ""
and the closed item "{}", and clients executed the tool with no arguments.

Keep the inline input as a seed. A real input_json_delta supersedes it, so
canonical streams keep their exact event sequence and two JSON documents are
never concatenated. When no delta arrived by content_block_stop, emit the
seed as a single arguments delta before the done event, which still repeats
exactly what the deltas streamed. Empty, "{}" and null inputs keep the
existing "{}" fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:45:14 +08:00
SPHINX998andClaude Opus 5.5 090b2cc000 fix(apicompat): recover streamed text when the final output message is empty
BufferedResponseAccumulator.SupplementResponseOutput rebuilt the output only
when the terminal response had an empty output array. Upstreams can also end
with a non-empty output whose message carries no (or only blank) output_text.
The buffered Chat Completions and Anthropic Messages paths then returned an
empty reply although the text had streamed, while usage still billed the
terminal output_tokens.

When the terminal output has no usable message text, fill the first empty
output_text part (or append one, or append a message item) from the
accumulated deltas. Non-empty terminal text stays authoritative, and nothing
is synthesized when no text streamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:44:05 +08:00
SPHINX998andClaude Opus 5.5 82b092ffc2 fix(apicompat): treat every GPT generation from 5 on as a reasoning model
isReasoningModel matched the literal "gpt-5" prefix plus the GPT-6 Sol/Luna
spellings, so gpt-6 and gpt-6-astra (both already in the model catalog and
price list) fell into the sampling branch. Anthropic Messages and Chat
Completions requests converted to the Responses API then forwarded
temperature/top_p, which the Responses API rejects for these models with
"Unsupported parameter".

Key the check on the parsed GPT generation number instead, keeping the
existing Sol/Luna spelling match, so later families are covered without
another edit. Non-generation GPT families (gpt-image-1, gpt-audio) and
gpt-4.x are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:37:16 +08:00
William DrakeandClaude Opus 5 5979521309 fix(openai): inject DeepSeek reasoning placeholder for OpenCode Zen upstream
- OpenCode Zen / Go 把 deepseek-* 模型转发给 DeepSeek,thinking mode 的
  reasoning_content 约束原样生效,上游 400 原文一字不差地回吐
- 原判定只认 platform=deepseek 与 base_url 指向 api.deepseek.com,
  opencode_go 账号漏出,Responses→CC 回退在缓存未命中时必踩 400
- 按「官方 OpenCode 上游 + deepseek-* 模型」补一条判定,不放宽成任意上游的
  deepseek-* 模型名,避免对无此约束的上游无谓改写请求体

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:32:49 -04:00
Yuan LiuandClaude Opus 5.5 1ca7b2a519 fix(scheduler): keep auto reset-credit fields in the scheduler projection
OpenAI candidate filtering reads the Redis metadata projection, but
filterSchedulerExtra dropped auto_reset_credit_enabled, the 5h/7d
reset-credit thresholds and codex_auto_reset_credit_state. The scheduler
therefore always saw automatic reset credits as disabled, so accounts
were paused at the ops quota auto-pause threshold even when a fresh
state showed an available reset credit. The "keep scheduling until the
reset-credit threshold" branch never took effect, and accounts with
credits stayed idle until their window reset naturally.

- Keep the four keys in the scheduler projection; the test round-trips
  the real JSON payload and resolves the credit config from it.
- Once visible, every scheduling pass notifies the auto-reset worker for
  paused credit-enabled accounts, and each notification reloads the
  account. Add a 30s per-account cooldown for the scheduler hot path
  only; other event-driven notifications are unchanged and the
  per-minute scan remains the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:02:50 +08:00
helianthus1 00f6f4e4a8 fix(ccswitch): stop usage query from requesting /v1/v1/usage
#7395 made Codex (and Grok) imports store a base URL ending in /v1, but the
usage script still appends a hard-coded /v1/usage, so CC Switch requests
/v1/v1/usage, gets a 404 and shows "query failed" for every newly imported
Codex/Grok provider. Move the script into ccswitchImport.ts and strip an
existing trailing /v1 from {{baseUrl}} before appending /v1/usage, which
covers both the imported value and base URLs users edit either way later.
2026-09-23 04:24:52 -07:00
wucm667 e2c35cf394 fix(account): prevent whitelist model mapping conflicts 2026-09-23 17:47:47 +08:00
github-actions[bot] a3eb7ef302 chore: sync VERSION to 0.2.8 [skip ci] 2026-09-23 09:18:46 +00:00
touhouqing 2fc91663d2 fix(apicompat): honor disabled thinking in OpenAI bridges 2026-09-23 16:48:15 +08:00
Wesley Liddick fd80b08c90 Merge pull request #5616 from alfadb/feature/opencode-go-usage-window
feat(opencode): 支持 OpenCode Go 用量窗口(opencode_go 平台账号 + 多厂商挂载复用)
2026-09-23 15:39:28 +08:00
wucm667 453b796df0 fix(gateway): enforce composite model ownership in legacy scheduling 2026-09-23 15:29:11 +08:00
wucm667 e878ab710f fix(gateway): constrain account model route ownership 2026-09-23 15:29:11 +08:00
wucm667 13fcfc0f3b fix(gateway): resolve composite routes for websocket aliases 2026-09-23 15:29:11 +08:00
wucm667 0416c9ede4 fix(gateway): avoid ambiguous late-cache input subtraction 2026-09-23 15:27:53 +08:00
alfadb dc9216512d fix(opencode): normalize the quota endpoint for the /zen/go base variant
OpenCode Go 有两个都合法的 base_url:https://opencode.ai/zen/go/v1(OpenAI
协议档,DefaultOpenCodeGoBaseURL)与 https://opencode.ai/zen/go(Anthropic
协议档,前端 defaultCNBaseUrl 会写入)。旧拼接只做 strings.TrimRight(base,
"/") + "/usage",Anthropic 档账号的额度探测必然打到 /zen/go/usage。

实测证据(curl 无鉴权,401=端点存在,404=不存在):
  401 https://opencode.ai/zen/go/v1/usage
  404 https://opencode.ai/zen/go/usage

修法沿用同包 kimiQuotaURL(coding/v1 与 coding 两种 base)的既有惯例:
TrimSuffix(base, "/v1") 后统一拼回 /v1/usage,两个变体与尾斜杠写法都归一到
https://opencode.ai/zen/go/v1/usage;自定义 base 保持同一语义。
2026-09-23 15:26:44 +08:00
alfadb d0731be2f0 fix(opencode): lite 列表 DTO 补 opencode_go_usage,修复列表页显示余额 2026-09-23 15:26:44 +08:00
alfadb a2e1618700 fix(opencode): 纠正 CASE 求值顺序注释并补齐 mode 语义护栏 2026-09-23 15:26:44 +08:00
alfadb 31635849e0 fix(opencode): 用量单元格与 CN 子单元格互斥,避免重复渲染 2026-09-23 15:26:44 +08:00
alfadb d03274ad5f feat(opencode): 用量窗口资格支持 OpenCode 平台并扩展到其它厂商挂载 2026-09-23 15:26:44 +08:00
alfadb 0c860e81e1 fix(i18n): merge duplicate opencodeGo locale blocks after upstream rebase 2026-09-23 15:26:44 +08:00
alfadb daf6cbb5d9 feat(opencode): 用量单元格支持主动查询并将周/月徽章改为 7d/1m 2026-09-23 15:26:44 +08:00
alfadb 912e1c414c fix(opencode): remove stray conflict-marker text from usage cell imports 2026-09-23 15:26:35 +08:00
alfadb 0a43efb9a4 fix(opencode): preserve managed usage state on account updates 2026-09-23 15:26:35 +08:00
alfadb f7f0a56f35 fix(opencode): preserve auto-refresh on usage refresh 2026-09-23 15:26:35 +08:00
alfadb ae2735cdb6 feat(opencode): 用量窗口支持同 Key 组共享、活动防抖与超窗强刷
与 Ollama Cloud usage 对齐:OpenCode Go 作为同 Key 聚合订阅,
多个 openai/apikey/base_url=opencode.ai/zen/go/v1 账号共享同一份
用量状态与刷新节奏:

- 组共享:组指纹 sha256("opencode.ai\0"+api_key);auto_refresh 开关
  与 snapshot 按组写(事务 + FOR NO KEY UPDATE + 锚点 CAS + 纯合并,
  写 snapshot 不会抹掉开关);列表/详情经 ResolveAccounts 组内共享;
  账号换 Key → IdentityChanged → 组级关闭 auto_refresh 并清快照
- 活动防抖 + 超窗强刷:settings 新增 debounce_minutes(默认 1,
  1-60,必须小于 interval_minutes);模型请求活动经网关 5 处调用点
  驱动刷新,due = min(lastUsed+debounce, fetchedAt+maxWait),
  成功路径有 5 分钟最小抓取间隔,失败路径退避优先
- 手动刷新限频 10s→30s 且按组;singleflight/RunDue 按组去重;
  ListDue 在 SQL 内按组算 due(CTE 分组取组内 MAX(last_used_at))

验证:go build + 五包回归 OK;新增组共享/三态 due/组单飞/
IdentityChanged/debounce 校验单测全绿;vitest 11 例全绿;
vue-tsc 零错误。
2026-09-23 15:26:35 +08:00
alfadb 6624947b62 feat(opencode): 支持 OpenCode Go 官方用量窗口查询与自动刷新
OpenCode Go 上游(https://opencode.ai/zen/go/v1)官方提供用量端点
GET /zen/go/v1/usage(Bearer 复用消费 key),返回 rolling(5h)/weekly/monthly
三窗口的 percent 与 resetsAt。本提交为 openai apikey 账号(base_url 为
opencode.ai/zen/go/v1)接入该用量窗口:

- 后端:OpenCodeGoUsageService——手动刷新(每账号 10s 节流)、
  周期自动刷新(全局开关 + 账号开关双开启,interval 5-1440 分钟,
  leader lock + 每轮 20 账号 + 并发 4)、失败指数退避(上限 24h)、
  Retry-After 尊重;401→unauthorized、403→failed;snapshot 持久化于
  accounts.extra(单账号写入,无分组 CAS、无会话加密)
- admin API:settings GET/PUT、/:id/opencode-go-usage GET、
  auto-refresh PUT、refresh POST(与 Ollama Cloud usage 同构)
- 前端:账号列表行内三窗口进度条(复用 UsageProgressBar)、
  编辑弹窗内配置面板(详情+手动刷新+auto-refresh)、全局设置卡片、
  en/zh i18n

验证:
- 上游端点已用生产真实 Go key 实测 200,响应
  {"usage":{"rolling":{"status":"ok","percent":6,...},...}}(2026-08-13)
- go build ./... OK;service/handler/repo/dto/cmd 全量回归 OK
- 新增单测 20 例(解析 401/403/畸形 JSON、退避、节流等)全绿
- frontend vitest 新增 7 例 + Ollama 回归 4 例全绿;vue-tsc 零错误
2026-09-23 15:26:35 +08:00
Wesley Liddick afd069b169 Merge pull request #7532 from alfadb/feature/claude-cli-version-sync
feat(claude): 自动同步 Claude Code 客户端版本号,无需改代码跟随官方版本
2026-09-23 14:54:36 +08:00
shaw 6452aeffa1 fix(claude): 修复版本同步契约测试和缓存刷新 2026-09-23 14:39:29 +08:00
Wesley Liddick 4318a63bd8 Merge pull request #7509 from lyy0709/codex/gpt6-sol-luna-opus55
feat(models): support GPT-6 Sol, Luna and Claude Opus 5.5
2026-09-23 14:23:38 +08:00
alfadb ca08825935 feat(claude): 自动同步 Claude Code 客户端版本号
出站伪装用的 Claude Code 版本号此前硬编码在 claude.CLICurrentVersion,
只能靠环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖且需重启;Anthropic 抬高
版本下限时(如 Opus 5.5 要求 >= 2.1.280)必须改代码发版才能跟上。

改为每小时从官方 GitHub Releases 同步最新稳定版,无需重启也无需发版。

- 新增 ClaudeCodeVersionSyncService:1 小时间隔,数据源 anthropics/claude-code
  的 releases/latest,失败回退最近 30 条列表;过滤 draft/prerelease;
  只向前推进,拉取失败保留上次值不降级;面板可关闭
- 新增三个设置项:claude_code_client_version(管理员手动固定)、
  claude_code_client_version_synced(同步服务独占写)、
  claude_code_version_auto_sync_enabled(开关,默认开)
- 取值优先级:面板手动值 -> 同步值 -> SUB2API_CLAUDE_CLI_VERSION -> 内置基线,
  每层经 IsSupportedCLIVersion 校验,含 60s 缓存与 singleflight
- 版本号运行期化:claude.DefaultHeaders 由包级 var 改为函数,新增
  EffectiveCLIVersion/DefaultUserAgent 与 SetCLIVersionResolver(未注入时
  与 CLIVersion() 等价);identity 的 defaultFingerprint 不再在包 init 固化,
  指纹下限与主版本超前校验改用运行期值
- 守住一致性铁律:buildUpstreamRequest 与 count_tokens 各取一次 UA 字符串,
  同时喂给出站 User-Agent 头与请求体 billing 的 cc_version,避免缓存翻转
  瞬间头体版本不一致被判非正版客户端
- 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
2026-09-23 13:35:44 +08:00
lyy0709 c060aa0031 fix(models): repair tests after rebasing onto upstream main
Close the existing catalog identity test before the new capability test and
assert that an explicit null capability remains present. Update the quota
snapshot fixture to the pointer parameter already used by the current base.

Verified six full backend unit packages, scoped lint and CGO-disabled build.
2026-09-23 12:01:40 +08:00
HuntercodeT ecba49f711 fix(gateway): let OpenAI passthrough accounts supplement model discovery instead of hiding mapped models
A single passthrough account made GetAvailableModels return nil for the whole
group, so every other account's model_mapping was dropped from /v1/models and
the Codex catalog. Treat passthrough accounts like unmapped ones: skip their
(possibly stale) mapping and let supplementUnmappedOpenAIModels add the
default set, while ordinary accounts' mappings still reach the list.

Refs #6647, #7517
2026-09-23 11:51:06 +08:00
Achord Chan 17ce21c038 test(scheduler): pass explicit multiplier pointer to snapshot 2026-09-23 11:38:23 +08:00
shaw 5e244e7382 fix(test): 修复调度倍率参数变更后的 CI 编译失败 2026-09-23 11:36:08 +08:00
Achord Chan b5b5f1f94b fix(model-plaza): apply independent video billing multipliers 2026-09-23 11:28:41 +08:00
lyy0709 5b5f315b87 chore: remove model selector review screenshot 2026-09-23 11:27:49 +08:00
lyy0709 c4c2e66070 feat(models): support GPT-6 Sol, Luna and Claude Opus 5.5 2026-09-23 11:27:49 +08:00
Wesley Liddick d8d5e1dd35 Merge pull request #7282 from feeeei/feat/affiliate-offline-withdrawal
feat(affiliate): 提取记录支持登记线下提现
2026-09-23 11:20:32 +08:00
Wesley Liddick 59a00631ed Merge pull request #7478 from superzcj/codex/simple-mode-key-window-limits
feat: optionally enforce API-key spending windows in simple mode
2026-09-23 11:19:17 +08:00
Wesley Liddick b2d6b954a1 Merge pull request #7090 from Brisbanehuang/codex/scheduling-rate-account-fallback
fix(scheduler): 无有效探测时回退账号倍率,支持 OAuth 参考倍率留空
2026-09-23 11:18:37 +08:00
Wesley Liddick 1947add842 Merge pull request #7288 from feeeei/feat/backup-monthly-archive
feat(backup): 支持月度归档与独立保留策略
2026-09-23 11:18:08 +08:00
Wesley Liddick 6b71d75bde Merge pull request #4867 from coo1white/fix-backup-s3-secret-reencrypt
fix(backup): keep the S3 secret encrypted when a save inherits it
2026-09-23 11:17:55 +08:00
Wesley Liddick f53252e292 Merge pull request #6844 from ouchihao/codex/issue-6837-groups-manifest-test-pinia
test(groups): initialize Pinia in Codex manifest regression
2026-09-23 11:17:41 +08:00
Wesley Liddick 61e7d31605 Merge pull request #7513 from Achordchan/fix/channel-video-pricing-preview
fix(channels): display video pricing per second
2026-09-23 11:17:27 +08:00
shaw 033047b7d4 chore(docs): 移除已删除文档的忽略例外 2026-09-23 10:36:16 +08:00
shaw d68a68fd0d chore(docs): 清理 PR 误入的文档和截图 2026-09-23 10:35:34 +08:00
Wesley Liddick 31a6e74770 Merge pull request #7491 from Thinker-Joe/fix/openai-picker-model-mapping
fix(openai): scope the account test picker to the account model mapping
2026-09-23 10:31:36 +08:00
Wesley Liddick 7c0a2a556c Merge pull request #7160 from StarryKira/codex/log-retention-upstream
feat: 支持运维与请求日志的滚动保留设置
2026-09-23 10:07:25 +08:00
Wesley Liddick 0dd71286f2 Merge pull request #7060 from heathermhuang/fix/composite-image-models-6076
fix(images): allow compatible Gemini models on API-key upstreams
2026-09-23 10:07:02 +08:00
Wesley Liddick b350e079f8 Merge pull request #7139 from HP-network/fix/api-key-model-401-failover
fix: keep API-key accounts available for unknown models
2026-09-23 10:05:42 +08:00
Wesley Liddick 24872fda7c Merge pull request #7493 from ouchihao/codex/bug-temp-unsched-race
fix(accounts): keep temporary unschedulable status scoped to the current account
2026-09-23 10:03:22 +08:00
Wesley Liddick 5ad7cf4fb9 Merge pull request #7300 from lizhaoxinlizhaoxin2-lab/fix/responses-stream-error-schema
fix(responses): emit protocol-correct stream errors once
2026-09-23 10:03:00 +08:00
Wesley Liddick 60b9bf7558 Merge pull request #7308 from HuntercodeT/fix/codex-manifest-non-gpt-identity
fix(codex-models): 非 GPT 模型的 Codex 提示词模板去掉 GPT 身份声明,避免 Antigravity 429
2026-09-23 10:01:45 +08:00
Wesley Liddick d6e8b44bcb Merge pull request #7411 from HuntercodeT/fix/antigravity-vendor-identity-429
fix(antigravity): neutralize Claude Agent SDK identity in system prompts
2026-09-23 10:01:18 +08:00
Wesley Liddick fa3f526785 Merge pull request #7489 from doublepi123/fix/tool-schema-null-required
fix(tools): 清洗工具 Schema 里非法的 null required,修复 xAI / Moonshot 400
2026-09-23 10:01:03 +08:00
Wesley Liddick 7d5615998c Merge pull request #7492 from ouchihao/codex/bug-monitor-detail-race
fix(channel-monitor): ignore stale detail requests
2026-09-23 10:00:49 +08:00
Wesley Liddick d7e4bba0e7 Merge pull request #7494 from ouchihao/codex/bug-balance-error-message
fix(users): show normalized API errors in the balance dialog
2026-09-23 10:00:30 +08:00
Wesley Liddick d3d0f653e5 Merge pull request #7495 from ouchihao/codex/bug-subscription-expiry-label
fix(subscriptions): use calendar dates for expiry labels
2026-09-23 09:59:49 +08:00
Wesley Liddick 9e0e146984 Merge pull request #7496 from ouchihao/codex/bug-totp-late-timer
fix(profile): avoid starting TOTP cooldowns after unmount
2026-09-23 09:59:22 +08:00
Wesley Liddick fc4465f78b Merge pull request #7498 from ouchihao/codex/bug-model-tag-ime
fix(channels): preserve model tags during IME composition
2026-09-23 09:58:50 +08:00
Wesley Liddick 0eaa7c3c84 Merge pull request #7499 from ouchihao/codex/bug-date-picker-midnight
fix(date-picker): refresh relative dates after midnight
2026-09-23 09:58:36 +08:00
Wesley Liddick 5e4968492f Merge pull request #7500 from ouchihao/codex/bug-user-attribute-race
fix(users): discard stale user attribute responses
2026-09-23 09:57:53 +08:00
Wesley Liddick a81547f244 Merge pull request #7501 from ouchihao/codex/bug-profile-dingtalk-source
fix(profile): recognize DingTalk profile sources
2026-09-23 09:57:39 +08:00
Achord Chan bdb9a91db0 fix(channels): display video pricing per second 2026-09-23 09:09:37 +08:00
zhangcj 3b336b2eca feat: optionally enforce API-key spending windows in simple mode 2026-09-23 08:17:48 +08:00
Thinker-Joe 9d7c636db9 fix(openai): respect passthrough in picker image choices
Validate mapped targets for default image models as well as aliases. Preserve native image choices in passthrough and cover mapping changes over the shared catalog cache.
2026-09-23 01:43:06 +08:00
feeeei c19204289d fix(affiliate): 线下提现登记按 Idempotency-Key 幂等
登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣
一次返利额度。现在一次登记由 Idempotency-Key 标识,数据库在同一事务内以唯一
约束去重:

- user_affiliate_ledger 新增 operation_id(幂等键派生的 SHA-256)与部分唯一
  索引(迁移 240);接口要求 Idempotency-Key,缺失或非法返回 400
- 事务内先以 operation_id 写入占位流水,再解冻、扣减并补写额度快照;同键
  重试返回首笔流水的结果并带 X-Idempotency-Replayed,不重复扣减;同键换了
  用户或金额返回 409 IDEMPOTENCY_KEY_CONFLICT;额度不足时占位随事务回滚
- 并发的同键请求在唯一索引上等待先到者:先到者提交则返回同一条流水,回滚
  则自行登记
- 弹窗在网络错误、超时、5xx、408/409 时锁定用户与金额,重试沿用同一请求与
  幂等键;幂等键按管理员、用户、金额暂存于 sessionStorage,登记有确定结果后
  释放
2026-09-23 01:24:59 +08:00
feeeei 8f8358cc67 fix(backup): 关闭月度归档后不再改写旧归档的保留份数 2026-09-23 00:44:29 +08:00
Wesley Liddick 20a94fbb56 Merge pull request #6920 from aofee/codex/grok-quota-cooldown
fix(grok): allow quota queries during account cooldowns
2026-09-22 22:01:24 +08:00
Wesley Liddick da25b18db7 Merge pull request #7077 from ggllzz04/fix/antigravity-tuple-array-schema
fix(antigravity): handle prefixItems and ensure array schema has valid items fallback
2026-09-22 22:01:09 +08:00
Wesley Liddick 02d9901b49 Merge pull request #7038 from zenggang/contribute/gpt55-lite-compat
fix(openai): remove Responses Lite markers after OAuth mapping to GPT-5.5
2026-09-22 22:00:51 +08:00
Wesley Liddick 13be6ca27c Merge pull request #7276 from cyhhao/fix/openai-keepalive-semantic-output
fix(openai): keep transport heartbeats outside semantic output
2026-09-22 22:00:08 +08:00
Wesley Liddick e26abaef7d Merge pull request #6473 from cxx-cod/pr/openai-stream-terminal-eof
fix(openai): end streaming at terminal event instead of waiting for u…
2026-09-22 21:57:45 +08:00
Wesley Liddick 583398d186 Merge pull request #7303 from little-greenbean/fix/upstream-response-close-lifecycle
fix(upstream): prevent EOF stalls when closing response bodies
2026-09-22 21:57:24 +08:00
Wesley Liddick 5cc6ca6f5b Merge pull request #7311 from YogaSakti/fix/scheduler-cache-rpm-fields
fix(scheduler): keep account RPM config in the scheduler projection
2026-09-22 21:57:05 +08:00
Wesley Liddick b12a187d4f Merge pull request #7097 from MHJoy99/contrib/upstream-5203-ping-filter
fix(streaming): drop Anthropic ping keepalives before OpenAI conversion
2026-09-22 21:56:46 +08:00
Wesley Liddick 974a819ba1 Merge pull request #7108 from ouchihao/codex/bug-select-keyboard-focus
fix(ui): focus non-searchable select dropdowns
2026-09-22 21:56:34 +08:00
Wesley Liddick d27fbb3b7d Merge pull request #7109 from ouchihao/codex/bug-date-range-dismiss
fix(ui): discard unapplied date ranges on dismissal
2026-09-22 21:56:24 +08:00
Wesley Liddick ea34a2356c Merge pull request #7142 from ouchihao/codex/bug-select-search-highlight
fix(ui): reset select highlight when search results change
2026-09-22 21:56:14 +08:00
Wesley Liddick fa9f104a93 Merge pull request #7169 from HuntercodeT/fix/gemini-mixed-model-listing
fix(gateway): list mixed Antigravity models for Gemini groups
2026-09-22 21:56:04 +08:00
Wesley Liddick 51f73840a1 Merge pull request #7272 from zoutao212/fix/responses-function-call-arguments-done
fix(apicompat): 修复 Anthropic 转 Responses 流中 function_call_arguments.done 缺少 arguments 参数的问题
2026-09-22 21:55:48 +08:00
Wesley Liddick 2b12de14ee Merge pull request #7319 from ouchihao/codex/bug-dialog-scroll-lock
fix(dialog): keep body locked while another dialog is open
2026-09-22 21:55:30 +08:00
Wesley Liddick 7865051fdf Merge pull request #7320 from ouchihao/codex/bug-image-upload-read-order
fix(upload): cancel superseded image reads
2026-09-22 21:55:18 +08:00
Wesley Liddick 4b0adb6863 Merge pull request #7321 from ouchihao/codex/bug-proxy-expiry-boundary
fix(proxies): label newly expired proxies correctly
2026-09-22 21:55:07 +08:00
Wesley Liddick ba5737fe14 Merge pull request #7322 from ouchihao/codex/bug-ccswitch-antigravity-slash
fix(ccswitch): trim trailing slashes before Antigravity path
2026-09-22 21:54:51 +08:00
Wesley Liddick 7b13cb410d Merge pull request #7323 from ouchihao/codex/bug-announcement-stale-fetch
fix(announcements): ignore superseded fetch results
2026-09-22 21:54:38 +08:00
Wesley Liddick bb1f40e35f Merge pull request #7487 from wucm667/fix/issue-7482-anthropic-moderation
Handle trailing system messages in moderation input
2026-09-22 21:54:26 +08:00
Wesley Liddick 7be1628a74 Merge pull request #7488 from wucm667/fix/issue-7484-responses-item-id
Strip oversized input item IDs
2026-09-22 21:54:02 +08:00
chihao.ou 3eb4bae9ab fix(profile): recognize DingTalk profile sources 2026-09-22 21:49:27 +08:00
chihao.ou 1f5cfe8973 fix(users): discard stale user attribute responses 2026-09-22 21:49:11 +08:00
chihao.ou 5fc4a06af6 fix(date-picker): refresh relative dates after midnight 2026-09-22 21:46:06 +08:00
chihao.ou a1eaad6033 fix(channels): preserve model tags during IME composition 2026-09-22 21:45:48 +08:00
chihao.ou 1af3269adb fix(groups): clean up modal listeners and pending searches 2026-09-22 21:43:13 +08:00
chihao.ou 1ca54bcc25 fix(profile): avoid starting TOTP cooldowns after unmount 2026-09-22 21:42:54 +08:00
chihao.ou 7ba6efe805 fix(subscriptions): use calendar dates for expiry labels 2026-09-22 21:40:14 +08:00
chihao.ou 4d667f1b08 fix(users): show normalized API errors in the balance dialog 2026-09-22 21:39:54 +08:00
chihao.ou 5c38d372c1 fix(accounts): keep temporary unschedulable status scoped to the current account 2026-09-22 21:37:01 +08:00
chihao.ou 9e4010695b fix(channel-monitor): ignore stale detail requests 2026-09-22 21:36:42 +08:00
Thinker-Joe 4daea093f9 fix(openai): keep OAuth image aliases in the test picker
Image choices were resolved from DefaultModels IDs and from mapping keys, so an
alias whose target is a local image model (paint -> gpt-image-2.5-flare) never
reached the picker: IsModelSupported only matches keys, and a public name is not
an image model. Resolve the public name to its target before deciding, and only
synthesize an entry when the target is an image model.

Update the fixture of
TestFetchOpenAIAccountModelsOAuthLabelsLocalImageModelsLikeUpstream: it asserted
on an upstream slug the account did not configure, which conflicts with the
account-scoped projection. Coverage is preserved by configuring that slug and by
a new test asserting unconfigured text models stay out.
2026-09-22 21:15:20 +08:00
lcy 1d640c40e5 fix(tools): 清洗工具 Schema 里非法的 null required,修复上游 400
JSON Schema 规定 required 必须是属性名数组,但部分客户端会发成 null,
严格校验的上游直接拒绝整个请求:

  xAI       400 /required: null is not of type "array"
  Moonshot  400 At path 'required': required must be an array

同样的模型发纯文本请求一切正常,只有带 tools 时才失败,因此表现得像模型
或渠道故障。

三处改动:

1. 清洗 required:null 时整键删除,而不是替换成 []。JSON Schema 里缺省
   required 等价于无必填项,删除保留了客户端原意,置 [] 反而凭空造出一条
   客户端从未声明的约束。

2. 把 input_schema 也识别为 schema 根。此前只认 OpenAI Responses 的
   parameters 拼法,Anthropic Messages 的工具定义因此绕过整套清洗。

3. 在 ForwardAsAnthropic 入口调用清洗。/v1/messages 此前不做任何工具
   Schema 处理;该函数会分流到原生 Anthropic 直通、Chat Completions 与
   Responses 三条路径,每条都原样透传 tools,所以必须在分流之前处理。

另外把 contentSchema 补进子 schema 关键字:它承载嵌套 schema,其中的
null required 此前会漏网。

配套测试同时锁定反向保证:default / examples / const / enum 里装的是实例
数据,字面量 {"required": null} 属于客户端 payload,不能被改写。现有的
关键字白名单下降本就如此,测试用于防止它退化成无差别遍历。
2026-09-22 20:49:24 +08:00
Wesley Liddick b0e32fe5be Merge pull request #7427 from xlplbo/fix/openai-legacy-scheduler-decision
fix(openai): 非高级调度补齐选号决策并按 previous_response 路由
2026-09-22 20:34:59 +08:00
Wesley Liddick 65f086d2d0 Merge pull request #7479 from lzfxxx/fix/opencode-commandcode-cf1010
fix(gateway): prevent Cloudflare 1010 from disabling OpenCode and Command Code accounts
2026-09-22 20:32:42 +08:00
Wesley Liddick de6388aeca Merge pull request #5443 from llissery/fix/vertex-429-cooldown
fix(gemini): honor RetryInfo on 429 and stop cooling Vertex service accounts until PST midnight
2026-09-22 20:30:37 +08:00
Wesley Liddick 55108ee877 Merge pull request #7394 from Achordchan/fix/baseten-reasoning-budget
fix(gateway): recognize Baseten reasoning budget errors
2026-09-22 20:30:21 +08:00
Wesley Liddick 231e72fdde Merge pull request #7339 from superman2003/fix/openai-stream-error-status-field
fix(openai): honor error.status when classifying in-stream failures
2026-09-22 20:30:08 +08:00
Wesley Liddick a079a6596a Merge pull request #7342 from Pluviobyte/codex/fix-inactive-proxy-backup
fix(proxies): exclude inactive fallback targets
2026-09-22 20:29:52 +08:00
Wesley Liddick ed2360d7ea Merge pull request #7341 from Pluviobyte/codex/fix-proxy-expiry-renewal
fix(proxies): preserve renewals during expiry sweeps
2026-09-22 20:29:39 +08:00
Wesley Liddick b717ac7f0b Merge pull request #7289 from wucm667/fix/issue-7281-platform-quota-options
fix(admin): align platform quota editor with supported platforms
2026-09-22 20:29:26 +08:00
Wesley Liddick c619ff8474 Merge pull request #7404 from a408665321/codex/fix-oauth-credentials-merge
fix(account): preserve model mapping on OAuth reauth
2026-09-22 20:29:01 +08:00
Wesley Liddick 2448a38b55 Merge pull request #7395 from zhuzhihaocool-bit/fix/codex-base-url-v1
fix(frontend): ensure Codex base URLs include /v1
2026-09-22 20:28:39 +08:00
Wesley Liddick 5fd3461142 Merge pull request #7418 from ouchihao/codex/bug-pricing-exponent
fix(pricing): parse scientific notation in token bounds
2026-09-22 20:28:12 +08:00
Wesley Liddick c1319917cf Merge pull request #7417 from ouchihao/codex/bug-payment-callback-slash
fix(payment): trim callback base URL trailing slashes
2026-09-22 20:28:03 +08:00
Wesley Liddick a5015e1fc4 Merge pull request #7354 from baryon/contrib/codex-deepseek-input-image-url
fix(openai): alias DeepSeek Responses input_image with url
2026-09-22 20:27:35 +08:00
Wesley Liddick 738ef1c73c Merge pull request #7442 from wucm667/fix/issue-7436-image-balance-error
fix(images): preserve insufficient balance failures
2026-09-22 20:27:23 +08:00
Wesley Liddick 5af29a3b93 Merge pull request #7422 from ouchihao/codex/bug-user-api-keys-stale
fix(users): keep API key results scoped to the selected user
2026-09-22 20:27:07 +08:00
Wesley Liddick 62a8919a1c Merge pull request #7365 from ouchihao/codex/bug-monitor-template-stale-list
fix(monitor): ignore stale template picker responses
2026-09-22 20:26:58 +08:00
Wesley Liddick 1ea00258d0 Merge pull request #7375 from ouchihao/codex/bug-balance-history-stale
fix(users): ignore superseded balance history requests
2026-09-22 20:26:33 +08:00
Wesley Liddick 023b38354f Merge pull request #7373 from ouchihao/codex/bug-user-groups-load-guard
fix(users): wait for group configuration before enabling save
2026-09-22 20:26:23 +08:00
Wesley Liddick a765b3c176 Merge pull request #7367 from ouchihao/codex/bug-rpm-override-empty
fix(groups): require integer RPM overrides before adding
2026-09-22 20:26:11 +08:00
Wesley Liddick ca47fa352c Merge pull request #7376 from ouchihao/codex/bug-group-rate-positive
fix(groups): reject negative custom rates before adding
2026-09-22 20:26:00 +08:00
Wesley Liddick 680a992fc9 Merge pull request #7426 from xlplbo/fix/openai-codex-turn-metadata-ascii
fix(openai): 改写 Codex turn metadata 时保留非 ASCII 转义
2026-09-22 20:25:47 +08:00
Wesley Liddick f2e55bf4d7 Merge pull request #7420 from ouchihao/codex/bug-error-detail-stale
fix(usage): ignore superseded error detail requests
2026-09-22 20:25:26 +08:00
Wesley Liddick c96d36ec95 Merge pull request #7374 from ouchihao/codex/bug-search-ime
fix(search): wait for committed IME input before searching
2026-09-22 20:25:15 +08:00
Wesley Liddick e4f97a53ec Merge pull request #7366 from ouchihao/codex/bug-user-attribute-clear-metadata
fix(users): persist cleared custom attribute hints
2026-09-22 20:24:59 +08:00
Wesley Liddick 9e175bd491 Merge pull request #7364 from ouchihao/codex/bug-group-replace-errors
fix(users): show errors when group replacement fails
2026-09-22 20:24:47 +08:00
Wesley Liddick e7d348868c Merge pull request #7264 from ouchihao/codex/bug-antigravity-mapping-retry
fix(accounts): retry default mappings after a failed fetch
2026-09-22 20:24:32 +08:00
Wesley Liddick c0b23ff791 Merge pull request #7372 from ouchihao/codex/bug-admin-settings-retry
fix(settings): allow retrying failed initial admin settings loads
2026-09-22 20:24:21 +08:00
Wesley Liddick 322cc5e5f1 Merge pull request #7363 from ouchihao/codex/bug-user-attribute-number
fix(users): keep numeric custom attribute values as strings
2026-09-22 20:24:12 +08:00
Wesley Liddick 23dc073e65 Merge pull request #7419 from ouchihao/codex/bug-monitor-refresh-preference
fix(monitor): preserve the saved auto-refresh preference
2026-09-22 20:24:00 +08:00
wucm667 a5d8db2445 fix(responses): strip oversized input item IDs 2026-09-22 19:47:12 +08:00
GongTengXY de28eea11f fix(openai): filter account test models by model mapping 2026-09-22 19:34:15 +08:00
wucm667 66843cc95c fix(moderation): handle trailing Anthropic system messages 2026-09-22 19:07:04 +08:00
Achord Chan f867e3e2ec test(antigravity): check schema properties type assertion 2026-09-22 18:34:36 +08:00
Wesley Liddick 1716e99151 Merge pull request #7466 from nagi330/add_grok4-7_support-0922
feat(grok):add grok 4.7 support
2026-09-22 18:13:37 +08:00
Wesley Liddick 483cd56924 Merge pull request #7361 from wucm667/feat/issue-7357-optional-default-groups
feat(simple): make startup default group creation optional
2026-09-22 18:13:18 +08:00
Wesley Liddick b6c6f1add5 Merge pull request #7473 from lzfxxx/fix/codex-manifest-opencode-go
fix(codex): include OpenCode Go in model capability resolution
2026-09-22 18:13:00 +08:00
Wesley Liddick 3442a6a53e Merge pull request #7432 from doublepi123/fix/antigravity-compat-bare-gemini-model
fix(antigravity): 裸 Gemini 模型名在所有转发入口解析到思考变体,修复 404/502
2026-09-22 18:12:34 +08:00
Wesley Liddick 9d9e90960e Merge pull request #7448 from feeeei/fix/gemini-transport-error-failover
fix(gemini): 传输层错误转 failover,不再同账号退避重试
2026-09-22 18:12:19 +08:00
Wesley Liddick fcd07ba0af Merge pull request #7475 from Pluviobyte/codex/fix-proxy-restore-probe
fix(proxies): invalidate billing probe on fallback restore
2026-09-22 18:12:04 +08:00
Wesley Liddick 405727805a Merge pull request #7477 from Pluviobyte/codex/fix-subscription-reduction-remainder
fix(subscriptions): preserve partial days during redemption deductions
2026-09-22 18:11:46 +08:00
Wesley Liddick a46493724b Merge pull request #7476 from Pluviobyte/codex/fix-subscription-reduction-lock
fix(subscriptions): lock negative redemption updates
2026-09-22 18:11:11 +08:00
Wesley Liddick d8eeb0989b Merge pull request #7481 from spongehah/fix/selection-skip-account-count
perf: 选号读取分组时不再聚合账号数
2026-09-22 18:11:00 +08:00
Wesley Liddick a266e50e8e Merge pull request #7369 from feeeei/feat/admin-users-toggle-status-in-place
ui(users): 禁用/启用用户后原地更新该行
2026-09-22 18:10:45 +08:00
Wesley Liddick 5295bd8227 Merge pull request #7446 from wucm667/fix/issue-7441-csv-reasoning-effort
fix(usage): export missing reasoning effort cleanly
2026-09-22 18:10:35 +08:00
spongehah 7fbc59eb69 fix: 选号读取分组时不再聚合账号数
- 推理选号只需要隐私开关和分组名,改为 GetByIDLite
- 避免 GetByID 附带的账号计数聚合打满主库
- 管理列表的实时账号数统计保持不变
2026-09-22 16:54:36 +08:00
Zhaofeng Li fa79b1ebc3 fix(gateway): prevent Cloudflare 1010 from disabling OpenCode and Command Code accounts 2026-09-22 15:51:51 +08:00
Rain 0c0df031c5 fix(subscriptions): preserve partial days during redemption deductions 2026-09-22 15:03:06 +08:00
Rain 85ed17b313 fix(subscriptions): lock negative redemption updates 2026-09-22 15:03:05 +08:00
Rain b754ff6f1b fix(proxies): invalidate billing probe on fallback restore 2026-09-22 14:51:44 +08:00
Zhaofeng Li e35123b8ac fix(codex): include OpenCode Go in model capability resolution 2026-09-22 14:50:29 +08:00
Erik 7f1f85cb76 fix(gemini): stop cooling Vertex 429s until PST midnight, honor RetryInfo
Vertex AI reports 429s with a standard google.rpc.RetryInfo detail
(retryDelay like "39s"), but ParseGeminiRateLimitResetTime only knew the
AI Studio quotaResetDelay metadata, so parsing always failed for Vertex
bodies. The fallback then lumped service_account accounts in with
API-key/AI-Studio ones and rate limited them until the next PST
midnight, so a transient per-minute throttle could take a pay-as-you-go
account (and with it the whole group) offline for up to a day.

- Parse google.rpc.RetryInfo retryDelay from 429 bodies, capped at 15
  minutes to guard against absurd upstream values.
- When no reset time can be parsed, cool service_account (Vertex)
  accounts down for 60s instead of until PST midnight: pay-as-you-go
  quotas are short-window RPM/TPM with no daily reset.

Fallback behavior for Code Assist, Google One and API-key accounts is
unchanged, covered by new regression tests.
2026-09-22 11:31:31 +08:00
nagi330 935db68517 feat(grok):add grok 4.7 support 2026-09-22 10:30:01 +08:00
Wesley Liddick 1c0a69c0ce Merge pull request #7412 from StarryKira/codex/custom-reasoning-effort-billing
feat: 支持自定义思考等级倍率计费
2026-09-21 22:55:17 +08:00
lcy 27f4398b50 fix(antigravity): 裸 Gemini 模型名在所有转发入口解析到思考变体
#7258 引入的 resolveGeminiThinkingVariant 只接到了 ForwardGemini,
因此仅覆盖 /v1beta 原生 Gemini 路径。经 /v1/chat/completions 与
/v1/messages 进来的请求直接调用 getMappedModel,裸名被原样透传;
Antigravity 目录只登记 -low/-medium/-high/-tiered 变体,这些请求
仍然以 404 "Requested entity was not found." 失败。

把解析下沉到 getMappedModel(新增 getMappedModelForThinkingLevel),
一次覆盖四个调用点:/v1beta 原生、/v1/chat/completions、/v1/messages
以及后台账号连接测试。各转发入口按自身协议推导档位——原生读
generationConfig.thinkingConfig,Claude 侧读 thinking.budget_tokens,
两者阈值一致;默认入口按 high 兜底。

mapAntigravityModel 保持不变,调度侧的模型支持判定语义不受影响。
2026-09-21 20:13:44 +08:00
haruka e47255715d fix(billing): preserve final reasoning effort across forwarding paths 2026-09-21 19:58:02 +08:00
feeeei d0ed0eacac fix(gemini): 传输层错误转 failover,不再同账号退避重试
上游 Do 返回非 HTTP 错误(代理/DNS/TCP/TLS/响应头超时)时,Gemini 三条转发路径
(ForwardNative / Forward / ForwardAsChatCompletions)原先在同一账号上重试 5 次,
退避 1+2+4+8≈15s(time.Sleep 不感知 ctx),耗尽后直接向客户端写 502 且不换号:
单账号链路故障期间 TTFT 被整段拖高,同组健康账号闲置。

对齐 Anthropic/Bedrock 侧 handleUpstreamTransportError 的既有语义:
- 传输层错误单次尝试后即返回 *UpstreamFailoverError(502),由 handler failover
  循环换号,耗尽后才按端点格式渲染错误;service 不再写响应
- 持久性故障(connection refused/no route/DNS not found/代理认证失败)额外临时
  摘除账号 10 分钟;瞬时故障(EOF/reset/超时)只换号不摘号
- context.Canceled 保持原样返回:不换号也不摘账号
- countTokens 传输失败时立即本地估算兜底,不换号
- 摘号实现抽为 tempUnscheduleAccountForTransportError 供两侧共用,日志事件名不变

HTTP 状态码(429/5xx)的服务内重试环未改动。
2026-09-21 14:13:57 +08:00
wucm667 ece1ecccca fix(gateway): normalize streamed Anthropic usage 2026-09-21 12:58:56 +08:00
wucm667 b13200d7a1 fix(gateway): forward received chat stream usage 2026-09-21 12:58:56 +08:00
wucm667 386f321d3c fix(usage): export missing reasoning effort cleanly 2026-09-21 12:41:01 +08:00
wucm667 60a64568bf ci: run simple mode compose environment test 2026-09-21 11:06:25 +08:00
wucm667 de98bdff1e fix(images): preserve insufficient balance failures 2026-09-21 10:23:10 +08:00
wucm667 992c4f4b89 test(frontend): run platform quota modal spec in CI 2026-09-21 08:16:18 +08:00
aofee d1ba57977d feat: add native TypeSafe Jev System One support 2026-09-20 20:20:16 +08:00
shaw 7c700729c2 chore: update gitignore 2026-09-20 20:05:09 +08:00
Wesley Liddick 87957a608b Merge pull request #7413 from ranxi2001/ci/release-build-matrix
ci: parallelize release builds across platform runners
2026-09-20 20:02:48 +08:00
xlplbo 0d02000592 fix(openai): 非高级调度按 previous_response 路由到持有响应的账号 2026-09-20 19:28:13 +08:00
xlplbo 8d88b66668 fix(openai): 补齐非高级调度选号决策标签 2026-09-20 19:27:53 +08:00
xlplbo 763443abf2 fix(openai): 改写 Codex turn metadata 时保留非 ASCII 转义 2026-09-20 19:27:47 +08:00
chihao.ou 7f18f3e9af fix(users): keep API key results scoped to the selected user 2026-09-20 18:09:50 +08:00
chihao.ou b86849e442 fix(usage): ignore superseded error detail requests 2026-09-20 18:07:52 +08:00
chihao.ou 319299519b fix(monitor): preserve the saved auto-refresh preference 2026-09-20 18:07:39 +08:00
chihao.ou 23a1d38187 fix(pricing): parse scientific notation in token bounds 2026-09-20 18:05:23 +08:00
chihao.ou 1582347204 fix(payment): trim callback base URL trailing slashes 2026-09-20 18:05:09 +08:00
ranxi2001 d1556a7a96 ci: point release helper tests at their final directory 2026-09-20 17:01:27 +08:00
ranxi2001 d2416a9b68 ci: keep release tooling and cache keys stable across jobs 2026-09-20 16:54:06 +08:00
HuntercodeT fb58dfbd93 test(antigravity): cover the identity rewrite end-to-end through the transformer
Adds a case with the real block layout (attribution block, identity sentence,
task instructions) and asserts that user content mentioning Claude Code and
claude-sonnet-4-6 is forwarded unchanged.
2026-09-20 16:48:04 +08:00
haruka b8d52fad34 feat(billing): support configurable reasoning effort multipliers 2026-09-20 16:41:49 +08:00
shaw d2e319b2a1 test: 修复 WebSocket 同线程抢占测试的关闭时序竞态 2026-09-20 16:37:07 +08:00
ranxi2001 0892ef3a9a ci: build release targets on independent matrix runners 2026-09-20 16:36:24 +08:00
HuntercodeT dd292e3b00 fix(antigravity): neutralize Claude Agent SDK identity in system prompts
Stripping the x-anthropic-billing-header line is not enough. Claude Code's
Agent SDK prompts (session title generation, and other SDK-driven calls) open
a separate system block with

    You are a Claude agent, built on Anthropic's Claude Agent SDK.

and the Google upstream still rejects those requests with 429 RESOURCE_EXHAUSTED.

Measured on Sub2API 0.2.7, which already contains the attribution fix, using an isolated single-account group, waiting for the per-model cooldown
to expire before every request so that a previous failure could not turn later
results into 503:

  original request replayed                      429 (8/8)
  same request, generic system prompt            200 (8/8)
  attribution also removed client-side           429 (3/3)
  attribution removed + identity neutralized     200 (3/3)
  only 'Claude' replaced in system               200 (2/2)
  only 'Anthropic' removed from system           200 (2/2)
  output_config / max_tokens / non-stream        no effect

The rewrite is anchored at the start of a system block, so user instructions that
merely mention Claude or Anthropic, and model ids such as claude-sonnet-4-6, are
left untouched. Scoped to the Antigravity transformer, as with the existing
attribution handling.
2026-09-20 16:34:26 +08:00
shaw 95134201b2 docs: 删除误合入的 Codex 积分文档和截图 2026-09-20 16:12:11 +08:00
Wesley Liddick acdf3c54da Merge pull request #7401 from StarryKira/codex/codex-credits-referrals-upstream
feat(openai): display Codex credits and support referral invitations
2026-09-20 16:00:28 +08:00
haruka fc96132e72 fix(openai): harden referral outcomes and isolate HTTP transport 2026-09-20 15:47:53 +08:00
haruka 8f6bbb59c7 test: stabilize Gemini keepalive test 2026-09-20 15:04:15 +08:00
Wesley Liddick fbb9006ade Merge pull request #7402 from Wei-Shaw/fix/7268-openai-http2-keepalive
fix(upstream): 恢复 OpenAI HTTP/2 保活容错期限
2026-09-20 14:57:35 +08:00
shaw e009ea3036 fix(upstream): 恢复 OpenAI HTTP/2 保活容错期限 2026-09-20 14:46:25 +08:00
a408665321 63c079d873 fix(account): preserve settings on OAuth reauth 2026-09-20 14:39:36 +08:00
haruka e509937382 docs: add Codex referral UI validation screenshots 2026-09-20 14:38:30 +08:00
haruka 5090ffe05e feat(openai): show Codex credits and manage referral invitations 2026-09-20 14:38:29 +08:00
Wesley Liddick bbdcfbac0a Merge pull request #7304 from wucm667/fix/issue-7298-response-model-alias
fix(gateway): restore public response model aliases
2026-09-20 14:07:16 +08:00
Wesley Liddick 7403a01177 Merge pull request #7314 from wucm667/fix/issue-7306-keyword-reminder-bypass
fix(moderation): prevent reminder tags from bypassing keyword checks
2026-09-20 14:07:04 +08:00
Wesley Liddick 53b4bbe736 Merge pull request #7349 from wucm667/fix/issue-7348-preserve-client-version
fix(settings): preserve client version on invalid user agent
2026-09-20 14:06:48 +08:00
Wesley Liddick 8bb48622a3 Merge pull request #7362 from wucm667/fix/issue-7358-preserve-account-errors
fix(accounts): preserve refresh errors during usage queries
2026-09-20 14:06:36 +08:00
Wesley Liddick f28adb6ddb Merge pull request #7387 from Brisbanehuang/codex/upstream-content-audit-typesafe
feat: 内容审计接入 TypeSafe AI,支持双引擎切换与独立配置
2026-09-20 13:47:14 +08:00
Wesley Liddick 19794bc46a Merge pull request #7397 from Wei-Shaw/feat/hostservice-account-metadata
feat(pluginapi): HostService 账号目录返回结构化只读元数据
2026-09-20 11:39:15 +08:00
shaw 29eead917f feat(pluginapi): HostService 账号目录返回结构化只读元数据
纯宿主侧改动,向后兼容。

- 契约(pkg/pluginapi/v1/plugin.proto,已重新生成):ListAccounts 增补
  repeated AccountInfo accounts(保留 account_ids 向后兼容),新增 metadata_json
  只读快照字段。HostServiceAPIVersion 1→2(InitHostServices 运行时协商,加法式,
  旧调用方忽略新字段即可)。
- 权限边界:可见范围由清单能力经 pluginCapabilityAccountScopeGrants 推导,
  ListAccounts 与 ResolveOutboundIdentity 均以该范围为权威边界。
- metadata_json 采用 denylist:剥离 Credentials 与含反向引用的关联对象
  Groups/AccountGroups(避免 encoding/json 循环引用),其余字段自动透传,
  数据模型新增非机密字段无需改宿主。

测试:范围/元数据/无凭据泄漏用例、denylist 反射护栏 + 循环引用安全断言、纵深过滤用例;
build/vet/gofmt、-tags unit 与 -race 全绿。
2026-09-20 11:26:19 +08:00
zhuzhihaocool-bit fabdfb8a5b fix(frontend): ensure Codex base URLs include /v1 2026-09-20 11:07:29 +08:00
Achord Chan 1411b7eb7e fix(gateway): recognize Baseten reasoning budget errors 2026-09-20 11:02:06 +08:00
Achord Chan ef51690b5a fix(antigravity): preserve string const constraints in tool schemas 2026-09-20 10:46:09 +08:00
Brisbanehuang 1bfe0d3728 fix: sync draft audit key status and threshold defaults 2026-09-19 20:52:31 -04:00
Brisbanehuang 783a0a9830 feat(content-audit): add independent TypeSafe engine profiles 2026-09-19 20:52:31 -04:00
chihao.ou cd2a4357c2 fix(groups): reject negative custom rates before adding 2026-09-19 19:43:39 +08:00
chihao.ou f3a2dcabba fix(users): ignore superseded balance history requests 2026-09-19 19:43:23 +08:00
chihao.ou 7bf3d0b585 fix(search): wait for committed IME input before searching 2026-09-19 19:43:08 +08:00
chihao.ou cb2bb6084c fix(users): wait for group configuration before enabling save 2026-09-19 19:42:12 +08:00
chihao.ou 00591cbe9a fix(settings): allow retrying failed initial admin settings loads 2026-09-19 19:41:56 +08:00
chihao.ou ba26e543f9 fix(accounts): retry default mappings after a failed fetch 2026-09-19 19:26:59 +08:00
feeeei f6eeed3d5a ui(users): 禁用/启用用户后原地更新该行
管理端用户列表点「禁用/启用」后会重新拉取整个列表:表格先换成骨架屏,
再重新请求用户列表与用量、属性、平台限额三份附属数据,滚动位置随之丢失,
连续处理多个用户时每点一次都像整页刷新。

- 请求成功后按 id 找到当前列表中的那一行,只回写 status 与 updated_at;
  更新接口的响应不含 current_concurrency、subscriptions 等列表专属字段,
  不能整行替换
- 更新返回时若有列表请求在途,该请求可能读到更新前的状态,此时重新拉取列表
- 按状态筛选时,被切换的行留在原地并显示新状态,可当场撤回误操作;
  总数在下次刷新前不变
- 新增三条用例:原地更新且不重载列表、请求失败时该行不变、并发列表请求时回退为重载
2026-09-19 18:14:27 +08:00
chihao.ou 3b0bb60ea3 fix(groups): require integer RPM overrides before adding 2026-09-19 17:56:42 +08:00
chihao.ou 26c09b7de6 fix(users): persist cleared custom attribute hints 2026-09-19 17:56:21 +08:00
chihao.ou 4d5a4ab0e5 fix(monitor): ignore stale template picker responses 2026-09-19 17:56:04 +08:00
chihao.ou 50f79e11fc fix(users): show errors when group replacement fails 2026-09-19 17:54:43 +08:00
chihao.ou 6b09c74e35 fix(users): keep numeric custom attribute values as strings 2026-09-19 17:54:19 +08:00
wucm667 b252821c59 fix(accounts): preserve refresh errors during usage queries 2026-09-19 17:22:39 +08:00
wucm667 68aa26477f feat(simple): make startup default group creation optional 2026-09-19 16:47:17 +08:00
Long LiandCursor 7e530c9d4e fix(openai): alias DeepSeek Responses input_image with url
Codex sends images as input_image.image_url. DeepSeek's live /responses
decoder requires a url field and returns 422 missing field url, including
on openai-platform accounts whose base_url host is api.deepseek.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 14:55:02 +09:00
github-actions[bot] 1a9d49e16f chore: sync VERSION to 0.2.7 [skip ci] 2026-09-19 04:29:03 +00:00
Wesley Liddick aea725f2ea Merge pull request #7247 from Qiaogun/codex/seedance-native-api
feat: 支持 Seedance / Ark 原生视频任务 API
2026-09-19 12:11:17 +08:00
Wesley Liddick 74431ffad8 Merge pull request #7258 from HuntercodeT/fix/antigravity-gemini-bare-model-thinking-variant
fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
2026-09-19 12:01:22 +08:00
Wesley Liddick 0c9e83b59c Merge pull request #7278 from HuntercodeT/fix/antigravity-gemini-sse-comment-keepalive
fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
2026-09-19 12:00:18 +08:00
Wesley Liddick 09f88865e0 Merge pull request #7294 from feeeei/feat/model-plaza-icon
ui(header): 手机竖屏顶栏保留模型广场图标入口
2026-09-19 11:59:46 +08:00
Wesley Liddick 1b45d21328 Merge pull request #7340 from ye4241/fix/kimi-cn-quota-403-rate-limit-pause
fix(ratelimit): CN coding-plan 账号配额耗尽的 403 按限流暂停调度,而非永久禁用
2026-09-19 11:59:21 +08:00
Wesley Liddick d5cea617a5 Merge pull request #7345 from laoxiechuzheng/fix/anthropic-root-union-schema
fix(apicompat): 摊平 Responses→Anthropic 工具 schema 顶层联合,修复 Codex 内置工具导致的 400
2026-09-19 11:59:00 +08:00
Wesley Liddick 4275047d16 Merge pull request #7313 from baryon/contrib/deepseek-chat-reasoning-content
fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
2026-09-19 11:57:55 +08:00
Wesley Liddick 4dbcdce43c Merge pull request #7351 from Wei-Shaw/feat/plugin-host-services-status-bridge
feat(plugin): generic host services + read-only status bridge channel
2026-09-19 11:57:41 +08:00
shaw 6477143533 test(plugin): check type assertions to satisfy errcheck lint 2026-09-19 11:47:14 +08:00
shaw c63bd14a02 feat(plugin): generic host services + read-only status bridge channel
Extend the local plugin system's host<->plugin contract with generic,
capability-neutral facilities, so transport plugins can be built on top
without embedding plugin-specific logic in the host.

Host services (plugin dials a broker-provided HostService gRPC):
- per-plugin namespaced KV store (key/value size and TTL bounded)
- account directory: ListAccounts + ResolveOutboundIdentity, restricted to
  the accounts a plugin's declared capability already covers
- InitHostServices lifecycle hook; plugins that don't implement it run
  unchanged and are simply offered no host services

Read-only runtime status channel:
- HealthResponse gains an opaque, plugin-defined status_json produced
  passively (no config apply, no upstream call, no side effects)
- GET /admin/plugins/:id/status (ungated) and the plugin.status UI-bridge
  verb map to Health, so a status UI can poll live state without a step-up
  prompt or a "test" side effect
- config.test no longer force-shows a host success toast; the plugin UI owns
  result presentation, so plugins that read status do not spam toasts

Document the UI-bridge verb taxonomy (read vs mutate/test gating) and the
status contract under pkg/pluginapi.
2026-09-19 11:25:56 +08:00
wucm667 2d37088bd9 fix(settings): preserve client version on invalid user agent 2026-09-19 10:36:51 +08:00
laoxiechuzheng a8a1a3a958 fix(apicompat): flatten root union tool schemas for Anthropic
Codex 会把 codex_app 等内置工具的 parameters 根节点声明成对象分支的 oneOf/anyOf,Anthropic 拒绝工具 input_schema 顶层的 oneOf/anyOf/allOf,导致 /v1/responses -> /v1/messages 直接 400。

Responses->Anthropic 转换时摊平顶层联合:属性取各分支并集,同名属性用嵌套联合保留两侧约束,required 在 oneOf/anyOf 下取分支交集、allOf 下取并集;嵌套联合保持不变。
2026-09-19 07:34:21 +08:00
Rain 4ccdead2a2 fix(proxies): skip inactive fallback targets 2026-09-19 00:51:01 +08:00
Rain f7280213f7 fix(proxies): skip expiry writes from stale snapshots 2026-09-19 00:51:01 +08:00
ye4241andClaude Opus 4.8 db8692d679 fix(ratelimit): pause CN coding-plan accounts on quota-exhausted 403
Kimi reports Coding Plan window exhaustion (5h/weekly) as a 403 with
error.type=access_terminated_error instead of 429. It fell into the
generic handleOpenAI403 escalation, so three 403s within 180 minutes
permanently disabled accounts that recover by themselves at the window
reset.

Detect the quota-exhausted 403 for CN coding-plan accounts and cool it
down exactly like the 429 path: SetRateLimited until the earliest future
window reset from the quota snapshot, falling back to a short
temp-unschedulable window when the snapshot is missing. Non-coding-plan
accounts keep the generic 403 escalation since the rolling-window
recovery mechanism does not apply to them.

Also extract cooldownCNProviderToQuotaSnapshotReset and
setCNProviderTempUnschedulable so the 429 (OpenCodeGo / Coding Plan)
and 403 branches stop carrying three copies of the same sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-19 00:00:57 +08:00
superman2003 37238c0985 fix(openai): honor error.status when classifying in-stream failures
OpenAI 兼容上游(含二级中转)常把流内 error / response.failed 事件的状态码写在
error.status 上,而不是 OpenAI 使用的 error.status_code。SSE 路径只读
status_code,于是这些上游的 401/403/429/529 全部被降级成通用 502:认证失败不
再判定为凭据故障、429 不再标记限流与配额窗口、error 帧丢掉 401/429/529 的
failover 快路径,运维日志也记成 502。WS 路径的 openAIWSPayloadTransientStatus
早已同时读这两种拼写,SSE 路径一直没跟上。

把状态码路径集合抽成共享常量,让 SSE 路径与 WS 路径对齐。5xx 仍按通用上游故障
处理,不改动既有的账号状态语义。
2026-09-18 23:51:08 +08:00
chihao.ou 7b282c4f1d fix(announcements): ignore superseded fetch results 2026-09-18 16:39:25 +08:00
chihao.ou d054ee9af3 fix(ccswitch): trim trailing slashes before Antigravity path 2026-09-18 16:38:58 +08:00
chihao.ou 9612a70c77 fix(proxies): label newly expired proxies correctly 2026-09-18 16:38:29 +08:00
chihao.ou 24f4736f64 fix(upload): cancel superseded image reads 2026-09-18 16:37:56 +08:00
chihao.ou 9d5c8d961c fix(dialog): keep body locked while another dialog is open 2026-09-18 16:37:03 +08:00
wucm667 7b4de8b6ae fix(moderation): prevent reminder tags from bypassing keyword checks 2026-09-18 14:54:18 +08:00
Long LiandCursor bcc73f8d40 fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
Codex histories often replay encrypted-only reasoning items. After a
Responses→Chat Completions fallback, a cache miss left assistant
messages without reasoning_content and DeepSeek rejected the turn
with 400. Fill empty assistant fields with a single-space placeholder
for DeepSeek hosts, including openai-platform accounts whose base_url
points at api.deepseek.com. Existing plaintext and cache hits are
left unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:42:22 +09:00
Yoga Sakti 4805f069e4 fix(scheduler): keep account RPM config in the scheduler projection
`filterSchedulerExtra` 的白名单没有 `base_rpm` / `rpm_strategy` /
`rpm_sticky_buffer`,写进 `sched:meta:<id>` 的精简投影因此丢掉整套 RPM 配置。
候选过滤走 `ListSchedulableAccounts`,缓存命中时读的正是这份投影:
`isAccountSchedulableForRPM` 拿到的账号上 `GetBaseRPM()` 返回 0,`baseRPM <= 0`
直接放行,已配置限流的账号在选号阶段被当作未开启 RPM,单个自然分钟内被超额调度。
缓存未命中时回退数据库读到完整 extra,所以现象是间歇性的。

选中的账号随后由 `hydrateSelectedAccount` 从 `sched:acc:<id>` 重新水合,
RPM 计数与用户消息队列限速读的是完整账号,不受影响;缺的只是选号阶段这道门。

修法沿用投影里既有的处理方式(见 `openai_passthrough` 一组注释):把判定依赖的
键补进白名单,而不是在判定侧另开一条读取完整账号的路径。

回归测试覆盖序列化往返后的三个键:`tiered` 子用例证明缓冲被裁掉时红区会被误判成
黄区,`sticky_exempt` 子用例证明策略被裁掉时会退回三区判定。

Refs #7225
2026-09-18 13:37:58 +07:00
HuntercodeT cfd2fc779b fix(codex-models): 非 GPT 模型的 Codex 提示词模板去掉 GPT 身份声明,避免 Antigravity 429
模型目录对所有模型都用 openai.CodexBaseInstructionsForModel 填 instructions_template,
gemini / claude 等因此下发 "You are Codex, a coding agent based on GPT-5."。Codex 把它作为
instructions 发出,Antigravity 对「Codex 提示词 + GPT-5 身份」返回 429 RESOURCE_EXHAUSTED,
并让账号进入限流冷却。

OpenAI GPT 模型保持原模板;其他模型族(含 gpt-oss)去掉 "based on GPT-N" /
"You are GPT-N" 身份声明,提示词其余部分不变。

Refs #7307
2026-09-18 14:21:21 +08:00
wucm667 0920e06a42 fix(gateway): restore public response model aliases 2026-09-18 13:31:58 +08:00
little-greenbean 3fdd54ca12 fix(upstream): cancel response attempt before closing body 2026-09-18 13:30:35 +08:00
lizhaoxinlizhaoxin2-lab cf10d6d01d fix(responses): emit protocol-correct stream errors once 2026-09-18 12:38:05 +08:00
feeeei a9c7c6e8bf ui(header): 手机竖屏顶栏保留模型广场图标入口
模型广场入口所有宽度都渲染,<640px 只保留图标,补 title/aria-label
2026-09-18 09:52:40 +08:00
HuntercodeT c12d0131cb fix(gateway): list mixed antigravity models for gemini groups 2026-09-18 09:03:41 +08:00
HuntercodeT 0f4d8acaa8 fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
Antigravity CLI / go-genai 发裸 Gemini 模型名(gemini-3.8-flash)并用
generationConfig.thinkingConfig 表达思考深度,而 Antigravity 上游目录只有
-low/-medium/-high/-tiered 变体。v0.2.5 起 resolveModelMapping 会给 antigravity
账号运行时补 gemini-3.x-flash 裸名自映射,裸名因此被当成显式透传直发上游,
上游回 404 "Requested entity was not found."。

在 ForwardGemini 取映射之前加 resolveGeminiThinkingVariant:
- 仅对 gemini- 前缀且不带后缀的裸名生效;
- 用户在 credentials.model_mapping 亲写的裸名条目一律不介入,运行时注入的
  默认自映射不算用户意图;
- thinkingLevel 优先;否则 thinkingBudget <0→high,≤1024→low,≤8192→medium,
  其余 high;未携带 thinkingConfig→high;所选变体不存在时按
  high→medium→low→tiered 降级;一个变体都没有则回退原逻辑;
- thinkingConfig 仍原样透传,上游按 budget 决定实际思考量。
2026-09-18 09:03:40 +08:00
wucm667 94225b5ed3 fix(admin): align platform quota editor with supported platforms 2026-09-18 08:42:21 +08:00
feeeei a9ff663387 feat(backup): 支持月度归档与独立保留策略 2026-09-18 06:56:27 +08:00
feeeei 57b7dbdc85 feat(affiliate): 提取记录支持登记线下提现
管理员在站外给邀请人打款后,系统里原本无法扣减对应的返利额度,用户仍可把
这部分额度转入余额。现在可在「邀请返利 → 提取记录」点「登记线下提现」,
从该用户的可提取返利额度中扣除已打款金额:

- POST /api/v1/admin/affiliates/users/:user_id/withdraw,请求体只含 amount,
  金额按 8 位小数舍入后校验
- 先解冻已到期的冻结额度,再以 aff_quota >= amount 为条件原子扣减,写入
  action=withdraw 的流水并带额度快照;额度不足返回 AFFILIATE_QUOTA_INSUFFICIENT
- 不新增字段与迁移:线下提现复用已有 action 列区分,操作人由管理端审计日志记录
- 提取记录同时列出转入余额与线下提现,新增「类型」列
- 弹窗选择用户后展示可提取额度,支持一键填入全部;从未产生返利的用户按 0 处理

返利记录改为 LEFT JOIN 订单与被邀请人,兑换码、管理员充值来源的返利也会出现
在明细中,接口里订单相关字段与被邀请人 ID 可为 null。
2026-09-17 23:42:02 +08:00
HuntercodeT f79b8bf96c fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
Antigravity 账号的 Gemini 原生流式路径在上游空闲超过 gateway.stream_keepalive_interval
(默认 10s)时会向下游写 ":\n\n" 作为心跳。SSE 规范允许注释行,但 Google 官方 SDK 里
go-genai 与 python-genai 都不会忽略它:

- go-genai(Antigravity CLI 在用)api_client.go iterateResponseStream 对任何非 data: 前缀的
  事件直接返回 "iterateResponseStream: invalid stream chunk: :",整个流中断;
- python-genai _api_client.py 把非 data: 行当作错误 JSON 拼接后 json.loads,同样抛错。

Google 官方 API 从不下发注释行,这两个 SDK 从未处理过这种输入。js-genai 用规范的
event-stream 解析器,不受影响。

现在按下游 User-Agent / X-Goog-Api-Client 识别这两类 SDK(形如
google-genai-sdk/1.71.0 gl-go/go1.28 …、google-genai-sdk/x gl-python/3.12),命中时该路径
不再发注释心跳;其他客户端(curl、js-genai、Gemini CLI)行为不变。

生产复现:Antigravity CLI 1.2.5 经网关(sub2api 0.2.5)跑长思考请求,上游首个 chunk 超过 10s
时客户端报 "agent executor error: … iterateResponseStream: invalid stream chunk: :"。
2026-09-17 19:41:55 +08:00
cyh e74c690ac4 fix(openai): keep transport heartbeats outside semantic output 2026-09-17 19:15:09 +08:00
Wesley Liddick efe9aab1e4 Merge pull request #7246 from laoxiechuzheng/fix/deepseek-native-responses-tool-output-media
fix(deepseek): lift Responses tool output images
2026-09-17 17:02:43 +08:00
zoutao 0952ce3410 fix(apicompat): include streamed arguments in function_call_arguments.done
Gemini/Antigravity delivers tool arguments as one complete object, so this
converter synthesizes the Responses tool-call stream itself and therefore owns
its self-consistency. The done event was built without Arguments, leaving it ""
while the preceding delta carried the entire JSON. Clients that reconcile the
done payload against the accumulated deltas (OpenAI Responses SDK); Qoder's
reconcileToolCall) reject that mismatch as inconsistent_tool_call and abort the
turn non-retryably, so every tool call through the gateway failed.

Emit state.CurrentArgs, which closeCurrentResponsesItem already uses for the
item, restoring sum(deltas) == done.arguments == item.arguments.

Regression test asserts the delta sum equals the done payload and fails closed
when Arguments is dropped.
2026-09-17 16:54:44 +08:00
Cai d099c06945 fix: resolve Seedance lint and gRPC security checks 2026-09-17 17:51:03 +09:00
Wesley Liddick aeab57b9b2 Merge pull request #7256 from zhudaguaneren/fix/antigravity-attribution-429
fix(antigravity): 过滤 Claude attribution 元数据,修复特定请求的 429
2026-09-17 16:44:09 +08:00
Wesley Liddick 18328aed05 Merge pull request #7212 from wucm667/feat/issue-7203-redemption-pagination
feat(redeem): paginate user redemption history
2026-09-17 16:43:06 +08:00
Wesley Liddick 48a86ec926 Merge pull request #7224 from HypoxanthineOvO/codex/upstream-chat-role-compat
fix(gateway): normalize developer roles for strict native Chat upstreams
2026-09-17 16:42:31 +08:00
Wesley Liddick b4e560824a Merge pull request #7177 from xO-ox-ai/codex/fix-openai-response-binding-context
fix(openai): 客户端断开后仍持久化 Responses 续聊绑定
2026-09-17 16:42:12 +08:00
Wesley Liddick 9efc167482 Merge pull request #7195 from bitnom/fix/refresh-paused-oauth-accounts
fix(account): keep refreshing OAuth tokens for paused accounts
2026-09-17 16:41:54 +08:00
Wesley Liddick 5050471822 Merge pull request #7239 from Liu8Can/fix/register-promo-code-flicker
fix: prevent promo code field flash on registration
2026-09-17 16:41:35 +08:00
Wesley Liddick c84a3d50a3 Merge pull request #7215 from xz-dev/fix/manifest-double-parse
fix(openai): parse Codex models manifest envelope once
2026-09-17 16:41:12 +08:00
Wesley Liddick cd1834e0a9 Merge pull request #7262 from ouchihao/codex/bug-refund-balance-warning
fix(payment): base refund balance warnings on the requested amount
2026-09-17 16:41:00 +08:00
Wesley Liddick 0932e8ce0a Merge pull request #7266 from ouchihao/codex/bug-subscription-loading-reset
fix(subscriptions): reset loading when clearing state
2026-09-17 16:40:44 +08:00
Wesley Liddick ffa43607e5 Merge pull request #7236 from ouchihao/codex/bug-payment-amount-input
fix(payment): restore accepted text after invalid amount input
2026-09-17 16:40:33 +08:00
Wesley Liddick 57e9f6a4b8 Merge pull request #7238 from ouchihao/codex/bug-model-tag-tab-navigation
fix(channels): allow tabbing out of an empty model input
2026-09-17 16:40:23 +08:00
Wesley Liddick 71c0eb5126 Merge pull request #7184 from ouchihao/codex/bug-totp-error-messages
fix(totp): display normalized API error messages
2026-09-17 16:40:07 +08:00
Wesley Liddick 10fd1ada28 Merge pull request #7182 from ouchihao/codex/bug-orders-filter-page
fix(payment): reset the order page when changing status
2026-09-17 16:39:53 +08:00
Wesley Liddick 53fd5f8351 Merge pull request #7183 from ouchihao/codex/bug-proxy-test-dedup
fix(proxies): reuse in-flight test guards during batch testing
2026-09-17 16:39:34 +08:00
Wesley Liddick 55bde0857f Merge pull request #7185 from ouchihao/codex/bug-announcement-read-results
fix(announcements): preserve successful bulk read results
2026-09-17 16:39:16 +08:00
Wesley Liddick 5f75e0d2a7 Merge pull request #7186 from ouchihao/codex/bug-clipboard-fallback-error
fix(clipboard): report errors thrown by the copy fallback
2026-09-17 16:38:55 +08:00
Wesley Liddick efd86c63e6 Merge pull request #7235 from ouchihao/codex/bug-totp-setup-input-sync
fix(auth): keep TOTP setup digits in sync with state
2026-09-17 16:38:37 +08:00
Wesley Liddick ea68c0e82c Merge pull request #7263 from ouchihao/codex/bug-dialog-title-ids
fix(ui): keep dialog title IDs unique across instances
2026-09-17 16:38:16 +08:00
Wesley Liddick 40e7ce7fbf Merge pull request #7265 from ouchihao/codex/bug-payment-config-await
fix(payment): await in-flight configuration requests
2026-09-17 16:37:54 +08:00
Wesley Liddick d52ab27def Merge pull request #7173 from HuntercodeT/fix/gemini-native-mixed-model-listing
fix(gemini): discover Antigravity mappings in native model lists
2026-09-17 16:37:38 +08:00
Wesley Liddick 7e4164c1cf Merge pull request #7207 from HuntercodeT/fix/group-usage-summary-seq-scan
fix: 分组用量汇总不再全表扫 usage_logs(48.7s → 34ms)
2026-09-17 16:37:21 +08:00
Wesley Liddick 44831bb854 Merge pull request #7261 from HuntercodeT/chore/bump-grpc-1.83.2
chore(deps): 升级 google.golang.org/grpc 到 v1.83.2,修复 govulncheck 报告的 GO-2026-6443 / GO-2026-6348
2026-09-17 16:36:57 +08:00
Wesley Liddick 131893e449 Merge pull request #7237 from ouchihao/codex/bug-platform-quota-negative
fix(admin): reject negative platform quota limits before saving
2026-09-17 16:36:34 +08:00
Wesley Liddick 4daabc3d84 Merge pull request #7234 from ouchihao/codex/bug-pagination-jump-number
fix(pagination): handle numeric jump input
2026-09-17 16:36:23 +08:00
laoxiechuzheng acc05620cb fix(deepseek): keep parallel Responses tool outputs contiguous 2026-09-17 15:45:56 +08:00
chihao.ou 406be7c518 fix(subscriptions): reset loading when clearing state 2026-09-17 14:59:43 +08:00
chihao.ou b51f0759d4 fix(payment): await in-flight configuration requests 2026-09-17 14:58:46 +08:00
chihao.ou be313735dc fix(ui): keep dialog title IDs unique across instances 2026-09-17 14:56:58 +08:00
chihao.ou 017e9e98d1 fix(payment): base refund balance warnings on the requested amount 2026-09-17 14:56:33 +08:00
HuntercodeT 611c30f04f chore(deps): 升级 google.golang.org/grpc 到 v1.83.2 修复 govulncheck 报告的两条公告
Security Scan / backend-security 自 2026-09-16 起在所有 PR 上失败,govulncheck 对
google.golang.org/grpc v1.82.1 报:
- GO-2026-6443 Server panic via missing authority or Host headers(修复于 v1.83.2)
- GO-2026-6348 Heap Memory Exhaustion via HTTP/2 DATA Frame Fragmentation(修复于 v1.83.1)
调用链在 pkg/pluginapi/v1/runtime.go 与 internal/service/plugin_runtime.go。

go get google.golang.org/grpc@v1.83.2 && go mod tidy。最小版本选择连带抬升了
golang.org/x/{crypto,mod,net,sync,term,sys,text,tools}、genproto/googleapis/rpc 与
go.opentelemetry.io/otel* 的间接依赖,均为补丁级别。
2026-09-17 14:33:48 +08:00
zhudaguaneren be4a4990ff fix(antigravity): strip Claude attribution metadata from system prompts 2026-09-17 11:57:53 +08:00
Cai aba34524f1 feat: support native Seedance Ark video task API 2026-09-17 04:14:14 +09:00
laoxiechuzheng 881ab1b0c6 fix(deepseek): lift Responses tool output images
DeepSeek native Responses rejects Codex view_image results when function_call_output.output contains input_image parts. Move media into a following user message and keep the tool output as a string so call_id pairing remains valid.
2026-09-17 01:22:22 +08:00
DanielSong fe36f4a914 fix: prevent promo code field flash on registration 2026-09-16 18:34:52 +08:00
chihao.ou f8e5a0d94f fix(channels): allow tabbing out of an empty model input 2026-09-16 18:19:41 +08:00
chihao.ou 0838e0e610 fix(admin): reject negative platform quota limits before saving 2026-09-16 18:17:29 +08:00
chihao.ou 98321a054e fix(payment): restore accepted text after invalid amount input 2026-09-16 18:17:14 +08:00
chihao.ou ee9ac3e45f fix(auth): keep TOTP setup digits in sync with state 2026-09-16 18:15:28 +08:00
chihao.ou 1a32b91eb2 fix(pagination): handle numeric jump input 2026-09-16 18:15:12 +08:00
HypoxanthineOvO 18bfa4bf20 fix(gateway): normalize developer roles for strict Chat upstreams 2026-09-16 16:11:43 +08:00
Xiangzhe 31f3003ff5 fix(openai): preserve manifest key validation 2026-09-16 06:15:31 +00:00
Xiangzhe 2f16e0984e fix(openai): avoid duplicate manifest parsing 2026-09-16 06:15:25 +00:00
wucm667 a9ed218989 feat(redeem): paginate user redemption history 2026-09-16 13:19:15 +08:00
HuntercodeT 18d483c2a1 fix: 分组用量汇总不再全表扫 usage_logs
GET /api/v1/admin/groups/usage-summary 在有一定数据量的库上会退化成
usage_logs 全表扫。根因是 getAllGroupUsageSummaryFromRollups 里,tail CTE
的 created_at 下界 state.tail_start 由同一条 SQL 的 state CTE 算出、再用
CROSS JOIN 交给 tail:这个值对 planner 来说要到运行期才知道,既进不了
index cond,也估不准选择性,于是只能顺序扫全表再逐行过滤。

生产实测(1721 万行 usage_logs / PostgreSQL 17):

    Nested Loop  (actual time=28161..48334 rows=34788)
      Join Filter: (ul.created_at >= state_1.tail_start)
      Rows Removed by Join Filter: 17178082
      ->  Seq Scan on usage_logs  (actual time=0.030..47567 rows=17212870)
          Buffers: shared hit=12863 read=951875
    Execution Time: 48720 ms

改成先读一次水位行、在 Go 侧算出 tail_start / closed_before / retained
日期,再把它们当查询参数传进来。同一套数据上:

    Index Scan using idx_usage_logs_created_at on usage_logs
      Index Cond: (created_at >= $7)
    Execution Time: 34 ms

水位有效性的判定(恰好一行、时区名与配置一致、水位不在未来)和无效时
退化成从 epoch 全量重算的行为,与原先 SQL 里 state_values / CASE WHEN
valid 的分支逐条对应,结果口径不变。

另外读取水位的语句不加锁,不参与 #6976 那条 FOR UPDATE / FOR KEY SHARE
的阻塞链。
2026-09-16 10:11:18 +08:00
bitnom 8e34ca5e31 fix(account): keep refreshing OAuth tokens for paused accounts
Paused (schedulable=false) active accounts were excluded from the
background token refresh candidates, so their stored access_token
silently expired. The admin usage-window quota probe uses the stored
token verbatim (no token provider / no refresh), so the probe then gets
HTTP 401 UNAUTHENTICATED and the dashboard reports a false "needs
re-auth" for a perfectly healthy account, while Test Connection and live
traffic (which refresh on demand via the token provider) keep working.

Pause is not permanent rejection: error accounts are already excluded by
the status = 'active' filter, and accounts whose refresh genuinely fails
are rate-limited by the retry-cooldown exclusion (10-minute retry-
exhausted cooldown). Refresh success only persists credentials and never
touches schedulable, so a paused account cannot be silently un-paused.

Drop the schedulable filter and update the SQL-filter test, the
integration pagination test, and the service-level candidate fake.
2026-09-15 10:00:26 -04:00
chihao.ou 130ba634ad fix(clipboard): report errors thrown by the copy fallback 2026-09-15 20:08:45 +08:00
chihao.ou 6a4938bdfb fix(announcements): preserve successful bulk read results 2026-09-15 20:06:33 +08:00
chihao.ou 14636d2f0e fix(totp): display normalized API error messages 2026-09-15 20:06:18 +08:00
chihao.ou 0ed735d3ba fix(proxies): reuse in-flight test guards during batch testing 2026-09-15 20:04:49 +08:00
chihao.ou 21532add46 fix(payment): reset the order page when changing status 2026-09-15 20:04:35 +08:00
xO-ox-ai d7ee1ab6be fix(openai): persist response affinity after client cancel 2026-09-15 18:44:50 +08:00
github-actions[bot] 881f320269 chore: sync VERSION to 0.2.5 [skip ci] 2026-09-15 09:02:51 +00:00
HuntercodeT da74bf13a4 fix(gemini): discover antigravity mappings in native model lists 2026-09-15 16:50:46 +08:00
Wesley Liddick 86f93c28ee Merge pull request #6977 from alfadb/fix/deepseek-model-name-validation
fix(deepseek): 未配置 model_mapping 时按官方模型名单校验,不再透传未知模型名
2026-09-15 16:20:31 +08:00
Wesley Liddick 30ed40a56a Merge pull request #7074 from Brisbanehuang/codex/fix-codex-quota-windows
fix(scheduler): 按规范 5h/7d 窗口读取 Codex 配额与重置时间
2026-09-15 16:17:37 +08:00
Wesley Liddick b8275209e8 Merge pull request #7162 from HuntercodeT/fix/antigravity-gemini-sse-separator
fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行(Antigravity CLI invalid stream chunk)
2026-09-15 16:17:25 +08:00
郭兰鑫andClaude Code 1e231ee2ae fix(antigravity): resolve golangci-lint errors in schema cleaner tests
- Replace unchecked single-value type assertions with comma-ok form
  guarded by require.True (errcheck check-type-assertions)
- Remove trailing blank line flagged by gofmt

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:01:30 +08:00
Wesley Liddick 25c21aedda Merge pull request #7129 from kk-418/fix/grok-build-responses-sequence-number
fix(responses): 始终在 Responses SSE 中写出 sequence_number 避免反序列化失败
2026-09-15 15:28:33 +08:00
Wesley Liddick 2d9af8592f Merge pull request #7153 from hezhaoqian1/codex/upstream-api-key-provider-filter
feat: filter API key groups by provider
2026-09-15 15:26:29 +08:00
HuntercodeT 8447bdd368 fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行
handleGeminiStreamingResponse 把上游每条 data 行改写成 "data: ...\n\n",
紧接着又把上游的空分隔行原样透传成第三个 "\n",下游看到的事件之间是
"\n\n\n"。google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,
多出的 "\n" 粘到下一个事件开头,前缀变成 "\ndata" 而报
"iterateResponseStream: invalid stream chunk",每条流都在第二个事件中断。

空分隔行不再透传(data 分支已经写过 "\n\n"),附回归测试:两个事件
(LF 与 CRLF 各一)经过后输出必须恰好是一个空行分隔,并按 "\n\n" 切帧
逐个校验前缀。

Fixes #7161
2026-09-15 15:10:45 +08:00
haruka 4f6f206458 docs: add rolling log retention settings screenshot 2026-09-15 14:23:27 +08:00
haruka 6582260bc8 feat: add configurable rolling log retention 2026-09-15 14:21:15 +08:00
shaw 32682a4f84 chore: update sponsors 2026-09-15 14:01:28 +08:00
hezhaoqian1 55a95d4c67 feat: filter API key groups by provider 2026-09-15 11:16:17 +08:00
alfadb a939553c87 fix(deepseek): 模型名校验不再默认兜底,未配置映射时按官方名单校验
- 未配置 model_mapping 的 DeepSeek 账号不再放行任意模型名,改为官方名单精确匹配
  (deepseek-flash / deepseek-v4-pro / deepseek-v4-flash /
   deepseek-v4-flash-vision-exp / deepseek-v4-pro-0813)
- 校验前先归一化:剥离 Claude Code 客户端上下文后缀 [1m](含重复形式)并统一小写,
  使官方 Claude Code 接入写法 deepseek-flash[1m] 继续可用
- 未知模型名在调度阶段即拒绝,返回 404 model_not_found,避免透传上游后触发
  per-(账号,模型) 冷却、并避免上游把不认识的模型名静默兜底成 deepseek-flash
- 出站前剥离泄漏的 [1m] 后缀,仅作用于 DeepSeek 平台
- 配置了 model_mapping 的账号语义不变(映射优先)
2026-09-15 10:42:29 +08:00
Wesley Liddick badfad8b72 Merge pull request #6257 from 88766lawrence/fix/oauth-promo-code-6216
fix(auth): preserve promo codes through OAuth registration
2026-09-15 09:01:39 +08:00
Wesley Liddick 1be482c313 Merge pull request #7148 from Wei-Shaw/revert-7110-codex/bug-batch-image-access-session
Revert "fix(batch-image): scope access cache to the current user"
2026-09-15 09:00:23 +08:00
Wesley Liddick 325213ae66 Revert "fix(batch-image): scope access cache to the current user" 2026-09-15 08:59:52 +08:00
Wesley Liddick 0a215c9318 Merge pull request #7110 from ouchihao/codex/bug-batch-image-access-session
fix(batch-image): scope access cache to the current user
2026-09-15 08:59:23 +08:00
Wesley Liddick 2061508ad8 Merge pull request #7072 from Brisbanehuang/codex/fix-scheduler-metrics
fix(scheduler): 修复选号耗时返回值和粘性命中率重复计数
2026-09-15 08:58:58 +08:00
Wesley Liddick fb90414c05 Merge pull request #7126 from xO-ox-ai/codex/fix-responses-lite-namespace
fix(openai): 保留 Responses Lite namespace 工具调用
2026-09-15 08:58:25 +08:00
Wesley Liddick 9fade3e8c2 Merge pull request #6769 from alfadb/feature/ollama-rate-limit-reset
feat(ollama): 根据用量窗口异步更新限流恢复时间
2026-09-15 08:57:43 +08:00
Wesley Liddick 1920a7d687 Merge pull request #7091 from SavitarC/feat/subscription-bulk-actions
feat: 支持订阅管理批量操作
2026-09-15 08:57:27 +08:00
Wesley Liddick a444551a6d Merge pull request #6654 from ouchihao/codex/issue-2229-register-confirm-password
feat(auth): require password confirmation during registration
2026-09-15 08:56:56 +08:00
Wesley Liddick 0bba06d64a Merge pull request #6691 from yan9651688/feat/api-key-bulk-edit
feat(keys): add bulk editing to API key management
2026-09-15 08:56:07 +08:00
Wesley Liddick 150c52b8de Merge pull request #6689 from tunsuy/fix/antigravity-codex-web-search-mixed-tools
fix(antigravity): drop mixed web_search when client tools present
2026-09-15 08:54:40 +08:00
Wesley Liddick 3e4b6a09b2 Merge pull request #6925 from reed-yang/fix/recover-responses-text
fix(apicompat): recover Responses text from done and terminal events
2026-09-15 08:54:21 +08:00
Wesley Liddick dd2e6b3680 Merge pull request #7076 from Shen-An/fix/antigravity-refresh-warning
fix(admin): surface partial Antigravity refresh warnings
2026-09-15 08:54:06 +08:00
Wesley Liddick d4439129a7 Merge pull request #7094 from liuyuzhou666/fix/responses-chat-developer-role-mapping
fix(apicompat): 修复 Responses 降级 Chat Completions 时的 system 消息顺序问题
2026-09-15 08:53:50 +08:00
Wesley Liddick 1e1d15cca5 Merge pull request #7082 from ggllzz04/fix/antigravity-token-cache-isolation
fix(antigravity): isolate OAuth token cache key by account ID to prevent 429 collision
2026-09-15 08:53:32 +08:00
Wesley Liddick f7e959ae5f Merge pull request #7111 from ouchihao/codex/bug-proxy-clear-credentials
fix(proxy): allow explicitly clearing stored credentials
2026-09-15 08:52:58 +08:00
Wesley Liddick 008391f699 Merge pull request #7085 from r266-tech/codex/sub2api-f352188cb784
fix(openai): strip internal metadata from OAuth input items
2026-09-15 08:52:30 +08:00
Wesley Liddick a4c517917b Merge pull request #7073 from Brisbanehuang/codex/fix-batch-image-priority
fix(batch-image): 修正批量生图账号优先级排序方向
2026-09-15 08:52:19 +08:00
Wesley Liddick e9169901d2 Merge pull request #7112 from ouchihao/codex/bug-monitor-refresh-interval
fix(monitor): honor the selected auto-refresh interval
2026-09-15 08:52:00 +08:00
chihao.ou 0e1329b520 fix(ui): reset select highlight when search results change 2026-09-14 21:28:59 +08:00
HPnetwork 29bce46a2a fix: keep API key accounts available for unknown models 2026-09-14 05:56:59 -04:00
xO-ox-ai 13c1d4cde2 test(openai): 覆盖 Lite 命名空间工具回传 2026-09-14 11:46:41 +08:00
cikenerdandCursor 087987070b fix(responses): 为 grok-build 始终写出 sequence_number
网关合成/重编的 Responses SSE 因 omitempty 或缺字段丢掉 sequence_number,
grok-build 会整轮反序列化失败。合成失败帧、compact 桥接和转换 wire JSON
一律写出该字段,0 也保留。

Fixes #7128

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:45:53 +08:00
xO-ox-ai 4bd7132ba9 fix(openai): 保留 Responses Lite namespace 调用 2026-09-14 11:35:03 +08:00
chihao.ou 28a4ea914d fix(proxy): allow explicitly clearing stored credentials 2026-09-13 17:23:24 +08:00
chihao.ou 5ecfaa4837 fix(batch-image): scope access cache to the current user 2026-09-13 17:22:56 +08:00
chihao.ou 06af9f871c fix(monitor): honor the selected auto-refresh interval 2026-09-13 17:20:56 +08:00
chihao.ou 7dace2fecb fix(ui): discard unapplied date ranges on dismissal 2026-09-13 17:18:15 +08:00
chihao.ou a985de062c fix(ui): focus non-searchable select dropdowns 2026-09-13 17:16:26 +08:00
MHJoy99 7511e4de59 fix(streaming): drop Anthropic ping keepalives before OpenAI conversion
Upstream Anthropic keepalive frames (event: ping) leaked into OpenAI
Chat Completions SSE streams, crashing strict clients (Cursor, Hermes,
OpenAI SDKs) on unexpected event types. Error events still forward.

Fixes #5203
2026-09-13 13:30:50 +06:00
liuyuzhou666 52558381c6 fix(apicompat): merge leading system messages and downgrade mid-conversation system from Responses bridge
The Responses to Chat Completions bridge mapped both the instructions field and role developer items to system messages. Codex always sends both, and also injects developer notices mid-conversation when switching models, so the forwarded request contained consecutive leading system messages and mid-conversation system messages. Strict Qwen-family upstreams reject this with HTTP 400 "System message must be at the beginning."

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

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

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

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

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

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

Closes #4825

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    pq: column groups.model_allowlist does not exist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Regression tests reproduce unreleased account slots on upstream ab99d56e9
and pass with this change. Validation: go test ./..., unit and integration
suites, focused race tests, backend build, and golangci-lint (0 issues).
2026-09-05 20:25:08 +08:00
Zero Clover cb31033972 feat(openai): reuse pinned accounts for standard model discovery 2026-09-05 19:58:47 +08:00
chihao.ou 3aeab296d8 fix(onboarding): remove button text shadows 2026-09-05 19:55:03 +08:00
Zero Clover f3bbb95310 fix(groups): react immediately to Codex manifest edits and unify dialog toggles 2026-09-05 19:46:25 +08:00
chihao.ou 00eabe8ab9 feat(accounts): add month and year expiry presets 2026-09-05 19:20:10 +08:00
chihao.ou 017cc62e41 feat(auth): require password confirmation during registration 2026-09-05 19:19:35 +08:00
chihao.ou a16070ccf6 fix(auth): show a loading placeholder for Turnstile 2026-09-05 18:25:51 +08:00
github-actions[bot] ab99d56e96 chore: sync VERSION to 0.2.1 [skip ci] 2026-09-05 09:45:17 +00:00
Robert Guo b1ce821c49 fix(frontend): preserve Codex manifest edit state 2026-09-05 17:18:01 +08:00
Wesley Liddick 578785ee7f Merge pull request #6640 from cyhhao/fix/claude-billing-fingerprint
fix(claude): align billing fingerprint with outbound User-Agent
2026-09-05 16:59:33 +08:00
Wesley Liddick ed7c8f2208 Merge pull request #6572 from lyy0709/codex/gpt-6-astra
feat(openai): complete GPT-6 Astra support
2026-09-05 16:50:40 +08:00
cyh 2e1c7c00a6 test(claude): check request body close errors 2026-09-05 16:43:54 +08:00
cyh 994ca26e92 fix(claude): align billing fingerprint with outbound user agent 2026-09-05 16:34:00 +08:00
Wesley Liddick d2f87c612e Merge pull request #6638 from feeeei/feat/images-url-to-b64json-backfill
feat(images): 账户开关将生图结果 url 下载回填为 b64_json
2026-09-05 16:24:40 +08:00
lyy0709 62cd63bfda fix(openai): retain capabilities when model listing is unavailable 2026-09-05 16:24:15 +08:00
lyy0709 dee35a841d merge: reconcile Astra fixes with upstream capability sync 2026-09-05 16:19:57 +08:00
Wesley Liddick fb44e2dd09 Merge pull request #6514 from StarryKira/codex/anthropic-reasoning-pricing
feat: add Anthropic reasoning effort and Fable 5.1 max pricing
2026-09-05 16:19:18 +08:00
Wesley Liddick c42d78e26c Merge pull request #6571 from DuskLin/feat/codex-ultrafast-tier
feat(codex): support ultrafast service tier
2026-09-05 16:18:16 +08:00
Wesley Liddick 2dc287f2e0 Merge pull request #6636 from specialpointcentral/cyber-ws-recording-fix
fix(openai): record WS cyber policy failures across paths
2026-09-05 16:17:33 +08:00
Wesley Liddick 83094abf21 Merge pull request #6536 from wucm667/fix/issue-6526-mapped-model-scheduling
fix(openai): schedule accounts with mapped model
2026-09-05 16:17:04 +08:00
Wesley Liddick aafe93b335 Merge pull request #6557 from Ritel-T/pr/compact-admin-account-list
perf(admin): return compact account lists for lite requests
2026-09-05 16:16:26 +08:00
Wesley Liddick cc52c93ad7 Merge pull request #6539 from creamtea47/codex/fix-delegation-bootstrap-resume
fix(openai): 兼容历史任务委派续聊
2026-09-05 16:16:00 +08:00
lyy0709 d7f048a265 fix(openai): preserve synced Astra capabilities and continuation 2026-09-05 16:13:28 +08:00
Zero Clover 55c5eed9fc test(repository): persist model allowlists in group fixtures 2026-09-05 16:11:46 +08:00
Wesley Liddick 0d9e7e1530 Merge pull request #6628 from baryon/contrib/openai-gpt6-astra-capability-sync
fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
2026-09-05 16:07:50 +08:00
Wesley Liddick 9b4fcfb897 Merge pull request #6606 from luckydududu/feat/claude-cli-version-env-override
feat(claude): allow operators to override the advertised CLI version
2026-09-05 16:05:33 +08:00
Zero Clover cff3f89850 feat(groups)!: enforce model allowlists across gateway endpoints 2026-09-05 16:05:17 +08:00
Wesley Liddick 9517f12cdd Merge pull request #6510 from wall-wxk/fix/release-session-slot-on-forward-failure
fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
2026-09-05 16:05:06 +08:00
Wesley Liddick 40f4d450be Merge pull request #6507 from wucm667/fix/issue-6501-glm53-thinking-effort
fix(gateway): normalize GLM-5.3 thinking effort
2026-09-05 16:04:16 +08:00
Wesley Liddick 620eb3fd00 Merge pull request #6581 from r266-tech/r266/forward-opencode-session
fix(openai): scope OpenCode session forwarding
2026-09-05 16:03:57 +08:00
Wesley Liddick c6ff69fd12 Merge pull request #6542 from wucm667/fix/issue-6530-gateway-concurrency-origin
fix(handler): distinguish gateway admission rejections
2026-09-05 16:03:17 +08:00
Wesley Liddick 7b271bbee9 Merge pull request #6529 from wucm667/fix/issue-6521-preserve-passthrough-reasoning
fix(openai): preserve reasoning effort in API-key passthrough
2026-09-05 16:02:56 +08:00
Wesley Liddick 68f099707e Merge pull request #6553 from wucm667/fix/issue-6547-heartbeat-bootstrap
fix(openai): normalize automation heartbeat bootstrap
2026-09-05 16:02:36 +08:00
Varrge 3bdb869fca fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices 2026-09-05 15:54:38 +08:00
feeeei c227863d51 fix(images): 生图 url 回填下载拒绝私网目的地并只信字节嗅探
账户开关 images_url_to_b64_json 下载的图片 url 来自上游响应,是不可信输入。此前沿用
base_url 的 security.url_allowlist 策略,默认配置下只做格式校验,上游返回
localhost:8080 之类地址时网关会真的去请求内网。

- 下载 URL 主机为 localhost、回环、私网、链路本地、未指定地址的字面量时直接拒绝,
  不受 security.url_allowlist 配置影响(urlvalidator 导出 IsBlockedHost)
- 新增 ctx 标记 WithHTTPUpstreamPublicHostsOnly:共享上游客户端对带标记的请求无视配置
  校验解析后 IP,并为其克隆客户端挂 redirectChecker,重定向保持跟随但每一跳都校验
- 下载内容改为只信 http.DetectContentType 嗅探结果且限 png/jpeg/webp/gif,
  响应头不再作为依据
- 下载失败仍只记日志并保留原样;超时与端口策略不变
2026-09-05 15:50:56 +08:00
lyy0709 3a8c9f16de Merge remote-tracking branch 'upstream/main' into codex/gpt-6-astra 2026-09-05 15:48:49 +08:00
Qi HU 2da31290a1 fix(openai): record WS cyber policy failures across paths
Cyber-policy detection only inspected response.failed, and the ingress check ran after error-specific early returns. As a result, error-shaped failures and the connection-pool, HTTP-bridge, and WSv2 paths could miss cyber-policy hits.

Centralize event marking and invoke it before retry and failover returns across the WebSocket implementations. Preserve the recorded guard during account failover and defer connection blocking until the logical failover chain completes, avoiding duplicate records while ensuring subsequent turns are blocked.
2026-09-05 15:32:29 +08:00
feeeei 3c53ba01a7 feat(images): 账户开关将生图结果 url 下载回填为 b64_json
部分第三方 GPT-image 上游把 Images 响应里的 b64_json 换成 url,按官方接口实现的
客户端读不到图。账户 extra.images_url_to_b64_json 开启后,OpenAI API Key 的
Images 非流式响应里 b64_json 缺失或为空且 url 非空的项,由网关下载 url 内容并以
base64 回填 b64_json,url 与其余字段原样保留;异步生图任务复用同一路径。

- 下载经账户代理发起,沿用 base_url 的出站 URL 校验策略(validateUpstreamBaseURL
  抽出 validateOutboundURL,nil cfg 按仅格式校验处理),单图上限 20MB 且须为图片
  内容(响应头或字节嗅探);data: URL 直接取 base64 载荷
- 单项下载失败仅记日志并保留原样;客户端显式 response_format=url 时不回填;
  流式响应不处理
- 创建/编辑账户弹窗在 OpenAI API Key 的 Responses 模式块后新增“生图结果 URL 转
  base64”开关,中英文案;新增 openai_images_b64_backfill_test.go
- 只被 unit 标签测试引用的定价目录测试辅助补上 //go:build unit(admin 包整文件加标签,
  service 包的 newPricingServiceFromModelPricing 搬到 pricing_stub_helpers_unit_test.go),
  消除 golangci-lint 在默认标签下的 unused 报错
2026-09-05 15:17:57 +08:00
Wesley Liddick 07bf8b92fd Merge pull request #6550 from weiness/fix/alipay-payment-reconciliation
修复支付宝回调延迟或丢失,已支付订单可能长时间停留在待支付状态的问题
2026-09-05 15:08:56 +08:00
Wesley Liddick 63dc24b5e8 Merge pull request #6626 from wucm667/feat/issue-6624-none-reasoning-mapping
feat(openai): allow none as reasoning mapping source
2026-09-05 15:06:32 +08:00
Wesley Liddick 8f1d6af3ed Merge pull request #6531 from wucm667/fix/issue-6524-continuation-unavailable-recovery
fix(openai): recover unavailable continuation responses
2026-09-05 15:06:02 +08:00
Wesley Liddick 85f431a072 Merge pull request #6620 from wucm667/fix/issue-6615-astra-messages-cache
fix(openai): enable Astra messages prompt caching
2026-09-05 15:04:13 +08:00
Wesley Liddick d7fb9b5d35 Merge pull request #6593 from wucm667/fix/issue-6584-chat-tool-discovery
fix(apicompat): preserve discovered tools in chat fallback
2026-09-05 15:03:52 +08:00
Wesley Liddick eba6ea5639 Merge pull request #6590 from wucm667/fix/issue-6582-search-tool-capability
fix(codex): advertise search capability for bridged routes
2026-09-05 15:03:31 +08:00
Wesley Liddick 2ad6e8fb75 Merge pull request #6599 from wucm667/fix/issue-6598-gemini-custom-models-list
fix(gemini): apply custom list to native models endpoint
2026-09-05 15:03:08 +08:00
Wesley Liddick 570bd084c2 Merge pull request #6594 from wucm667/fix/issue-6592-preserve-zero-system-metrics
fix(ops): preserve zero-valued system metrics
2026-09-05 15:02:40 +08:00
Wesley Liddick 15278fe2f9 Merge pull request #6580 from OG-Wang/fix/sidebar-collapse-when-active
fix(frontend): allow collapsing sidebar groups while a child route is active
2026-09-05 15:02:30 +08:00
Wesley Liddick c4e6dcfd80 Merge pull request #6602 from ZeroClover/feat/codex-manifest-pinned-accounts
feat(codex): pinned-accounts Codex model manifest for OpenAI groups
2026-09-05 14:51:17 +08:00
Wesley Liddick 701f844755 Merge pull request #6555 from feeeei/feat/usage-upstream-request-id
feat(usage): 用量记录落库直接上游的请求 ID,并支持按账户配置响应头名
2026-09-05 14:36:07 +08:00
Wesley Liddick 91cf660377 Merge pull request #6397 from feeeei/fix/openai-ws-replay-oom-6382
fix(openai-ws): eliminate O(T²) replay copying and invalid_encrypted_content retry storms
2026-09-05 14:32:11 +08:00
Wesley Liddick 78e1aaedb9 Merge pull request #6535 from feeeei/feat/pricing-custom-file-hot-reload
feat(pricing): fallback/override 定价文件按内容哈希热重载,改动无需重启生效
2026-09-05 14:31:09 +08:00
shaw 708b85a6a4 fix: complete upstream request id handling 2026-09-05 14:18:13 +08:00
shaw 1cab4d8c22 fix: make custom pricing reload coherent 2026-09-05 14:18:13 +08:00
Wesley Liddick d8b9a83f8c Merge pull request #6492 from feeeei/main
fix(gateway): 负载感知调度补齐渠道模型限制的逐账号检查
2026-09-05 14:06:21 +08:00
Long LiandCursor a016bd4ba5 style: gofmt upstream models capability sync tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 14:58:51 +09:00
Long LiandCursor 983a3db3af fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
Official OpenAI hosts and models.dev entries without an api field left
Astra falling back to reasoning effort none and empty Fast tiers. Persist
complete per-model metadata, advertise Astra reasoning/Fast locally, and
surface partial sync warnings when image models remain incomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 14:45:06 +09:00
wucm667 7969751f1f feat(openai): allow none as reasoning mapping source 2026-09-05 12:32:30 +08:00
David Stone 6271c517da fix(apicompat): preserve arguments from done events 2026-09-04 21:18:36 -06:00
wucm667 4aaf5cb73b fix(openai): enable Astra messages prompt caching 2026-09-05 10:58:05 +08:00
LuckyandClaude Opus 5 e9bad40b10 feat(claude): allow operators to override the advertised CLI version
Anthropic gates newer models on a minimum Claude Code client version. With
CLICurrentVersion pinned at 2.1.220, claude-fable-5-1 fails every request with
"Claude Code 2.1.220 does not support this model; version 2.1.251 or newer is
required", and the only fix is a release that bumps one constant.

Add SUB2API_CLAUDE_CLI_VERSION, mirroring what internal/pkg/xai already does
with XAI_GROK_CLI_VERSION. CLICurrentVersion remains the built-in pin; the new
CLIVersion() is what every consumer reads. With no env var set the resolved
value equals the pin, so existing deployments are unaffected.

The value is resolved once at package init rather than per call: the User-Agent
header and the request body's cc_version are written by different code paths for
the same request, and reading different values would make that request
contradict itself.

Validation is strict (three numeric segments, not below the pin) because
identity_service.go documents what a malformed version does once it reaches an
account's persistent fingerprint: sustained 429s without reset headers, 5s
fallback cooldown, pool collapse into a 503 storm, and no reset entry point.
Invalid values warn and fall back rather than being silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168uDMAj9anaJrnH8CeFze6
2026-09-04 18:01:34 +00:00
Zero Clover 1e28ef5942 feat(codex): pinned-accounts Codex model manifest for OpenAI groups 2026-09-05 00:10:24 +08:00
wucm667 28f673e4cd fix(gemini): honor custom native models list 2026-09-04 22:29:36 +08:00
wucm667 9ddf69698d fix(ops): preserve zero-valued system metrics 2026-09-04 18:51:24 +08:00
wucm667 51631d0324 fix(apicompat): preserve discovered tools in chat fallback 2026-09-04 18:33:23 +08:00
wucm667 c7538cd256 fix(codex): advertise search capability for bridged routes 2026-09-04 16:43:15 +08:00
doublepi123 8ed57b000c feat(antigravity): support Gemini 3.7 Flash models
Google released Gemini 3.7 Flash on 2026-08-13. Register it alongside 3.8
using the same shape as the existing 3.6 entries: bare id plus the
Antigravity -high/-low/-medium/-tiered thinking tiers, the model catalog,
the custom-mapping passthroughs, fallback pricing and the tier alias
normalizer.

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

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

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

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

Refs #6523
2026-09-04 16:04:47 +08:00
r266-tech 6cf645f87c fix(openai): scope OpenCode session forwarding 2026-09-04 12:45:06 +08:00
Rick aa556c372f fix(frontend): allow collapsing sidebar groups while a child route is active
A collapsible sidebar group was force-expanded whenever the active route
was one of its children, so clicking the chevron did nothing: the toggle
only updated the internal set while isGroupActive() kept reporting the
group as expanded. Any click that produces no visible change reads as a
bug to users.

Track per-group expand/collapse overrides instead. A group without an
entry keeps the automatic behavior (expanded while active, so context is
not lost on load or navigation), but a chevron click records the user's
choice and that choice wins, letting an active group be collapsed and
re-expanded at will.
2026-09-04 12:42:20 +08:00
刘佳林 126ac24c8c feat(codex): support ultrafast service tier
Accept service_tier=ultrafast on the OpenAI/Codex gateway, advertise it
on GPT-5.6 Sol in the Codex model catalog, and include it in ChatGPT
OAuth routing hints so Codex clients can select Ultrafast.
2026-09-04 10:14:29 +08:00
lyy0709 3c8be00131 feat(openai): add GPT-6 Astra support 2026-09-04 10:04:44 +08:00
wucm667 f5ae32e53f fix(gateway): normalize GLM-5.3 thinking effort 2026-09-04 08:15:41 +08:00
Ritel-T a3a6a85b73 test(admin): assert nested credential redaction in lite lists 2026-09-03 23:32:34 +09:00
Ritel-T db15e0090a fix: keep account table on compact list responses 2026-09-03 21:20:09 +09:00
Ritel-T 994192ef4f fix: type admin account list responses 2026-09-03 21:20:09 +09:00
Ritel-T 3e60c3b86b fix: add compact admin account list DTO 2026-09-03 21:20:08 +09:00
feeeei de27905e88 feat(usage): 用量记录落库上游请求标识并支持按账户配置响应头名
- usage_logs 新增 upstream_request_id 列(迁移 232)与仅索引非空行的 CONCURRENTLY 部分索引(迁移 233,runner 登记失效索引清理)
- 账户 extra.upstream_request_id_header 指定要读取的上游响应头,只读该头;留空不记录;创建/更新/批量入口校验头名合法且不超过 64 字节
- ForwardResult/OpenAIForwardResult 携带 UpstreamHeaders,中央计费按账户解析落库,WS 轮次保持 NULL
- 管理端用量明细新增默认隐藏的"上游ID"列,带与请求ID列一致的复制按钮,并同步导出
- 创建/编辑账户弹窗新增"上游ID"输入(UpstreamRequestIdHeaderField),提示按接入类型与平台分条列出常见头名
- HelpTooltip 悬停模式下指针移入提示框时保持打开,便于选中复制提示内容
2026-09-03 18:02:15 +08:00
wucm667 28dde982cb fix(openai): normalize automation heartbeat bootstrap 2026-09-03 17:29:04 +08:00
weiness de8d756afa fix(payment): reconcile pending Alipay orders 2026-09-03 16:33:30 +08:00
shaw b1748c4ea9 chore: update sponsors 2026-09-03 15:40:51 +08:00
Wesley Liddick 432ebc498c Merge pull request #6179 from wwcchh0123/codex/upstream-error-proxy-attribution
fix(ops): preserve event-time proxy attribution for upstream errors
2026-09-03 15:34:59 +08:00
Wei-Shaw abc07bb079 test(ops): satisfy errcheck and gofmt in proxy attribution tests 2026-09-03 15:23:22 +08:00
wucm667 78fc7b171e test: skip live token comparison on network errors 2026-09-03 15:16:18 +08:00
Wei-Shaw 4c1f920d58 fix(ops): close proxy attribution gaps and bound queued upstream events
Follow-up to the event-time proxy snapshot: every upstream attempt now
carries attribution, the direct/unknown contract is enforced everywhere,
and the queued upstream_errors array has hard bounds again.

Attribution coverage
- compact fallback retry helper and the streaming compact failover event
  now stamp proxy_id/proxy_name; the non-streaming SSE compact retry
  previously dropped its first attempt on `continue` and now records it
- raw chat-completions stream truncation events (new on main) attributed
- Anthropic/Bedrock transport errors get attribution inside the shared
  handleUpstreamTransportError helper, so all five callers inherit it
- OpenAI first-output timeout takes attribution from the caller: HTTP
  callers pass the HTTP rule, the WS passthrough relay passes the WS rule
  (no managed proxy => unknown, never direct, because http.DefaultClient
  may honor environment proxies)

Contract hardening
- single opsUpstreamProxyAttribution returns (id, name) from one decision;
  a hydrated Proxy without a durable ID is unknown, not id=null+name
- both normalizers (struct at append time, JSON on detail reads) enforce
  proxy_id==null <=> sentinel name; blank managed name => "proxy"
- detail-read normalization edits only the attribution keys via sjson, so
  keys from older struct versions and stored key order are preserved

Queue bounds
- keep at most 256 attempts and ~512 KiB serialized per entry, newest
  first; the oldest retained event records dropped_earlier_attempts
- attempts outside the 16-event body window trim url/message to 512 bytes
- restore the empty-event drop rule, judged before the body window clears
  detail so older real attempts survive

Tests cover each site above (managed proxy, direct, WS unknown), the
normalizer invariants, key/order preservation, and the count/byte bounds.
2026-09-03 15:11:36 +08:00
wwcchh0123 e9e3c46cb7 fix(ops): snapshot proxy on upstream errors 2026-09-03 14:20:39 +08:00
wucm667 62198286e9 [verified] fix(handler): distinguish gateway admission rejections 2026-09-03 12:48:02 +08:00
NellPoi af90a9bd1f fix(openai): 兼容历史任务委派续聊 2026-09-03 11:39:23 +08:00
wucm667 5e4958c889 fix(openai): schedule accounts with mapped model 2026-09-03 10:51:12 +08:00
feeeei fdc2ec17e6 feat(pricing): fallback/override 定价文件按内容哈希随定时器热重载
PricingService 的定时器(hash_check_interval_minutes,默认 10 分钟)每轮先做
远程目录哈希同步,再比对 fallback_file/override_file 两个文件内容的联合 sha256
(customFilesHash),变化时从本地 model_pricing.json 重建叠加层,只替换内存数据
与指纹,不改远程同步锚点 localHash。只配置了自定义文件而 remote_url 为空时
调度器同样启动。

- 三条重建路径(启动加载、远程下载、热重载)统一走 buildPricingData,指纹在合并
  读取之前采样,并发改文件只会落后于数据、下一轮补重建
- 删除文件视为该层清空,下一轮生效;文件存在但不可读或不是 JSON 对象时保留旧数据、
  不更新指纹,每轮重试并告警
- config.example.yaml 同步生效说明;新增 pricing_service_hot_reload_test.go
2026-09-03 10:35:20 +08:00
wucm667 b6740c937f fix(openai): recover unavailable continuation responses 2026-09-03 10:26:12 +08:00
wucm667 10c8f523c4 fix(openai): preserve passthrough reasoning effort 2026-09-03 09:42:50 +08:00
haruka 8249ab37db feat: add Anthropic reasoning effort pricing controls 2026-09-02 20:16:57 +08:00
anguobao123 1859519573 feat(deploy): support optional Apple network subnet 2026-09-02 20:08:57 +08:00
wall-wxkandClaude Opus 4.6 9be9c0b68c fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放
机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号
耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致
max_sessions 受限的账号在窗口内拒绝所有新会话。

修复:
- SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册
- GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐
- Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时
  统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放;
  成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义
- CountTokens:转发失败时同样释放选号时注册的会话槽

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 18:45:20 +08:00
Soulxyz 8ed48e27a6 fix(setup): avoid hardcoded postgres connection 2026-09-02 15:30:59 +08:00
aofee a7f1ed8fa4 fix(openai-ws): track pending passthrough turns 2026-09-02 14:59:13 +08:00
WUJI-Labs f5e4da5e72 style(upstream): gofmt http_upstream_profile.go 2026-09-02 12:40:23 +08:00
WUJI-Labs 9dc4c40bff test(upstream): align HTTP/2 keepalive tests with long-stream profile API 2026-09-02 12:28:37 +08:00
WUJI-Labs 81fd853005 feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams
Long-running LLM inferences (such as high-thinking Gemini models) may experience
silent TCP disconnections across intermediate proxies, NAT gateways, or firewalls
when no data frames are emitted for tens of seconds to minutes.

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

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

Signed-off-by: WUJI-Labs <zoujincool@gmail.com>
2026-09-02 12:11:17 +08:00
aofee 01bd9b71a2 feat: add Grok media eligibility controls 2026-09-02 11:50:33 +08:00
feeeei 9ad3865690 fix(gateway): 负载感知调度补齐渠道模型限制的逐账号检查
渠道 billing_model_source=upstream 时,限制模型只能按账号映射后的上游模型
逐账号判定。GatewayService.SelectAccountWithLoadAwareness 的负载感知各层
(模型路由候选、路由内粘性 gate、无路由粘性 gate、负载感知候选及其兜底)
此前都没有这一判定,load_batch_enabled 默认开启,anthropic/gemini/antigravity
分组的渠道白名单实际不生效。

现在入口算一次 needsUpstreamChannelRestrictionCheck,各层共用同一判定;
候选因渠道限制筛空时记 warn 并返回带 channel pricing restriction 的
ErrNoAvailableAccounts,与预检路径一致。
2026-09-02 11:46:37 +08:00
cxx-cod 6f9fde093f style(openai): apply De Morgan's law to satisfy staticcheck QF1001 2026-09-02 03:30:14 +00:00
github-actions[bot] 5097b31457 chore: sync VERSION to 0.2.0 [skip ci] 2026-09-02 03:24:57 +00:00
Wesley Liddick aa23648835 Merge pull request #6480 from Wei-Shaw/fix/group-model-pricing-ui
fix(frontend): 优化分组模型定价弹窗布局
2026-09-02 11:09:55 +08:00
aofee a774230661 fix(grok): keep inconclusive billing accounts eligible 2026-09-02 11:09:21 +08:00
aofee 222181efd6 fix(anthropic): scope Fable credits errors to model 2026-09-02 11:02:38 +08:00
shaw 1a33dc8cc4 fix(frontend): 优化分组模型定价弹窗布局 2026-09-02 10:40:21 +08:00
Wesley Liddick 3510aa22b5 Merge pull request #6425 from DuskLin/feat/openai-reasoning-effort-model-scope
feat(group): OpenAI 推理强度映射支持按模型名/前缀/后缀匹配
2026-09-02 10:18:07 +08:00
刘佳林 77729e2723 merge: resolve conflicts with upstream/main
Keep model-scoped reasoning effort mappings together with the
deny-or-downgrade ceiling control that landed on main.
2026-09-02 10:01:42 +08:00
shaw 05ea883e26 fix(ent): correct group field indexes after merge 2026-09-02 10:01:33 +08:00
Wesley Liddick 6566039bc8 Merge pull request #6470 from aofee/codex/fix-ws-passthrough-missing-terminal
fix(openai-ws): reject passthrough close before terminal event
2026-09-02 09:42:13 +08:00
Wesley Liddick e50bffb7e3 Merge pull request #6469 from wucm667/fix/issue-6465-apikey-chat-cache-identity
fix(openai): preserve API key chat cache identity
2026-09-02 09:25:02 +08:00
Wesley Liddick f2804eb2c8 Merge pull request #6444 from Ritel-T/pr/group-free-openai-fast
Add group-level Standard billing for OpenAI Fast requests
2026-09-02 09:23:49 +08:00
Wesley Liddick 559960865a Merge pull request #6447 from DuskLin/feat/openai-reasoning-effort-over-limit
feat(groups): 推理强度上限增加超限自动降档与拒绝访问
2026-09-02 09:07:53 +08:00
Wesley Liddick a4fb58e428 Merge pull request #6443 from Ritel-T/pr/group-openai-fast
Add group-level OpenAI Fast enforcement
2026-09-02 09:07:05 +08:00
Wesley Liddick a668aa8b36 Merge pull request #6460 from HuntercodeT/fix/model-not-found-not-overridden-by-rate-limit
fix(gateway): stop a per-model cooldown from turning 404 model_not_found into 429
2026-09-02 09:06:17 +08:00
Wesley Liddick 8d0b5ede28 Merge pull request #6458 from HuntercodeT/fix/scheduler-projection-keeps-openai-passthrough
fix(scheduler): keep OpenAI passthrough flag in the scheduler snapshot projection
2026-09-02 09:05:51 +08:00
Wesley Liddick 2786ae9de6 Merge pull request #6474 from StarryKira/codex/fix-6472-fable-5-1
feat: support Claude Fable 5.1 and split cache-write pricing
2026-09-02 09:05:29 +08:00
haruka 34b8bf1a6e feat: support Claude Fable 5.1 2026-09-02 05:47:01 +08:00
cxx-cod 277aa1411d fix(openai): end streaming at terminal event instead of waiting for upstream EOF
The streaming forward loops treated upstream EOF as the only normal exit
condition; response.completed/[DONE] only set a flag. With keep-alive /
HTTP/2 connection reuse to the upstream, the upstream may hold the
stream open for 8-46s after the terminal event, during which the
gateway could only emit downstream keepalives, inflating tail latency.

Finish the stream as soon as the terminal event frame has been fully
written downstream (usage is already parsed from the terminal event),
and close the upstream body to unblock the reader goroutine. Applied
to the guarded/async, sync, and passthrough streaming paths. The WS
HTTP bridge already behaved this way.

Adds a regression test with an upstream that hangs after
response.completed.
2026-09-01 19:32:24 +00:00
Ritel-T 52c7d88340 chore: merge upstream v0.1.185 2026-09-02 03:29:52 +09:00
Ritel-T 9e2d97f250 chore: merge upstream v0.1.185 2026-09-02 03:28:24 +09:00
aofee bfe0a5a873 fix(openai-ws): reject close before terminal event 2026-09-01 20:44:11 +08:00
wucm667 504919a054 fix(openai): preserve API key chat cache identity 2026-09-01 19:10:37 +08:00
Wesley Liddick 0d27f45ead Merge pull request #6454 from alfadb/fix/anthropic-fallbacks-sanitize
fix(gateway): 缺 server-side-fallback beta 时剥离 Anthropic fallbacks 字段
2026-09-01 17:47:34 +08:00
Wesley Liddick 65380be9c8 Merge pull request #6463 from DuskLin/feat/kimi-native-responses
feat(kimi): 原生转发 OpenAI Responses API
2026-09-01 17:46:36 +08:00
Wesley Liddick d596d0844f Merge pull request #6450 from WizardPro/codex/fix-scheduled-automation-bootstrap
fix(openai): accept scheduled automation bootstrap without call id
2026-09-01 17:30:04 +08:00
shaw cd04848b97 chore: update sponsors 2026-09-01 17:19:36 +08:00
刘佳林 e377c4358c feat(kimi): forward native OpenAI Responses API
Moonshot pay-as-you-go and Kimi For Coding both expose native
/v1/responses. Stop converting Kimi Responses traffic to Chat
Completions, and treat Kimi like DeepSeek for protocol selection while
keeping /v1/responses URL construction.
2026-09-01 16:50:04 +08:00
HuntercodeT 3438580213 fix(gateway): stop a per-model cooldown from turning 404 model_not_found into 429
classifySelectionFailureError upgrades a selection failure to 429
rate_limit_error whenever the scheduler's compact reason string matches
model_rate_limited=(\d+) with a positive count. It does so unconditionally,
including when the fallback it was handed is the authoritative 404
model_not_found classification.

Selection failure reasons are additive, so both can appear at once, e.g.
"pool=9, filtered: model_not_supported=8 model_rate_limited=1": eight accounts
cannot serve the model at all and the ninth happens to sit in a per-model
cooldown. classifyNoAccountError only produces ModelNotFound through
DiagnoseModelAvailabilityForPlatform, a dedicated database query over
persistent eligibility (active + schedulable + model_mapping), so it has
already established that no account in the group can serve the model. One
transient cooldown does not make "All available accounts are currently
rate-limited" true.

The mislabel is not cosmetic:

- Retrying can never succeed, yet 429 explicitly invites a retry.
- Clients that treat 429 as a rate limit retry hard and swallow the body;
  Codex surfaces only "exceeded retry limit, last status: 429", discarding the
  one message that names the real problem. Operators then look for capacity
  and rate limits instead of a model whitelist.
- Ops attribution becomes self-contradictory: classifyNoAccountErrorFromGin
  has already marked the request business-limited for local model
  configuration, while call sites gate markOpsRoutingCapacityLimitedIfNoAvailable
  on ModelNotFound, which the override clears, so the same request is also
  counted as routing capacity limited.

Return the fallback untouched when it is ModelNotFound. The 429 upgrade is
preserved for every other fallback, which is the case it was written for:
accounts that can serve the model exist but are all cooling down, where the
fallback is 503 and retrying is meaningful.

Claude-Session: https://claude.ai/code/session_01LwvyJVV2sdqiu41Z9JDNUq
2026-09-01 15:18:30 +08:00
HuntercodeT e93e6368fc fix(scheduler): keep OpenAI passthrough flag in the scheduler snapshot projection
filterSchedulerExtra is an allowlist and never carried openai_passthrough /
openai_oauth_passthrough, while credentials.model_mapping is preserved in full.
Candidate filtering (ListSchedulableAccounts) reads that trimmed projection
(sched:meta:<id>), so there a passthrough account looks exactly like a
whitelist-mode one: Account.IsModelSupported falls back to the model_mapping
whitelist and the account is dropped as model_not_supported. Forwarding, which
hydrates the full account from sched:acc:<id>, still honors passthrough - hence
"testing the account directly succeeds, but the gateway reports no available
accounts supporting model X".

The practical impact is that a stale leftover whitelist silently shrinks the
candidate pool of accounts that were explicitly configured to let the upstream
decide. When no account in the group lists the requested model, the group is
fully dark for it even though every account can serve it.

This is not a regression of #4936: that fix corrected the predicate
(Account.IsModelSupported short-circuits passthrough before the mapping check),
this one restores the input the predicate is fed. The projection allowlist
predates #4936 by three and a half months and has never carried the flag, so
the fix simply never took effect on the snapshot path.

Add both keys to the allowlist, plus a regression test that round-trips the
projection through JSON (the real sched:meta write/read path) and asserts the
model gate still lets a non-whitelisted model through for a passthrough account.

Claude-Session: https://claude.ai/code/session_01LwvyJVV2sdqiu41Z9JDNUq
2026-09-01 15:13:11 +08:00
alfadb 200b1406d7 fix(gateway): strip Anthropic fallbacks unless server-side-fallback beta is present
Clients now send body.fallbacks; upstream rejects it without
anthropic-beta: server-side-fallback-2026-07-01. Mirror the
context_management sanitize so OAuth mimic / default API-key
beta paths no longer 400 with "fallbacks: Extra inputs are not permitted".
2026-09-01 14:23:33 +08:00
WizardPro 421a83282a fix(openai): accept scheduled automation bootstrap without call id 2026-09-01 11:36:26 +08:00
github-actions[bot] a2fb09260a chore: sync VERSION to 0.1.185 [skip ci] 2026-09-01 02:52:28 +00:00
刘佳林 aa7a811e66 feat(groups): add deny-or-downgrade control for reasoning effort ceiling
OpenAI/Composite groups can now reject requests that exceed the max
reasoning effort instead of always rewriting them down to the ceiling.
Existing groups keep the previous auto-downgrade behavior.
2026-09-01 10:15:45 +08:00
Wesley Liddick 2ac784c51a Merge pull request #6353 from feeeei/main
refactor(billing): 长上下文阶梯计价改为价格目录数据驱动
2026-09-01 09:32:06 +08:00
Wesley Liddick 6b39d0b453 Merge pull request #6379 from baryon/contrib/codex-priority-service-tier
fix(codex): advertise Fast service tier in generated catalogs
2026-09-01 09:02:24 +08:00
Wesley Liddick f218c8d40a Merge pull request #6394 from qiujun8023/fix/account-stats-deepseek-peak
fix(billing): apply model pricing policies to account stats cost
2026-09-01 08:58:49 +08:00
Wesley Liddick 1cce2b38ee Merge pull request #6417 from fengshao1227/fix/ws-ingress-capacity-shed-client-code
fix(openai): ctx_pool WS ingress 下发前改写上游容量降载错误码(Codex 会话被硬终止)
2026-09-01 08:54:00 +08:00
Wesley Liddick 9d9ed1cc62 Merge pull request #6415 from icebreaker-forked/fix/retry-transient-db-startup
fix(repository): PostgreSQL 恢复期间退避重试,避免首次启动退出
2026-09-01 08:53:25 +08:00
Wesley Liddick 2d7767eaf2 Merge pull request #6407 from wucm667/fix/issue-6402-delegation-bootstrap
fix(openai): accept delegation bootstrap without call id
2026-09-01 08:51:46 +08:00
Wesley Liddick 064d9b74e8 Merge pull request #6432 from okbexx/fix/codex-disabled-account-capabilities
fix(codex): ignore persistently disabled catalog accounts
2026-09-01 08:46:42 +08:00
Wesley Liddick e87c47c957 Merge pull request #6435 from wucm667/fix/issue-6420-apikey-responses-instructions
fix(openai): avoid synthesized instructions for API keys
2026-09-01 08:45:58 +08:00
Wesley Liddick bdedd6c544 Merge pull request #6433 from swjturay/codex/upstream-stale-ws-recycle
fix(openai-ws): recycle stale idle pool connections without reader liveness
2026-09-01 08:45:30 +08:00
wucm667 5fb5dfb34c fix(openai): reacquire passthrough turn slots 2026-09-01 08:13:29 +08:00
Ritel-T 9908d3ca2f Bill free Fast requests at Standard price 2026-09-01 04:34:24 +09:00
Ritel-T 4df6b0636b Add the free Fast group switch 2026-09-01 04:34:24 +09:00
Ritel-T 498e06c582 Localize the free Fast control 2026-09-01 04:34:24 +09:00
Ritel-T e6722126ba Add frontend free Fast types 2026-09-01 04:34:24 +09:00
Ritel-T e619ca386e Carry free Fast through auth snapshots 2026-09-01 04:34:24 +09:00
Ritel-T 8224434d21 Preserve free Fast when duplicating groups 2026-09-01 04:34:24 +09:00
Ritel-T fd55f3248f Manage free Fast through group lifecycle 2026-09-01 04:34:24 +09:00
Ritel-T 08d6c153b7 Return free Fast in admin responses 2026-09-01 04:34:24 +09:00
Ritel-T 636d6be690 Expose free Fast in admin requests 2026-09-01 04:34:24 +09:00
Ritel-T 7dcf728461 Project free Fast into API key auth 2026-09-01 04:34:24 +09:00
Ritel-T fb409787c6 Persist the free Fast group policy 2026-09-01 04:34:24 +09:00
Ritel-T e7caedc22a Regenerate Group persistence for free Fast 2026-09-01 04:34:24 +09:00
Ritel-T eebf4e3ffb Add the free Fast migration 2026-09-01 04:34:23 +09:00
Ritel-T c7cf84ad39 Preserve Group Fast in integration fixtures 2026-09-01 04:34:06 +09:00
Ritel-T 0681aa2566 Cover Group Fast in native Responses 2026-09-01 04:34:06 +09:00
Ritel-T 17747df842 Add the group Fast admin switch 2026-09-01 04:34:06 +09:00
Ritel-T 3fbce499d8 Add group Fast form helpers 2026-09-01 04:34:06 +09:00
Ritel-T 1a40e5690f Localize the group Fast control 2026-09-01 04:34:06 +09:00
Ritel-T 7903716ad1 Add frontend group Fast types 2026-09-01 04:34:06 +09:00
Ritel-T f8eae45048 Force OpenAI Fast across gateway paths 2026-09-01 04:34:06 +09:00
Ritel-T a44a1019f8 Carry group Fast through auth snapshots 2026-09-01 04:34:06 +09:00
Ritel-T 9fba8ed43b Return group Fast in admin responses 2026-09-01 04:34:06 +09:00
Ritel-T 7f5e915dd1 Expose group Fast in admin requests 2026-09-01 04:34:06 +09:00
Ritel-T 7e06816571 Preserve group Fast when duplicating groups 2026-09-01 04:34:06 +09:00
Ritel-T e9e66da5a8 Manage group Fast through group lifecycle 2026-09-01 04:34:06 +09:00
Ritel-T 94bb9354ce Accept group Fast in admin service inputs 2026-09-01 04:34:06 +09:00
Ritel-T 95982a508f Project group Fast into API key auth 2026-09-01 04:34:06 +09:00
Ritel-T 563979e91e Persist the group Fast setting 2026-09-01 04:34:06 +09:00
Ritel-T 1cb28fd429 Regenerate Group persistence for Fast mode 2026-09-01 04:34:06 +09:00
Ritel-T aa679efe41 Add the group Fast migration 2026-09-01 04:19:38 +09:00
feeeei e2cfaa46e7 fix(pricing): 目录 cache 侧字段契约哨兵与 Gemini pro 缓存写入价修正
阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差,
解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价:

- orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧
  above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打
  WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价
  缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在
  即不算孤儿,与计费回落一致
- isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档
  折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格
  版本的形态),显式 long_context_* 字段不告警
- deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档
  须恰为基础价 × 输入倍率
- 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准
  输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带
  priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值

测试:
- 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧
  阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存
  写入价 = 输入价)
- 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生
  cache_creation token(Gemini usageMetadata 无该类别)
- 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值
  计入缓存写入 token;阶梯表场景与夹具同步为现行目录值
2026-09-01 01:26:55 +08:00
feeeei 593fc93659 feat(pricing): 新增 override_file 价格目录覆盖补丁
新增 pricing.override_file 配置(默认关闭):稀疏补丁文件按 JSON 字段
浅合并覆盖目录/回退数据,作为最高优先级数据源,用于本地自定义官方
目录条目(如显式 long_context_input_token_threshold=0 关闭某模型阶梯)
而无需自建价格镜像:

- applyPricingOverrides 挂在 parsePricingData 解析入口,只修补已存在
  条目(补丁字段覆盖同名字段,值为 null 删除字段);目录、回退与灾备
  路径都经该入口,覆盖语义三处一致
- mergeOverrideOnlyModels 在回退合并后把目录/回退都没有的模型作为
  独立条目并入(复用主解析路径,须自带价格字段通过有效性过滤);
  不在主解析时抢先建条目,避免纯补丁挡住回退完整条目、其余分项价
  静默变 0
- 最终未生效的条目(模型名拼错、纯补丁落在不存在的模型上)打 WARN
  哨兵;override 文件缺失/损坏仅跳过合并,不影响目录加载
- 生效时机为重启或下次目录下载,与 fallback_file 一致;fallback_file
  只补缺失模型的语义不变
2026-09-01 01:26:55 +08:00
feeeei 530fb20f25 refactor(billing): 长上下文阶梯计价改为价格目录数据驱动
删除 GPT/Gemini 系列写死的阶梯规则,阶梯一律由目录数据驱动:

- 解析层折算 LiteLLM 的 *_above_XXXk_tokens 绝对价字段为阈值+倍率
  (阈值取自字段名,倍率 = above/基础价,cache 侧跟随输入倍率,
  xAI 按 provider 用达到即进高档语义);显式 long_context_* 字段
  按"字段存在"优先,显式 threshold=0 或 multiplier=1 可关闭阶梯
- 删除 OpenAI 272K 常量与 applyModelSpecificPricingPolicy 的长上下文
  强补分支、fallbackPrices/静态兜底条目的 LongContext 字段、Gemini 旧
  边际规则全链路(CalculateCostWithLongContext / RecordUsageWithLongContext /
  阶梯表 marginal 基准);保留 fastRatio 与 GPT-5.6 cache_write 1.25x
  防御分支、grok 静态兜底阶梯
- 渠道配置定价区间时完全以渠道区间为准,不再叠加目录阶梯
- 长上下文倍率 ≤0 在应用点按 1 计,防止显式字段残缺把分项乘 0 免费
- 目录重载时对比新旧数据,原有阶梯丢失打 WARN 哨兵告警

行为变化:Gemini 由边际 2x(仅 /v1beta、全模型)改为整单换档(数据
驱动、仅带 above_200k 的 pro 系),渠道平价之上叠加目录阶梯;Claude
sonnet-4/4.5 随目录数据获得 200K 整单阶梯(与官方定价一致);模型广场
官方参考价列与实付列同源展示目录阶梯。
2026-09-01 01:26:55 +08:00
Wesley Liddick 200602b41b Merge pull request #6438 from Brisbanehuang/fix/codex-custom-provider-image-input
fix(codex): preserve known GPT image input capabilities
2026-08-31 21:57:09 +08:00
Brisbanehuang e2624fb65c fix(codex): preserve known image input capabilities 2026-08-31 09:42:58 -04:00
shaw cc6a8e5174 chore: update sponsors 2026-08-31 21:29:13 +08:00
Ray a10ff1255c fix(openai-ws): classify canceled client streams 2026-08-31 21:04:29 +08:00
Ray 6d5f027848 fix(openai-ws): recycle stale idle pool connections 2026-08-31 21:04:29 +08:00
wucm667 e21b849a92 fix(openai): avoid synthesized instructions for API keys 2026-08-31 20:58:49 +08:00
Jarl ba345f1052 fix(codex): ignore persistently disabled catalog accounts 2026-08-31 20:26:29 +08:00
spin6lock c74a4f521c fix(billing): 补上 glm-5.3 / glm-5.3-flash 兜底价,避免被 glm-5 子串匹配抢走 2026-08-31 19:51:50 +08:00
刘佳林 7c01ec9be8 feat(groups): scope OpenAI reasoning effort mappings by model
Allow OpenAI/Composite group mappings to match by exact model name,
prefix, or suffix. Empty type and model still apply to every model.
The admin form groups multiple request-to-forward pairs under one
type+model scope.
2026-08-31 19:21:31 +08:00
cyh 163eb7ffae fix(ops): bound database-backed system logs 2026-08-31 19:04:58 +08:00
github-actions[bot] 52374af940 chore: sync VERSION to 0.1.184 [skip ci] 2026-08-31 09:28:15 +00:00
ice breaker b6a7b8b7df fix(repository): allow eight transient database retries 2026-08-31 17:10:31 +08:00
li 1dc0a09009 fix(openai): ctx_pool WS ingress 下发前改写上游容量降载错误码
上游容量降载(server_is_overloaded / slow_down)被 ctx_pool 的 ingress 直写路径
原样转发给客户端。Codex CLI 按闭集判定,这两个码属致命集,客户端打印
"Selected model is at capacity" 并直接终止会话,不退避重试。

仓库已有 sanitizeOpenAICapacityShedErrorCodeForClient 处理这件事,且注释写明了
Codex 的闭集判定与"账号状态判定使用改写前原始 payload"的前提。它此前只在
HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge
(openai_ws_http_bridge.go)两条路径被调用,ctx_pool ingress 直写路径是唯一漏掉
的一条——同一账号同一时刻切到 http_bridge 就能正常退避重试。

改写结果写入独立的 clientMessage,不动 upstreamMessage:写出点之后的
markOpenAIWSClientVisibleFailure 与 handleOpenAIWSTerminalTransientFailure
仍按原始 payload 判定账号状态,与 http_bridge 的写法一致。

新增用例覆盖改写生效与改写范围两侧(非容量类错误码必须原样下发)。
2026-08-31 16:53:57 +08:00
Wesley Liddick e98ef32eb2 Merge pull request #6414 from fengshao1227/fix/anthropic-responses-stream-lifecycle
fix(apicompat): Anthropic→Responses 流式转换丢失助手文本(thinking 块顶掉未关闭的 message item + content_index 从不推进)
2026-08-31 16:43:40 +08:00
ice breaker 863667ce67 fix: retry transient database startup errors 2026-08-31 16:25:13 +08:00
li 8f5451587e fix(apicompat): Anthropic→Responses 流式转换在 thinking 块前关闭 message item 并推进 content_index
thinking 块开始时不关闭已打开的 message item,直接覆盖 CurrentItemType/
CurrentItemID,累积的助手文本既不进 state.Outputs 也拿不到 output_item.done,
response.completed 只带 reasoning——客户端收到「成功但无输出」。同 switch 的
tool_use 分支本来就先调 closeCurrentResponsesItem,只有 thinking 漏了。

content_index 只在 item 打开时置 0,从不推进。同一 message item 里第二个 text
块会再发一次 content_part.added(content_index=0),与第一个 part 撞在同一下标,
SDK 累积式 helper 按 content[content_index] 写入时后者覆盖前者。反方向
responses_to_anthropic.go 有三处 ContentBlockIndex++,正方向一处都没有。

新增用例覆盖两条坏顺序,外加一条生命周期不变式用例(output_item.added 与
output_item.done 必须配平、terminal 事件按announce 顺序逐项携带)。
2026-08-31 16:15:08 +08:00
Wesley Liddick 85b593fd2f Merge pull request #6411 from Wei-Shaw/fix/openai-ttft-setting-ui
fix(openai): move TTFT metric mode to admin settings
2026-08-31 15:39:12 +08:00
shaw 0fcec63b68 test(openai): isolate visible TTFT fixture 2026-08-31 15:15:44 +08:00
shaw 8553c91c1a test(api): update settings contract fixture 2026-08-31 15:05:39 +08:00
shaw c66e700f05 fix(openai): move TTFT metric mode to admin settings 2026-08-31 14:52:07 +08:00
Wesley Liddick fdf9751c12 Merge pull request #6378 from Ritel-T/fix/codex-oauth-service-tier-billing
fix(openai): preserve Codex OAuth Fast billing tier
2026-08-31 13:56:00 +08:00
wucm667 1be69e56aa fix(openai): accept delegation bootstrap without call id 2026-08-31 12:57:06 +08:00
qiujunandClaude Opus 5 e7c029875c test(billing): 图片输出按 output 子集计价
上一条改用统一定价管线后,TestTryModelFilePricing_WithImageOutput 挂在 0.3 vs
0.28:原算式把 ImageOutputTokens 当 OutputTokens 之外的额外量叠加,等于按文本
output 价和图片价各收一次,测试固化了这个重复计费。

ImageOutputTokens 本就是 OutputTokens 的子集(Gemini 取自 candidatesTokensDetails
的 IMAGE 档,OpenAI 取自 output_tokens_details),统一管线先扣除再按图片单价计,
未配图片价时回退到 output 价、总额不变,正是这个语义。期望改为 0.28。

影响:涉及图片输出的模型,账号统计成本会降低"图片 token × 文本 output 单价"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:33:06 +08:00
Wesley Liddick 0678b24d58 Merge pull request #6283 from okbexx/feat/usage-compaction-kind
feat(usage): track native compaction requests
2026-08-31 11:19:37 +08:00
Wesley Liddick b369fbca15 Merge pull request #6395 from lylelllll/fix/account-expiry-timezone
fix(frontend): clarify local timezone for expiry inputs
2026-08-31 11:18:55 +08:00
Wesley Liddick 36ee193e3c Merge pull request #6367 from qiujun8023/fix/usage-stats-cards-mobile-horizontal-scroll
fix(frontend): prevent hidden cache tooltip from causing horizontal scroll on mobile
2026-08-31 11:18:03 +08:00
Wesley Liddick 1dd0f2e5dc Merge pull request #6384 from alfadb/fix/unify-usage-window-style
fix(frontend): unify usage window rendering and tighten alert thresholds
2026-08-31 11:17:09 +08:00
Wesley Liddick f34735f462 Merge pull request #6388 from alfadb/feature/ollama-usage-cn-platforms
feat(ollama): 用量窗口支持挂在国产三家平台下的 Ollama Cloud 账号
2026-08-31 11:16:25 +08:00
Wesley Liddick a2d7d41185 Merge pull request #6325 from 251392463/fix/grok-responses-vision-tool-output
fix(grok): preserve Responses vision tool output images
2026-08-31 11:15:33 +08:00
Wesley Liddick 03ab687686 Merge pull request #5866 from wucm667/fix/issue-5863-scope-monitor-groups
fix: scope channel monitor groups by user access
2026-08-31 11:15:13 +08:00
Wesley Liddick 2e756b71f2 Merge pull request #6198 from xuhaihan/fix/openai-reauth-refresh-token
fix(accounts): reauthorize OpenAI with refresh token
2026-08-31 11:14:12 +08:00
Wesley Liddick fbc69322af Merge pull request #6254 from wucm667/fix/issue-6249-configurable-image-cooldown
feat(openai): configure image tool cooldown
2026-08-31 11:13:52 +08:00
Wesley Liddick 414489d152 Merge pull request #5469 from wucm667/fix/issue-5467-passthrough-http-bridge
fix(openai-ws): bridge oversized passthrough requests
2026-08-31 11:13:35 +08:00
Wesley Liddick 50a9dd7a64 Merge pull request #6386 from wucm667/fix/issue-6383-isolate-passthrough-ws
fix(openai): isolate passthrough websocket sessions
2026-08-31 11:13:16 +08:00
Wesley Liddick a4156eea10 Merge pull request #6385 from wucm667/fix/issue-6381-preserve-quota-limits
fix(admin): preserve quota limits on partial group updates
2026-08-31 11:12:56 +08:00
Wesley Liddick e0ecb55d4b Merge pull request #4872 from wucm667/fix/issue-4845-reset-scheduler-rate-limit
fix(account): clear rate limit on quota reset
2026-08-31 11:12:35 +08:00
wucm667 897faea333 fix(account): reset quota cooldown atomically 2026-08-31 09:12:09 +08:00
feeeei 0e08c39999 fix(openai-ws): remember rejected encrypted content across turns
invalid_encrypted_content 恢复此前只清理当次请求体:同一失效密文随客户
端维护的会话历史在后续每一轮重新出现,反复触发"整包被拒→剥离→重试/
重连"(#6382 生产观测 2h 内 251 次 recovery、273 次 WS 重连),既是
QoS 放大器也是内存压力放大器。

按会话记录被上游拒绝过的 encrypted_content SHA-256 摘要(lineage):
- OpenAIWSStateStore 新增会话级摘要绑定,TTL 复用 session sticky,
  单会话 512 摘要为存储自保护,超出仅退化为既有单次 recovery
- 写点覆盖 forward WSv2 recovery、forward 非 WS 400 重试、ingress
  error 事件、bridge 400/流内 error;读点在 forward 进场与 ingress/
  bridge turn 循环入口按摘要预剥离,新密文摘要不同不受影响
- 只收剥离端可处理的项类型(reasoning/compaction/compaction_summary),
  避免记入永远剥不掉的摘要后每轮空转解码
- replay 历史序列同步剥离,避免与已剥离的当前 input 项错位导致
  prefix 复用失配

含端到端集成测试:turn1 上游拒绝即记录,turn2 上游收到的请求不再携带
失效密文且 reasoning 骨架保留。
2026-08-31 01:02:13 +08:00
feeeei ca9b4d73f2 fix(openai-ws): share immutable replay bodies to stop O(T²) copying
OpenAI Responses WS/HTTP-bridge 的 replay 历史此前每轮对全部正文做多次
深拷贝(构建、双份序列、保存点各一遍),长会话累计分配 O(T²),实测
128 turn x 10KiB 达 160MB+,是 #6382 内存持续增长直至 OOM 的主因。

改为共享不可变正文所有权:
- extract 经零拷贝 string 视图 + gjson Index 直接切 payload 子切片,
  并以 json.Valid 保持对断裂数组的既有错误语义
- 序列构建/sanitize/collector/保存点全部只传引用;跨持有者保存经
  combineOpenAIWSReplayItems 新建头数组,正文不复制
- bridge 每轮一次解析,正常 replay 与 account-failover 序列共享正文
- 前缀比较增加字节级快路径,避免逐轮全历史 decode+encode
- strict 状态可用时不再整包保留上一轮 payload

新增 128 turn 分配回归测试锚定上界(实测 2.5MB,深拷贝回归会击穿),
以及正文共享的所有权断言。
2026-08-31 01:01:20 +08:00
lylelllll 94edcd5d81 chore: remove second misplaced component copy 2026-08-30 23:31:59 +08:00
lylelllll 3673702af5 chore: remove misplaced uploaded component copy 2026-08-30 23:27:08 +08:00
lylelllll 5778739cdb fix(frontend): use strict local expiry parsing for redeem codes 2026-08-30 23:20:59 +08:00
lylelllll 81e461f651 fix(frontend): parse local expiry datetimes strictly 2026-08-30 23:19:45 +08:00
lylelllll b7aca87fd0 test(frontend): cover local datetime parsing across timezones 2026-08-30 23:17:11 +08:00
lylelllll ae1bcdc25d fix(frontend): clarify expiry timezone in Chinese labels 2026-08-30 23:15:54 +08:00
lylelllll 263605779c fix(frontend): clarify expiry timezone in English labels 2026-08-30 23:13:52 +08:00
qiujunandClaude Opus 5 9eabd2a5b7 fix(billing): apply model pricing policies to account stats cost
resolveAccountStatsCost 的优先级 3 维护了第二份"单价 × token 数"实现,每加一个
定价特性都要手工镜像:service tier 与长上下文分别由 9261dd77、25e6688a 补齐,
而 b5827cfd 引入的 DeepSeek 官方峰谷一直没有生效,高峰时段账号成本最多低估一半。

改为复用 CalculateCostWithServiceTier。channelPricing 仍为 nil,优先级 3 的语义
不变:只取模型定价文件、不引入渠道自定义定价;低谷时段结果与原算式完全一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:57:53 +08:00
lylelllll d66bc88e68 fix(frontend): apply expiry input changes in source paths 2026-08-30 22:11:22 +08:00
lylelllll 8177f27aa3 fix(frontend): clarify local timezone for expiry inputs 2026-08-30 22:10:15 +08:00
wucm667 d5a0124637 fix(openai): isolate passthrough websocket sessions 2026-08-30 16:49:01 +08:00
alfadb 30b29e51e8 feat(ollama): 用量窗口支持挂在国产三家平台下的 Ollama Cloud 账号
Ollama Cloud 用量窗口(浏览器会话、5h/7d 快照、自动刷新、同 key 组共享)此前
只认 platform ∈ {openai, anthropic} 的 apikey 账号。但网关对 kimi/zhipu/deepseek
的 apikey 账号同样按 credentials.base_url 转发,用户把 Ollama Cloud 的 key 挂在
这三个平台下跑 Ollama Cloud 托管的 glm/kimi/deepseek 模型时,请求转发正常,用量
窗口却完全不可用。

- 平台白名单放开到 {openai, anthropic, kimi, zhipu, deepseek};type='apikey'
  与官方 ollama.com base_url 的限制不变
- Go 侧收敛为 isOllamaCloudUsagePlatform,SQL 侧收敛为
  ollamaCloudUsagePlatformsSQL,6 处白名单只引用单一来源,并补两侧互为镜像的
  漂移守卫测试
- CN 周期余额/额度探测跳过 ollama 用量账号:其探测端点由 base_url 衍生,对
  ollama.com 会被出站 URL 白名单拒绝,每轮都白跑并产生噪声
- 前端账号列表 CN 分支在 eligible 时改渲染 Ollama 用量窗口,并跳过 CN 额度/
  余额子单元格(同上,渲染出来只会是一行探测报错)
2026-08-30 16:38:11 +08:00
wucm667 9f1effd71c fix(admin): preserve quota limits on partial group updates 2026-08-30 16:36:13 +08:00
alfadb 6532d5b61e fix(frontend): unify usage window rendering and tighten alert thresholds
CN provider accounts (Kimi/Zhipu/DeepSeek) and the monitor quota view each
hand-rolled their own usage bar, so the same account column rendered three
different label styles, bar widths and threshold palettes. Both now reuse
UsageProgressBar, the component the other five platforms already share.

Also move the default utilization thresholds from >=100 red / >=80 amber to
>=90 / >=75. Turning red only once the quota is exhausted is an after-the-fact
notice rather than a warning. The remainingCapacity mode keeps its 20/50 pair,
which is already a warning semantic.

- UsageProgressBar: add labelWidth 'fixed' | 'auto' so the monitor view can
  render composite labels ("Pro/7d") without breaking column alignment; the
  fixed branch is byte-identical, so the existing platforms are untouched
- drop the superseded utilizationColor/utilizationTextColor/formatReset copies
- shorten CN window labels to 5h / 7d and remove the now-dead resetSoon keys
2026-08-30 15:34:21 +08:00
Long Li 57c76584a6 fix(codex): advertise priority tier for fast models 2026-08-30 14:21:08 +09:00
Ritel-T 82105f260a fix(billing): resolve shadow credential tier contract 2026-08-30 13:40:37 +09:00
Ritel-T 50ad6e2e50 fix(billing): narrow Codex OAuth default exception 2026-08-30 13:40:19 +09:00
Ritel-T f323d84644 test(billing): cover Codex OAuth usage pricing 2026-08-30 13:07:33 +09:00
Ritel-T 3a9070359b fix(billing): scope Codex OAuth tier trust 2026-08-30 13:07:21 +09:00
Ritel-T d39fc491ec test(openai): cover websocket tier separation 2026-08-30 13:07:07 +09:00
Ritel-T 2b8cb628b6 fix(openai): preserve tiers in messages chat fallback 2026-08-30 13:06:45 +09:00
Ritel-T 8b4b3f4a94 fix(openai): preserve tiers in responses chat fallback 2026-08-30 13:06:32 +09:00
Ritel-T 624e4eef64 fix(openai): separate outbound and observed tiers 2026-08-30 13:06:11 +09:00
wucm667 a3bbf33c05 fix(monitor): scope groups by user access 2026-08-30 09:28:40 +08:00
wucm667 7c616db07a fix(openai-ws): bridge oversized passthrough requests 2026-08-30 08:41:08 +08:00
Clansty 5fea83dcae fix: 修复429未耗尽额度仍触发账号退避
- 关闭429默认回避时不再创建OAuth运行时冷却

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

- 补充未耗尽额度和关闭开关的回归测试
2026-08-30 00:02:14 +08:00
qiujunandClaude Opus 5 0aef702b66 fix(frontend): prevent hidden cache tooltip from causing horizontal scroll
The cache token breakdown tooltip in `UsageStatsCards` was hidden with
`opacity-0`. An element with zero opacity is invisible but still takes part
in layout, so its fixed `w-56` (224px) width kept contributing to the
document's scrollable overflow.

The tooltip is anchored with `left-1/2 -translate-x-1/2`, and on mobile the
stats grid falls back to `grid-cols-2`, which puts the trigger icon close to
the right edge of the viewport. Centering a 224px box on that point pushes it
roughly 26-49px past the right edge, so `/usage` and `/admin/usage` gained a
phantom horizontal scroll with nothing visible to explain it.

Switching the hidden state to `hidden` (`display: none`) takes the tooltip out
of the flow entirely, while `group-hover:block` / `group-focus:block` keep the
existing hover and keyboard-focus behaviour. The 150ms opacity transition is
dropped, since `display` cannot be transitioned.

Verified over Chrome DevTools Protocol at 360/390/414px: `documentElement.scrollWidth`
goes from 424 back to 390 (equal to `clientWidth`) with no other overflowing
elements remaining, and the tooltip still renders at 224x86 on focus. A sweep of
all 39 application routes confirmed these were the only two affected pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:57:36 +08:00
alfadb b5827cfd54 fix(pricing): align DeepSeek billing with official peak/off-peak rates
同步 DeepSeek 官方峰谷价格与模型定价。
2026-08-29 17:30:23 +08:00
m11y ea4291a92c fix(anthropic): scope Fable scheduling threshold to Fable models
让 Fable 7d_oi 调度阈值仅限制 Fable 模型族,不暂停整个 Anthropic 账号。
2026-08-29 17:30:18 +08:00
101Harry 32ac921f24 fix(anthropic): avoid Fable OAuth system prompt refusal
修复 Fable OAuth 请求因系统提示词形态导致空响应的问题。
2026-08-29 17:29:30 +08:00
chensunlai ed12ea7163 fix(frontend): authenticate Codex API key mode inline
让 Codex API Key 模式在配置文件中正确发送 Bearer 鉴权并保留图像工具授权。
2026-08-29 17:29:26 +08:00
Ryan Chou eb4237a2b0 fix(billing): 渠道定价对带后缀的模型名不再被官方兜底价覆盖
修复带后缀模型无法命中渠道自定义定价的问题。
2026-08-29 17:29:21 +08:00
Brisbanehuang 706b5676a6 fix(groups): show API error messages on create and update
保存分组失败时显示标准化 API 错误信息。
2026-08-29 17:28:20 +08:00
wucm667 c31fe2ed9e fix(admin): preserve saved SMTP TLS mode in test endpoints
保留测试邮件端点已保存的 SMTP TLS 配置,区分省略与显式 false。
2026-08-29 17:28:00 +08:00
Wesley Liddick 987588eaa1 Merge pull request #6358 from Wei-Shaw/fix/openai-spark-model-429-rate-limit
fix(openai): scope Spark quota 429 to model
2026-08-29 15:44:37 +08:00
shaw 804679d996 fix(openai): preserve model scope on stream failover 2026-08-29 15:25:10 +08:00
shaw 571d1e1d9b fix(openai): isolate websocket semantic rate limits 2026-08-29 11:14:54 +08:00
shaw 3c22e78af0 fix(openai): preserve Spark quota reset semantics 2026-08-29 11:08:01 +08:00
shaw 5d9c7abed5 fix(openai): scope Spark quota 429 to model 2026-08-29 09:57:08 +08:00
Wesley Liddick ac18c588c8 Merge PR #6165
修复上游倍率探测导致账号列表整页刷新的问题
2026-08-29 09:12:05 +08:00
Wesley Liddick b83284071a Merge PR #5348
fix(subscription): 周/月窗口展示的重置时间与实际推进锚点对齐
2026-08-29 09:10:07 +08:00
Wesley Liddick fd13c72e95 Merge PR #6330
feat(zhipu): support team GLM Coding Plan usage query
2026-08-29 09:08:42 +08:00
Wesley Liddick 3c2e9a43b9 Merge PR #5860
fix(antigravity): preserve mixed built-in chat tools
2026-08-29 09:07:27 +08:00
Wesley Liddick ca319d09f2 Merge PR #6280
fix(update): strip hyphenated pre-release/custom suffix in version comparison
2026-08-29 09:06:10 +08:00
Wesley Liddick 46598dd491 Merge PR #6306
fix(openai): emit pre-output SSE keepalive on the /v1/responses passthrough path
2026-08-29 09:04:46 +08:00
Wesley Liddick 3045b3ade1 Merge PR #6316
fix(openai): honor Claude Code session id for messages sticky routing
2026-08-29 09:03:14 +08:00
Wesley Liddick 604360d1cc Merge PR #6328
fix(frontend): 批量编辑显式提交 codex_fingerprint_mode=off,修复无法关闭指纹收敛
2026-08-29 09:02:25 +08:00
Wesley Liddick 25dc5742ea Merge PR #6332
fix(monitor): 配额抓取在 singleflight 执行体内重查缓存,消除重复上游查询
2026-08-29 09:01:15 +08:00
Wesley Liddick 1136e290fd Merge PR #6334
fix(payment): show selected currency in recharge rate
2026-08-29 09:01:06 +08:00
Wesley Liddick 6506c0ea68 Merge PR #6343
fix(gateway): preserve Anthropic tool arguments
2026-08-29 09:00:56 +08:00
Wesley Liddick 1323d1645d Merge PR #6346
fix(keys): preserve Claude attribution headers
2026-08-29 09:00:46 +08:00
Wesley Liddick f1d845c630 fix(grok): honor prompt cache key for side calls
fix(grok): honor client prompt_cache_key over X-Grok-Conv-Id so grok-build side-calls share the main-turn cache
2026-08-29 09:00:06 +08:00
RayWangX 5688bcba91 fix(openai): honor Claude Code session for messages sticky routing 2026-08-29 08:31:15 +08:00
Jarl 1a61eb7151 test(usage): update compaction API contracts 2026-08-28 23:32:11 +08:00
Jarl 1cc6999ada feat(usage): track native compaction requests 2026-08-28 23:32:11 +08:00
风起 88cb79d8b3 fix(grok): honor client prompt_cache_key over X-Grok-Conv-Id for cache identity
grok-build CLI recap-style side-calls (turn-summary / title-refresh) send
a fresh per-call X-Grok-Conv-Id label but set body prompt_cache_key to
the parent session id, so a direct xAI connection shares the main turn's
server-side prompt cache prefix.

sub2api's explicitGrokCacheSeed preferred X-Grok-Conv-Id over the body
key, which fragmented the cache identity on every side-call and forced a
full-price replay of the whole conversation (~300K+ tokens each, ~2.3%
cache hit rate observed over 44 side-calls / 4.9M input tokens in 7
days — about a third of all grok-4.6 input).

Priority is now: CC session > generic session headers > body
prompt_cache_key > X-Grok-Conv-Id > explicitKey > previous_response_id.
IDE header-stability tests updated; new regression test locks in the
side-call/parent key-sharing contract.
2026-08-28 21:56:22 +08:00
Ryan ChouandClaude Opus 5 32ad1dcdce fix(subscription): 周/月窗口展示的重置时间与实际推进锚点对齐
订阅首窗初始化在开通日零点时,automaticWindowStartAt 会把实际推进锚点修正为
StartsAt,但展示层(calculateProgress / WeeklyResetTime / MonthlyResetTime)
仍按窗口起点+周期计算,导致仪表盘显示的重置时间早于实际重置时间(差值 =
StartsAt 的时分秒)。用户在显示的重置时间过后请求仍会收到 429
WEEKLY_LIMIT_EXCEEDED。

抽出 windowResetAnchor 供推进与展示共用,保证两者恒一致。非 legacy 锚点
(手动重置或已推进过的窗口)保持权威,不做修正。

日窗口不在本 PR 范围内:#5380 已把日额度改为按日历日对齐(DailyResetTime =
窗口起点所在日的次日 0 点),与周/月的期限对齐滚动窗口语义不同,不共用本修正。
附一个守护用例钉死这条边界。

Fixes #5348

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:46:44 +08:00
wucm667 c037766040 fix(keys): preserve Claude attribution headers 2026-08-28 20:42:58 +08:00
wucm667 da10822d7d fix(gateway): preserve Anthropic tool arguments 2026-08-28 19:08:26 +08:00
wucm667 5345881b1d Merge remote-tracking branch 'upstream/main' into fix/issue-5843-antigravity-mixed-tools
# Conflicts:
#	backend/internal/repository/group_usage_rollup_trigger_integration_test.go
2026-08-28 17:36:43 +08:00
wucm667 c5ff640dff test(repository): pin rollup trigger session timezone 2026-08-28 17:34:42 +08:00
wucm667 02eee39ddf fix(payment): show selected currency in recharge rate 2026-08-28 16:54:33 +08:00
li e652f6e20a fix(monitor): 配额抓取在 singleflight 执行体内重查缓存,消除重复上游查询
ChannelMonitorQuotaFetcher.Fetch 顶部的 cachedSnapshot 与随后的 flight.DoChan
之间存在一个窗口:goroutine A 判定缓存未命中之后、真正入队之前,goroutine B 的
flight 可能已经跑完、写好缓存,并且它的 singleflight key 也已被摘掉。A 于是不会
并入 B 的那次飞行,而是另起一个新 flight,对同一账号再打一次上游——正是
singleflight 想要消除的重复查询。

多个监控可以关联同一账号,interval 最小 15s,国产配额服务自身无缓存,
这条路径的全部意义就是防止打爆上游配额端点(见文件头注释)。

修复:把 flight 执行体提为具名方法 fetchShared,开头重查一次缓存。
这次重查必然命中,所以合并是确定的而非尽力而为:storeSnapshot 发生在执行体
返回之前,singleflight 删 key 发生在返回之后,因此「能新起一次飞行」必然蕴含
「上一次的快照已经可见」。

该窗口也是主线 CI 上 TestQuotaFetcher_ConcurrentFetchesShareSingleFlight
间歇性失败(expected: 1, actual: 2)的成因,本地 -count=900 复现 3 次(约 0.33%),
带修复后 -count=1700 零失败。

新增两条确定性用例:既有并发用例只能以约 1/300 的概率撞上该窗口,故直接钉住
窗口本身——fetchShared 就是 flight 的执行体,直接调用它等价于「已越过顶层缓存
判定、拿到属于自己的那次飞行」这一状态。另一条守住重查不得退化成无条件短路:
缓存过期后必须照常回源。
2026-08-28 16:49:49 +08:00
wall-wxk c4e46c3be8 feat(zhipu): support team GLM Coding Plan usage query
Team GLM Coding Plan accounts fail the quota probe with
"当前用户不存在coding plan" because the personal-plan query path
does not carry team context.

- quota probe: when credentials contain zhipu_organization, switch the
  Zhipu quota endpoint to the team variant (?type=2 + bigmodel-organization
  / bigmodel-project headers); personal plans keep the existing path
- account create/edit modals: optional organization/project ID inputs for
  zhipu coding-plan accounts, persisted in credentials; edit modal
  backfills them and clears back to personal plan when emptied
- add a click-to-open tooltip tutorial (zh/en) explaining how to grab the
  IDs from the bigmodel.cn web console via the /api/biz/v1/organization
  network request, with a sample URL mapping org-/proj_ segments to fields
2026-08-28 16:36:30 +08:00
li 0756c98102 fix(frontend): 批量编辑显式提交 codex_fingerprint_mode=off,修复无法关闭指纹收敛
批量编辑选「关闭(默认)」时,buildUpdatePayload 先 ensureExtra() 建出 extra
再 delete 掉唯一的键,payload 退化成 {account_ids, extra:{}},被后端
len(req.Extra) > 0 判为空更新,直接 400 "No updates provided";device/session/full
则正常。即便后端放行空 extra 也无济于事:批量更新走 JSONB 顶层合并
(extra = COALESCE(extra,'{}') || payload),删掉 payload 里的键只表示
「本次不更新该键」,清不掉账号上已有的 device/session/full。

根因是把 Create/Edit 的写法直接搬到了 Bulk:那两个表单提交完整 extra 对象、
后端 SetExtra 整体覆盖,删键确实等于清除;批量接口只合并增量键,两种持久化
语义不能共用同一套写法。

改为 off 也显式落键,与本函数里其它「关闭/清除」字段一致——codex_cli_only
直接落 false(EditAccountModal 相邻注释已写明「关闭时显式写 false,避免 extra
为空被后端忽略导致旧值无法清除」)、load_factor 落 0、proxy_id 落 0。

读取侧完全等价,不改变 #5610 定下的语义:codexFingerprintModeFromExtra 对
空值/非法值走 default 回落 off,对 "off" 命中同一分支;
ShouldEnsureCodexFingerprintSeedForExtraUpdates 也只在 device/session/full
时要种子,显式 off 不会触发种子创建。

顺带给「编辑该项」勾选框补 id(同区域 Select 早有 data-testid,兄弟开关
codex_cli_only 也有 id),否则新增用例无法定位该控件。

新增 3 条用例:主复现(默认 off + 勾选编辑 ⇒ 提交 codex_fingerprint_mode=off,
并断言 extra 非空以钉死 400 那个形状)、显式 opt-in 仍原样提交、未勾选时不写入该键。

Fixes #6327
2026-08-28 16:20:14 +08:00
Wesley Liddick 7b693ae429 Merge pull request #6188 from JialinLiu-codedance/feat/usage-requested-reasoning-effort
feat(usage): 使用记录展示映射前的推理强度
2026-08-28 16:18:50 +08:00
Wesley Liddick d4754c211a Merge pull request #6291 from Whisper-stark/fix/grok-codex-responses
fix(grok): sanitize Codex Responses requests
2026-08-28 15:58:39 +08:00
Wesley Liddick 423f895755 Merge pull request #6227 from OG-Wang/codex/promo-code-oauth-signup
fix(auth): pass registration promo codes to OAuth signup
2026-08-28 15:58:09 +08:00
Wesley Liddick c8a6e93f33 Merge pull request #6310 from fengshao1227/fix/nonstreaming-terminal-failure-failover-v2
fix(openai): 非流式路径按流式同一套判定处理 HTTP 200 终止失败事件
2026-08-28 15:57:39 +08:00
Wesley Liddick 446042e518 Merge pull request #6309 from fengshao1227/fix/easypay-resolve-relative-payurl-qrcode
fix(payment): EasyPay 补全 mapi.php 回传的相对 payurl/qrcode
2026-08-28 15:57:10 +08:00
Wesley Liddick 1e6926cb9c Merge pull request #6312 from fengshao1227/fix/openai-ws-client-close-not-account-failure
fix(openai): 入站 WS 的客户端正常关闭与断开不再计为账号故障
2026-08-28 15:56:34 +08:00
251392463 b4b5371640 fix(grok): preserve Responses vision tool output images 2026-08-28 15:38:04 +08:00
spongehah 6d339ec93b fix: honor persisted cooldown when dropping in-process account blocks
- Treat TempUnschedulableUntil/RateLimitResetAt/OverloadUntil as source of truth during account selection
- Drop stale local account-level runtime blocks with generation+deadline CAS
- Leave model-scoped transient and Grok quota blocks unchanged
2026-08-28 14:38:15 +08:00
Wesley Liddick e866ff6ec4 Merge pull request #6303 from akihitohyh/feat/per-user-public-group-access
feat(admin): 支持为单个用户限制可访问的公开分组
2026-08-28 13:19:11 +08:00
li c83dced4ba fix(openai): 入站 WS 的客户端正常关闭与断开不再计为账号故障
ingress WebSocket 收尾处只把 *service.OpenAIWSClientCloseError 且状态码为 1000
的情况认作正常关闭,其余全部落到 shouldReportOpenAIWSProxyAccountFailure ——
而它只排除 model-switch 与 session-preempted 两种。两种完全正常的结束因此被
误判为上游/账号故障:

- 客户端干净关闭:coder/websocket 直接回裸 coderws.CloseError{Code: 1000},
  ReadOpenAIWSClientMessage 把 conn.Read 的错误原样返回,没有任何一层把它包成
  *OpenAIWSClientCloseError,所以 errors.As 对该类型必然为假。
- 客户端中途断开:ReadOpenAIWSClientMessage 在 controlCtx.Done() 分支用
  StatusGoingAway(1001) 收尾并以 context.Canceled 为 cause,「只认 1000」匹配不到。

两者都会走到 ReportOpenAIAccountScheduleResult(success=false),进而喂给
ObserveOpenAIAPIKeyHealthFailure 与 scheduler.ReportResult,累积到阈值即把上游
账号熔断出调度池——客户端按一次 Ctrl-C 就在扣上游账号的健康分。

同文件的 summarizeWSCloseErrorForLog 一直用 coderws.CloseStatus 读关闭码,就在
该判断之后 12 行被调用,于是同一条 WARN 会打印
close_status=1000(StatusNormalClosure) 的同时把账号记为故障。

失败归因的政策仓库早有明文:同包 failover_loop.go 的 failoverClientGone 写着
「客户端断开后 failover 必须静默终止……并被误报成账号耗尽」。本次把 WS ingress
补齐到同一条规矩。

判定收窄:只豁免 1000 与 context.Canceled。1001 不整类放行——网关也会因自身原因
用 GoingAway 收场,客户端取消那一支已由 context.Canceled 覆盖;
context.DeadlineExceeded 同样不放行,空闲超时那条本就被包成 1000 走前一个判据,
其余 deadline 是真实停滞。

Fixes #6105
2026-08-28 13:17:43 +08:00
Wesley Liddick d674a04f2a Merge branch 'main' into feat/per-user-public-group-access 2026-08-28 13:08:41 +08:00
Wesley Liddick 9b61c1bdd9 Merge pull request #6263 from zenor0/fix/openai-reset-usage-sync
fix(openai): refresh usage after quota reset
2026-08-28 13:08:33 +08:00
Wesley Liddick add86cc3bc Merge branch 'main' into fix/openai-reset-usage-sync 2026-08-28 12:57:48 +08:00
Wesley Liddick dc332d141c Merge pull request #6260 from feeeei/fix/anthropic
fix(gateway): Anthropic/Bedrock 传输层错误转 failover + 持久故障临时摘除账号
2026-08-28 12:57:41 +08:00
Wesley Liddick 59bb131df4 Merge branch 'main' into fix/anthropic 2026-08-28 12:47:15 +08:00
Wesley Liddick 443537daae Merge pull request #6293 from specialpointcentral/fix/ws-v2-passthrough-cyber
fix(openai): detect cyber policy in ws v2 passthrough
2026-08-28 12:47:07 +08:00
Wesley Liddick b588c0bc42 Merge branch 'main' into fix/ws-v2-passthrough-cyber 2026-08-28 12:36:22 +08:00
Wesley Liddick 4e9c1d7c8b Merge pull request #6299 from william-drakemond/fix/openai-raw-stream-truncation
fix(openai): fail raw CC streams truncated before any terminal chunk
2026-08-28 12:36:14 +08:00
Wesley Liddick a0b313018a Merge branch 'main' into fix/openai-raw-stream-truncation 2026-08-28 12:26:01 +08:00
Wesley Liddick fafc4d288d Merge pull request #6278 from kk-418/fix/grok46-xhigh
fix(grok): 为 Grok 4.6 广告 xhigh 推理档位
2026-08-28 12:25:52 +08:00
Wesley Liddick de084cdfc2 Merge branch 'main' into fix/grok46-xhigh 2026-08-28 12:15:53 +08:00
Wesley Liddick 7eed2d3b7d Merge pull request #5623 from fengshao1227/fix/responses-synthesized-created-at
fix(responses): 网关合成的 Responses 对象补齐 created_at,修复严格客户端反序列化失败
2026-08-28 12:15:45 +08:00
Wesley Liddick f7dca22ea5 Merge branch 'main' into fix/responses-synthesized-created-at 2026-08-28 12:05:38 +08:00
Wesley Liddick 5eb8628ff2 Merge pull request #5620 from fengshao1227/fix/anthropic-chat-bridge-reasoning-passback
fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
2026-08-28 12:05:31 +08:00
Wesley Liddick 002aaaa3d0 Merge branch 'main' into fix/anthropic-chat-bridge-reasoning-passback 2026-08-28 11:55:10 +08:00
Wesley Liddick 0f6ad105fe Merge pull request #6246 from wucm667/fix/issue-6240-preserve-multimodal-tool-output
fix(openai): preserve multimodal client tool outputs
2026-08-28 11:55:02 +08:00
Wesley Liddick 28fa458dc7 Merge branch 'main' into fix/issue-6240-preserve-multimodal-tool-output 2026-08-28 11:37:40 +08:00
Wesley Liddick eca8d6b9ab Merge pull request #6200 from xuhaihan/fix/openai-reasoning-replay
fix(openai): normalize cross-provider reasoning replay
2026-08-28 11:37:33 +08:00
li 81ac8ccd64 fix(openai): 非流式路径按流式同一套判定处理 HTTP 200 终止失败事件
stream=false 时上游仍可能回 SSE(其他 sub2api 实例、部分 OpenAI 兼容上游),
容量/限流错误经 HTTP 200 的 response.failed / error 终止事件回传。
handleSSEToJSON 与 handlePassthroughSSEToJSON 把所有终止事件塞进固定 502
(writeOpenAINonStreamingProtocolError),而几百行外的流式读取器对同一帧走
openAIStreamFailedEventShouldFailover / openAIStreamErrorEventShouldFailover
判定并返回 UpstreamFailoverError。

同一个上游、同一个事件,仅因请求上的 stream 标志而结果相反:流式换号,
非流式把上游原文直接抛给客户端,池里还有可调度账号也不换。

新增 nonStreamingTerminalFailureFailover,在两条非流式路径上复用流式的同一套
分类,并按 terminalType 分派——裸 error 帧走更保守的那个(仅正向识别为瞬时才
换号),response.failed 走完整的那个,与流式读取器逐字一致。

安全前提:响应体已被 ReadUpstreamResponseBody 完整缓冲,判定发生在写出任何
语义字节之前。能否真正换号仍由 handler 的 openAIForwardMayFailover 用
keepalive 调整后的写出量仲裁(#3887),service 侧只额外拒绝 IsResponseCommitted
与 account 为 nil 的场景,不重复实现心跳判定,避免与 handler 口径分叉。

行为变更:未被分类为不可重试的泛化 response.failed 在非流式路径上由「回写 502」
变为「换号」——这正是与流式对齐的结果。既有用例
TestHandleSSEToJSON_ResponseFailedReturnsProtocolError 传的是 nil account,
无账号则无可换对象,故其断言原样保留,仅更名为 ...WithoutAccountReturnsProtocolError
并注明原因;带真实账号的同一报文由新增用例钉死。

Fixes #5281
2026-08-28 11:36:08 +08:00
Wesley Liddick d24de611f8 Merge branch 'main' into fix/openai-reasoning-replay 2026-08-28 11:27:39 +08:00
Wesley Liddick f83fe6435b Merge pull request #6277 from TYzzt/fix/apikey-namespace-roundtrip
fix(openai): preserve declared API-key namespace calls
2026-08-28 11:27:31 +08:00
Wesley Liddick ee2c8b97bb Merge branch 'main' into fix/apikey-namespace-roundtrip 2026-08-28 11:16:48 +08:00
Wesley Liddick d36f4dd6c6 Merge pull request #6255 from feeeei/main
fix(openai): 避免 pool 两跳重复计算 system 提示词
2026-08-28 11:16:40 +08:00
li 1e8745c887 fix(payment): EasyPay 补全 mapi.php 回传的相对 payurl/qrcode
createAPIPayment 把上游 mapi.php 返回的 payurl/payurl2/qrcode 原样落库。
EasyPay 兼容上游对该字段并不统一:部分实现回站点根相对路径(如
`/api/pay/toapp/<order>`)而非完整地址。而链路上没有任何一层修复它——
sanitizeCreatePaymentResponseDetails 只剥 NUL 字节就写进 pay_url/qr_code,
前端 PaymentQRCodeView.renderQR 又把 qr_code 直接喂给 QRCode.toCanvas。
结果二维码的载荷是一段裸路径:微信扫出来是文本,pay_url 则落到网关自己的
域名下 404,用户无法进入上游收银台。

同文件的 createRedirectPayment 本就自行拼绝对地址(apiBase + "/submit.php?"),
只有 mapi.php 这一支透传;Alipay provider 也早已写明「Setting it as QRCode
would let the frontend render an unscannable image」并拒绝产出不可扫的值。
本提交让 EasyPay 与这两处对齐:按实例配置的 apiBase 解析上游回传的引用。

仅补全以 "/" 开头的引用。带 scheme 的一律原样返回,覆盖绝对 https:// 收银台
地址与 weixin:// / wxp:// / alipays:// 等 app 深链;无前导斜杠的引用同样不动,
因为二维码载荷常是不透明 token,把 "OrderToken123" 改写成
"<apiBase>/OrderToken123" 会毁掉一个当前可用的载荷,比本 bug 更糟。

payurl2 优先于 payurl 的选择语义保持不变。

Fixes #6292
2026-08-28 11:09:34 +08:00
Wesley Liddick 96e9ab8662 Merge branch 'main' into main 2026-08-28 11:06:29 +08:00
Wesley Liddick 0eddfe1cfa Merge pull request #5395 from ryanchou1994/fix/openai-images-capability-loss-cooldown
fix(openai): 图像能力丢失时冷却 image 调度,不再反复选中坏号
2026-08-28 11:06:22 +08:00
Wesley Liddick acd2f09ddd Merge branch 'main' into fix/openai-images-capability-loss-cooldown 2026-08-28 10:54:47 +08:00
Wesley Liddick 5929cdd38c Merge pull request #6270 from fengshao1227/fix/openai-images-tool-cooldown-not-from-text-heuristic
fix(openai): 图片工具冷却不再由「模型回文字」这一请求级判据触发
2026-08-28 10:54:33 +08:00
HuntercodeT 60756c0ca4 fix(openai): emit pre-output SSE keepalive on the /v1/responses passthrough path
The passthrough streaming loop buffers leading events in `pendingLines` until
the first visible output, so a client receives **zero bytes** — not even the
HTTP response headers, since gin's ResponseWriter only commits them on the
first write — for as long as the upstream takes to produce its first visible
event.

The Forward path already recognises this exact hazard and solves it with a
downstream keepalive:

    // Track downstream writes separately from upstream reads: pre-output
    // failover can buffer response.created / response.in_progress, so
    // keepalive must be based on downstream idle time.

The passthrough path has the same buffering but never starts a keepalive.
Reasoning models routinely think for several minutes before emitting a visible
event, so intermediate proxies close the connection on idle timeout.

Production evidence from one deployment (12h window, gpt-5.6 family):

  * 44 `/v1/responses` requests produced their first visible output only after
    600–900 s, each carrying 30k–50k output tokens — the upstream had finished
    the work.
  * Every one of them was killed by an intermediate nginx `proxy_read_timeout`
    (600 s) and returned 504. The user received nothing.
  * Cutting early does not even save resources: the code deliberately keeps
    draining the upstream for usage after the client disconnects.

Fix: start the existing keepalive helper for the passthrough stream as well.

* `StartOpenAICompactSSEKeepalive` keeps its compact-marker gate; the gate-free
  body is extracted as `startOpenAISSEKeepalive` for callers that already know
  they are in an SSE context.
* The passthrough loop starts it when `gateway.stream_keepalive_interval > 0`
  and stops it immediately before its first real write, so the ResponseWriter
  is never shared.

Pre-output failover is unaffected: keepalive bytes are already excluded by
`OpenAICompactKeepaliveAdjustedWrittenSize`, which `openAIStreamClientOutputStarted`
on this very path already consults (#3887). No pendingLines and no
account-specific headers are ever emitted by a keepalive.

Behaviour is unchanged when `gateway.stream_keepalive_interval` is 0.
2026-08-28 10:19:43 +08:00
akihitohyhandClaude Opus 5 b56c61eccd feat(admin): let admins restrict which public groups a user may access
Public groups have always been bindable by every user: CanBindGroup returned
true for any non-exclusive group, and user_allowed_groups only ever carried the
exclusive groups an admin had granted. Admins had no way to hand a single user
a subset of the public groups short of converting a group to exclusive, which
changes it for everyone already using it.

A user now carries restrict_public_groups. While it is false, which is the
default and what every existing row migrates to, nothing changes: every public
group stays bindable. Once an admin turns it on for a user, that user's public
groups are narrowed to the ones listed in user_allowed_groups, the same table
that already gates exclusive groups.

The flag is an administrative control, so it rides on the admin user DTO only
and leaves the shape of the end-user endpoints alone.

The model plaza filter honours the flag as well, so a restricted user is not
shown groups they would be refused when binding a key. Anonymous visitors have
no user record and keep the previous view.

Enforcement rides on the existing CanBindGroup choke point, so it covers key
creation, key updates, and per-request authorization together. An API key bound
to a group that is later withdrawn stops working at request time rather than
lingering as a key that can be listed but not used.

The admin dialog gains a toggle over the public group list. Turning it off
re-checks every public group, so an admin cannot save a list that reads as
restrictive while the restriction itself is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 02:40:17 +08:00
Qi HU f4e3eb1c5a fix(openai): detect cyber policy in ws v2 passthrough 2026-08-27 21:50:53 +08:00
Whisper-stark fd872550d5 fix(grok): handle typed invalid tool unions 2026-08-27 21:03:23 +08:00
Whisper-stark f4820c00dc fix(grok): simplify invalid tool union roots 2026-08-27 19:24:50 +08:00
Whisper-stark de6ef7134d fix(grok): sanitize Codex Responses requests 2026-08-27 19:24:50 +08:00
Ryan ChouandClaude Opus 5 e6ea7b9af0 fix(openai): 图像能力丢失时冷却 image 调度,不再反复选中坏号
OAuth 账号被上游收走 image_generation 能力后,/v1/images/* 稳定返回同一句 400
(Tool choice 'image_generation' not found in 'tools' parameter),但网关不做
任何处置,坏号持续被排程选中(报告者实测连续 17 次同 body 400、窗口内 0 成功、
持续 3.5 小时,直到人工停调度)。

RateLimitService 的 400 分支是白名单式,只认 organization has been disabled /
credit balance / identity verification is required 三句,其余 400 明确不处理;
唯一能兜任意 400 的 tryTempUnschedulable 又要求账号开启 temp-unschedulable
开关,OAuth 账号默认没开。两条路都不命中,shouldDisable 恒为 false,也没有任何
model 作用域冷却。

修复收窄到 sub2api 自建 body 的 images 管线:buildOpenAIImagesResponsesRequest
产出的 body 必定同时带 tool_choice 与匹配的 image_generation tool,这条路上出现
该 400 只可能是上游收了账号能力。forwardOpenAIImagesOAuth 在 upstreamCtx 上打
自建标记(覆盖其下游全部三个错误分支),共用错误入口读到标记才把该 400 判为能力
丢失,对 openai:image_generation 作用域冷却 30 分钟(对齐 upstreamModelNotFound
Cooldown),不禁用整颗账号——账号对文本请求依然健康。

未采用 issue 建议①的全局签名匹配:handleOpenAIAccountUpstreamError 同时服务
passthrough 路径,客户端可自带「tool_choice 指 image_generation 但 tools 不带」
的请求触发一字不差的同一句 400,全局匹配等于任何 API 用户一句话即可禁用健康
账号。附 5 个测试,其中防毒杀用例在撤掉该闸门后会实测失败。建议②(连续 N 次
400 熔断)涉及跨请求状态与配置项,另议。

Fixes #4466

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:36:01 +08:00
WUJI-Labs 9e7aff59df fix(update): strip hyphenated suffix from version string during comparison
When running a custom build or pre-release version (e.g. v0.1.183-custom),
parseVersion failed to parse the patch segment because of the non-numeric suffix,
causing compareVersions to incorrectly report an available update.

This commit strips any hyphenated suffix before splitting SemVer components.

Signed-off-by: WUJI-Labs <zoujincool@gmail.com>
2026-08-27 15:54:22 +08:00
kk 00efee4303 🐛 fix(grok): 为 Grok 4.6 广告 xhigh 推理档位
Grok Build 的 /v1/models 原先给所有可配 reasoning 的 Grok 模型写死三档,
导致 grok-4.6 无法在 /model 里选出官方支持的 xhigh。转发与 Codex catalog
已按模型判断,现与同一条件对齐。
2026-08-27 15:50:20 +08:00
zhaotao1 00b7c855cc fix(openai): preserve declared API-key namespace calls 2026-08-27 15:22:43 +08:00
li 4a02e54171 Merge remote-tracking branch 'origin/main' into fix/anthropic-chat-bridge-reasoning-passback 2026-08-27 13:40:29 +08:00
li 9192426d2e merge: 合入 origin/main,与新增的 service_tier 字段并存
主线在 ResponsesResponse 与两处流式事件上新增了 service_tier;本分支新增的
created_at 与之并列,无语义冲突。同时跟进 ChatCompletionsResponseToResponses
新增的 functionTools 形参。
2026-08-27 13:40:07 +08:00
li d077002eba fix(openai): 图片工具冷却不再由「模型回文字」这一请求级判据触发
v0.1.181 起,/v1/images/generations 只要上游回了文字而没回图,就给账号写 30 分钟
openai:image_generation 模型级冷却。该判据由 openAIImagesTextFallbackErrorForText
从模型的自然语言输出**推断**而来,只说明这个 prompt 这一轮模型选择了说话——
仓库自己的既有用例 fixture 就是一句「Here's a polished image prompt for your request.」。

更糟的是同一个错误被判为可重试(502 → IsOpenAIImagesRetryableUpstreamError)并驱动
newOpenAIAccountFailoverError:一次闲聊回复会被逐个账号重试,沿途把每个账号都冷却掉,
表现为「开启后没多久账号就全部被限制」。

改为只在上游 error 帧点名该状态时才写冷却;网关自行推断的判据打上
SynthesizedFromModelText 标记并跳过。换号行为一字未动。

依据是仓库既有政策(alpha/search 路径原话):工具端点的单次失败
「仍允许本次请求换号,但不修改任何账号状态」。

Fixes #6171
2026-08-27 13:36:50 +08:00
zenor0 5f09442fce fix(openai): refresh usage after quota reset 2026-08-27 10:09:42 +08:00
feeeei 44003d7f6b fix(gateway): Anthropic/Bedrock 传输层错误转 failover + 持久故障临时摘除账号
上游 Do/DoWithTLS 返回非 HTTP 错误(代理/DNS/TCP/TLS)时,Anthropic 五条转发路径
(forward/apikey 透传/Bedrock/ForwardAsResponses/ForwardAsChatCompletions)原先
直接向客户端写 502 且不换号,单账号网络故障期间该账号仍持续被调度,请求全量失败。

对齐 OpenAI 侧 handleOpenAIUpstreamTransportError 的既有语义:
- 传输层错误统一返回 *UpstreamFailoverError(502),由 handler failover 循环换号,
  耗尽后才向客户端渲染错误;service 不再写响应
- 持久性故障(connection refused/no route/DNS not found/代理认证失败)额外临时
  摘除账号 10 分钟(仅写库,Anthropic 调度以持久化状态为准,无内存快路径)
- context.Canceled 保持原样返回:不换号也不摘账号
- 分类器改为平台无关命名(classifyUpstreamTransportError)供两侧共用
- Ops 错误事件保留各路径原有字段(UpstreamURL/DurationMs/Passthrough)
2026-08-27 09:56:41 +08:00
Lawrence e1eff57c25 fix(auth): preserve promo codes through OAuth registration 2026-08-26 21:12:52 +08:00
feeeei d881bfc0de fix(openai): 避免 pool 两跳重复计算 system 提示词 2026-08-26 19:55:49 +08:00
wucm667 6ff771d3d3 feat(openai): configure image tool cooldown 2026-08-26 19:07:39 +08:00
Wesley Liddick efb46db0a9 Merge pull request #5926 from baryon/contrib/routed-codex-model-catalog
feat(gateway): generate complete routed Codex model catalogs
2026-08-26 15:49:38 +08:00
shaw 8ba81615e0 merge: resolve conflict with main (upstream endpoint observability)
Merge origin/main into contrib/routed-codex-model-catalog.

Single conflict in openai_gateway_forward.go: main added
ClearActualOpenAIUpstreamEndpoint + SetActualOpenAIUpstreamEndpoint
(commit 4795650d2) at the same insertion point where the PR added
filterOpenAIResponsesNoneReasoningEffortForAccount. Both changes
are independent — endpoint observability first, then body filtering.
2026-08-26 15:29:01 +08:00
wucm667 50ba146298 fix(openai): preserve multimodal client tool outputs 2026-08-26 15:04:17 +08:00
Wesley Liddick b9083fc7ad Merge pull request #6211 from Monika-shipship/codex/ws-v2-tool-id-regression
test(openai): 补充 OAuth Responses WS v2 历史 ID 清洗回归测试
2026-08-26 15:03:24 +08:00
Wesley Liddick 1bf76d26d7 Merge pull request #6229 from Wei-Shaw/fix/openai-upstream-endpoint-observability
fix(openai): 记录真实上游端点避免错误误报
2026-08-26 15:03:11 +08:00
Long Li 2abce65031 fix(codex): harden routed catalog capability sync 2026-08-26 12:46:31 +09:00
haruka 1ee929e4b9 fix: broadcast channel cache invalidations 2026-08-26 11:16:32 +08:00
shaw 6ca1e15b0a chore: update sponsors 2026-08-26 09:58:16 +08:00
Long LiandCursor 195b219707 fix(codex): isolate API-key catalog cache and DeepSeek Codex defaults
Copy cached API-key manifests before group-specific mutation, use DeepSeek
model IDs for Codex fallbacks, omit unsupported config.toml effort, and
drop wildcard mapping keys from generated catalogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:56:17 +09:00
shaw 66d664ff08 chore: update sponsors 2026-08-26 09:53:47 +08:00
shaw 4795650d21 fix(openai): record actual upstream endpoint on errors 2026-08-26 09:42:28 +08:00
Rick d522aed65e fix: preserve promo codes for OAuth signup 2026-08-26 08:58:22 +08:00
github-actions[bot] 7634e3c23b chore: sync VERSION to 0.1.183 [skip ci] 2026-08-25 13:53:14 +00:00
Monika d8694f03bc test(openai): cover WSv2 cleanup of stale native tool IDs 2026-08-25 21:45:24 +08:00
Wesley Liddick e8cb019fab Merge pull request #6078 from YogaSakti/fix/responses-custom-tool-call-id
fix(openai): keep restored tool-call item IDs typed
2026-08-25 21:32:34 +08:00
Wesley Liddick 7a7bd3729c Merge pull request #6166 from jyking/fix/email-bind-alias-dedup
fix(auth): 邮箱换绑增加别名与并发守卫
2026-08-25 21:31:39 +08:00
Wesley Liddick 49ad7021d7 Merge pull request #6164 from jimbirthday/fix/openai-sticky-capacity-spillover
fix(openai): preserve sticky binding on capacity spillover
2026-08-25 21:28:38 +08:00
Wesley Liddick 4952b919a4 Merge pull request #6204 from wucm667/fix/issue-6203-kimi-concurrency-403
fix(kimi): keep concurrency-limit 403 recoverable
2026-08-25 21:28:08 +08:00
Wesley Liddick fa0685a4a3 Merge pull request #6175 from wucm667/feat/issue-6172-antigravity-token-clamp
fix(antigravity): clamp compatible token limits
2026-08-25 21:27:57 +08:00
Wesley Liddick 8040428716 Merge pull request #6201 from xuhaihan/fix/codex-session-id-affinity
fix(openai): honor Codex session-id header
2026-08-25 21:27:24 +08:00
Wesley Liddick 4ff136cfd9 Merge pull request #6193 from antTing/codex/fix-channel-monitor-v2-nullif
fix(channel-monitor-v2): fix composite platform aggregation SQL
2026-08-25 21:26:32 +08:00
Wesley Liddick 832cf4df65 Merge pull request #6208 from Wei-Shaw/fix/openai-oauth-429-quota-scheduling
fix(openai): pause OAuth accounts on exhausted quota 429
2026-08-25 20:41:41 +08:00
Long LiandCursor 77b6c5bb0c merge: sync contrib/routed-codex-model-catalog with upstream v0.1.182
Keep catalog membership on schedulable accounts and intersect advertised
capabilities across all active group members that map an alias. Preserve
upstream plugin, service-tier, and models-list body-limit changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:28:57 +09:00
shaw f1aadd48d5 fix(openai): pause OAuth accounts on exhausted quota 429 2026-08-25 20:25:22 +08:00
Long LiandCursor 5934981e22 test(codex): cover unschedulable catalog capability intersection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:02:47 +09:00
Long LiandCursor db01fb98f8 fix(codex): keep catalog capabilities stable when accounts are unschedulable
Intersect advertised alias capabilities across all active group members
that map the model, not only currently schedulable accounts, so rate-limits
cannot widen image input or context windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:01:58 +09:00
wucm667 3802268e2d fix(kimi): keep concurrency 403 recoverable 2026-08-25 19:40:39 +08:00
hank 8e60d57475 fix(openai): honor Codex session-id header 2026-08-25 17:08:27 +08:00
hank 32064d39e7 fix(openai): normalize cross-provider reasoning replay 2026-08-25 17:04:38 +08:00
hank 92a550973a fix(accounts): reauthorize OpenAI with refresh token 2026-08-25 16:52:29 +08:00
暴走的小笼包 b20f29d115 fix(channel-monitor-v2): fix composite platform aggregation SQL 2026-08-25 16:36:04 +08:00
刘佳林 a8cfe746b3 test(usage): cover user and admin reasoning effort page display
Mount the real usage tables and assert users only see the requested
effort, while admins can reveal mapped effort via column settings.
2026-08-25 15:49:04 +08:00
刘佳林 5705f4a4aa fix(usage): hide mapped reasoning effort from users
Users only see the reasoning effort they requested. Admins still see
the requested value plus the forwarded mapped value, matching the
model column's requested vs upstream split.
2026-08-25 15:36:22 +08:00
刘佳林 11ada80d58 feat(usage): 使用记录展示映射前的推理强度
用量行新增 requested_reasoning_effort,记录分组策略与模型族改写前的客户端请求值;推理强度列与模型列一致,主行显示请求值,有映射时用 ↳ 展示实际转发值。
2026-08-25 15:10:52 +08:00
wucm667 1a9898a6a4 fix(antigravity): clamp compatible token limits 2026-08-25 13:13:08 +08:00
github-actions[bot] aa2c4e8d13 chore: sync VERSION to 0.1.182 [skip ci] 2026-08-25 04:47:52 +00:00
Wesley Liddick 5a7d469622 Merge pull request #6157 from LiPu-jpg/fix/issue-6147-responses-lite-parallel
fix(openai): enforce Responses Lite parallel tool calls across account types
2026-08-25 12:31:32 +08:00
shaw 095b525360 fix(openai): pin Responses Lite parallel tool calls 2026-08-25 12:14:52 +08:00
Wesley Liddick 027d442f9b Merge pull request #6101 from jianjianai/fix/composite-group-channel-monitor-v2
fix(monitor-v2): 修复Composite分组错误事实未解析到真实账号平台导致渠道监控V2无效
2026-08-25 11:27:03 +08:00
Wesley Liddick 810d50a00f Merge pull request #6155 from HypoxanthineOvO/fix/composite-kimi-k3-routing
fix(composite): route Kimi Code K3 model IDs
2026-08-25 11:13:59 +08:00
Wesley Liddick 41d712be09 Merge pull request #5658 from william-drakemond/fix/opencode-go-usage-limit-reset
fix(openai): honor OpenCode Go usage reset durations
2026-08-25 11:05:15 +08:00
Wesley Liddick 3c714d8736 Merge pull request #6152 from ranxi2001/fix/payment-result-balance-refresh
fix(payment): refresh balance after fulfillment
2026-08-25 11:04:10 +08:00
Wesley Liddick 636d7debf9 Merge pull request #6132 from wucm667/fix/issue-6125-anthropic-cache-ttl
fix: prevent duplicate Anthropic cache TTL billing
2026-08-25 11:02:29 +08:00
Wesley Liddick 3dd717ab0d Merge pull request #5920 from wucm667/fix/issue-5884-antigravity-sonnet46
fix(antigravity): define Sonnet 4.5 to 4.6 compatibility routing
2026-08-25 11:01:34 +08:00
Wesley Liddick 1dc1b44268 Merge pull request #6149 from SipengXie2024/fix/oauth-image-verbatim-prompt
fix(openai): preserve OAuth image prompts verbatim
2026-08-25 11:01:20 +08:00
jyking 4ca86c52e3 fix(auth): 邮箱换绑增加别名与并发守卫 2026-08-25 10:40:02 +08:00
yardbirds0 3f1581b2d3 修复上游倍率探测导致账号列表整页刷新的问题 2026-08-25 10:34:46 +08:00
超级英雄吉姆 e55727d4c8 fix(openai): preserve sticky binding on capacity spillover 2026-08-25 10:27:48 +08:00
William DrakeandClaude Opus 5 b7ec3cdadf fix(openai): fail raw CC streams truncated before any terminal chunk
The raw Chat Completions passthrough returned a nil error whenever the upstream
body ended, so an upstream that cut the SSE stream mid-generation surfaced as
HTTP 200 with usage 0/0: the client kept a half-finished answer, the request was
not counted as an SLA failure, and ops saw nothing. Observed against
opencode.ai/zen/go (deepseek-v4-pro): 11 of 1346 streams in 24h recorded a first
token but no final usage, alongside a concurrent `connection reset by peer`
against the upstream edge.

Track whether the upstream ever emitted a terminal signal — [DONE], a usage
chunk, or a finish_reason — and treat a stream that ends without one as an
upstream failure. Matching only [DONE] would misfire on compatible upstreams
that EOF right after their last chunk and would drop billing for tokens that
were actually delivered, so all three count.

  - nothing written to the client yet: UpstreamFailoverError, retried on another
    account with no client-visible half stream
  - bytes already on the wire: typed openAIUpstreamStreamReadError, so the
    handler appends an SSE error frame and marks the request an SLA failure

Client cancel/disconnect keeps the previous semantics — there the upstream read
error is indistinguishable from a truncation, so partial usage is still billed
rather than blamed on the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:29:37 -07:00
shaw 3b7753a8e4 chore: update sponsors 2026-08-25 08:51:58 +08:00
Jiao Ziang d5e43ef7d1 fix(openai): normalize Lite requests in WS HTTP bridge 2026-08-25 03:46:46 +08:00
Jiao Ziang d6012b0b35 fix(openai): preserve numeric precision in Lite payloads 2026-08-25 01:05:28 +08:00
Jiao Ziang 53d76ad800 fix(openai): enforce Responses Lite tool call mode 2026-08-25 00:21:29 +08:00
HypoxanthineOvO 4347e5555b fix(composite): route Kimi Code K3 model IDs 2026-08-24 23:09:30 +08:00
SipengXie2024 329b92ef04 fix(openai): preserve OAuth image prompts verbatim 2026-08-24 14:37:48 +00:00
github-actions[bot] e2d9b823f6 chore: sync VERSION to 0.1.181 [skip ci] 2026-08-24 14:35:57 +00:00
ranxi2001 eb594eefc9 fix(payment): refresh balance after fulfillment 2026-08-24 22:30:24 +08:00
wucm667 71aa6e3574 fix(antigravity): preserve explicit Sonnet 4.5 routing 2026-08-24 16:07:27 +08:00
wucm667 99ec347eaf fix(antigravity): migrate legacy Sonnet tests to 4.6 2026-08-24 15:58:47 +08:00
wucm667 bc4a9ae432 fix: prevent duplicate Anthropic cache TTL billing 2026-08-24 11:05:57 +08:00
Long Li fc589bce17 fix(codex): address routed catalog review feedback 2026-08-23 15:43:57 +09:00
jjaw 49752060fc fix(monitor-v2): resolve composite group error facts to concrete account platform
Composite groups are a routing layer. The usage-side aggregation already
maps their traffic to the concrete account platform via
usageLogEffectivePlatformExpr, but the error aggregation read
ops_error_logs.platform verbatim. Pre-routing failures on composite groups
fall back to the group platform 'composite', which is never an enabled
monitor v2 config platform, so those error facts were filtered out of
every channel monitor v2 query and composite groups looked permanently
healthy.

Join groups/accounts in the error dedup CTE and resolve the effective
platform the same way as the usage side: account platform first, then a
real recorded platform, else 'unknown'. The window delete+rewrite
recompute rewrites historical facts with the corrected key.
2026-08-23 02:33:09 +08:00
William Drake a6b11ccced fix(openai): honor OpenCode Go usage reset durations
Recognize GoUsageLimitError responses and parse both single and composite reset durations from their human-readable messages. Keep API-key accounts rate-limited until the parsed reset instead of falling back to the short default cooldown.
2026-08-22 10:26:56 -07:00
Yoga Sakti e0e5e45cd2 fix(openai): keep restored tool-call item IDs typed
The Responses client-tool restore raised an upstream function_call back to
custom_tool_call and tool_search_call without touching its item ID, so the
client received a custom_tool_call carrying the upstream's fc_ ID. That
combination is invalid: once the item lands in the client's history, any
later replay to an upstream that validates item IDs fails the whole request
with "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with
'ctc'", and every following message re-submits the same bad item.

Re-prefix the ID to match the type it is raised to, keeping the suffix so
the ID stays stable and unique per upstream item. IDs without a known
tool-call prefix are left alone rather than guessed at.

The lowering direction has to reverse this, because the WebSocket HTTP
bridge replays restored items back to the upstream and would otherwise lose
the upstream's own ID. normalizeLoweredFunctionItemID now maps a ctc_/tsc_
call ID back to fc_ instead of dropping it, which makes the round trip
lossless; output item IDs (ctco_/tso_) still have no function-protocol
counterpart and are still dropped. The helper was renamed from
dropInvalidLoweredFunctionItemID to match what it now does.

The stream restorer keeps the upstream ID for its own bookkeeping so later
upstream events still match the call, and emits the client-facing ID on
output_item.added/done and the custom_tool_call_input events.

Closes #6071
2026-08-22 14:03:25 +07:00
Long Li e39fce270d fix(codex): sync routed capabilities from upstream
Account model mappings decide availability, but generated Codex catalogs previously inferred capabilities from local model-name tables. Compatible upstreams can expose unknown models such as OpenCode x-preview-f-free, so reasoning levels, image input, and context limits were missing or wrong.

Sync capability metadata from the account model endpoint, enrich incomplete lists from the Models.dev provider matching the account base URL, and persist only complete snapshots. Mixed groups consume the safe intersection across schedulable accounts. When IDs sync without complete metadata, return an explicit warning and preserve the previous snapshot.

Third-party Responses providers often omit /models. If that endpoint returns 404 or 405 and the account already has concrete model mappings, use those configured upstream IDs for capability enrichment. Authentication, rate-limit, server, and network failures remain hard errors, and metadata is never guessed across providers by model name alone.

Advertise a single none choice for non-reasoning models so current Codex can select them, then omit that catalog placeholder when forwarding to compatible Responses upstreams while preserving official OpenAI request semantics.
2026-08-22 15:33:12 +09:00
Long Li 5a2f542ab0 fix(codex): prefer configured models over discovery
OpenAI groups can mix OAuth accounts with Responses-compatible API key accounts whose upstreams do not implement /models. The manifest handler previously selected one account and fetched its catalog before merging model_mapping aliases, so a permanent 404 returned 502 and hid models the administrator had already configured.\n\nTreat explicit model mappings from schedulable accounts as the authoritative Codex catalog. Build that catalog locally before account selection, preserve group filtering and final-body ETags, and retain the existing upstream discovery and failover path only when no mappings exist. Composite groups keep their existing account-first catalog behavior.
2026-08-22 14:43:40 +09:00
Long Li b16ed03cad fix(codex): align routed catalogs with actual routes
Anthropic fallbacks previously reused Antigravity defaults, leaking Gemini models into Claude-only groups. Composite aliases were also emitted with generic metadata and could be scheduled to accounts that did not own the exact mapping, while generated configs could default to a model absent from the downloaded catalog.

Keep provider defaults separated, derive alias capabilities from unique upstream targets with conservative fallback, require exact mapping ownership during scheduling, filter media targets, and choose generated config defaults from the fetched catalog.
2026-08-22 14:43:40 +09:00
Long Li 3e98a5a1ad fix(composite): route exact account model aliases
Composite model catalogs expose exact account model_mapping aliases, but request routing only considered explicit routes or model-name detection. Unprefixed aliases were selectable but unroutable.

Resolve unique exact account ownership before the detector, keep explicit routes authoritative, and fail closed when an alias is claimed across platforms.
2026-08-22 14:43:40 +09:00
Long Li e471be7302 feat(codex): complete routed model catalogs
Codex treats the downloaded model catalog as a capability contract. Slug-only entries hide supported reasoning levels, can advertise invalid image inputs, and expose automatic or media-only models in the model picker. Generate complete route-aware metadata, preserve official manifests, filter non-agent models, and compute validators from the final catalog.
2026-08-22 14:43:40 +09:00
Long Li 22e1b8144a feat(gateway): expose routed Codex model catalogs
Codex model discovery expects a top-level models manifest, but Composite and other non-OpenAI groups either reached the OpenAI live-manifest handler or had no Codex-specific response at all. API key users also had no supported way to fetch that manifest and reference it from config.toml.

Generate minimal manifests from each group's effective model list while preserving the official OpenAI live path and the ordinary /models response. Add the Use Key flow for authenticated catalog download and model_catalog_json configuration without writing the API key into the downloaded file.
2026-08-22 14:43:40 +09:00
wucm667 b1737cc841 fix(antigravity): preserve mixed built-in chat tools 2026-08-20 00:29:33 +08:00
li 3c5553e253 fix(responses): 网关合成的 Responses 对象补齐 created_at,修复严格客户端反序列化失败
严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把 created_at 声明为
必填字段,缺失即 `missing field 'created_at'` 直接读不出响应。

apicompat.ResponsesResponse 此前根本没有这个字段,六个构造点全部不写:
Chat→Responses 与 Anthropic→Responses 两座桥的非流式结果、response.created、
终止事件,以及两处手写的 response.failed 帧。

两个流式 state 早就在 New*State() 里采集了 Created 时间戳,只是没有出口——
反方向(Responses→Chat)每个 chunk 都老老实实发 created,正方向直接丢在地上。

原生 Responses 透传走 gjson/sjson 字节级改写,保留上游原样,不受影响;
本次只补合成路径。

Fixes #5601
2026-08-14 11:05:29 +08:00
li 901a77cfb1 fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
/v1/messages 打到只支持 Chat Completions 的 OpenAI 兼容上游时,
anthropicAssistantToChatMessages 把历史 assistant 消息里的 thinking 块整块丢弃。
DeepSeek 的 thinking mode 要求产生工具调用的 reasoning_content 随该 assistant
消息回传,缺失即 400 —— 表现为「单轮正常,一进多轮工具对话必现失败」。

这些 thinking 块本来就是同一个文件的出站方向
(chatMessageToAnthropicBlocks) 用上游 reasoning_content 生成的,客户端只是
原样回传:桥自己造出来的东西,回来时被自己丢掉。

兄弟路径 Responses→Chat (buildChatMessagesFromItems 的 pendingReasoning) 早已
处理同一约束。本次把同一策略补到漏掉的这条桥上,作用域严格对齐:reasoning 只挂
到带 tool_calls 的 assistant 消息,纯文本轮次维持现状。

Fixes #5528
2026-08-14 10:33:47 +08:00
Nick 89ba561a6f fix(backup): keep the S3 secret encrypted when a save inherits it
UpdateS3Config only encrypted the SecretAccessKey on the branch where the
caller supplied a new one. The other branch inherits the stored secret
through loadS3Config, which *decrypts* it — so that path wrote the
plaintext straight back to the settings row and overwrote the ciphertext.

It is the normal path, not an edge case. UpdateS3Config clears the secret
in its own return value, so the admin form's field is empty afterwards.
Any later save — changing the endpoint, saving the schedule, pressing save
twice — sends an empty secret and takes the inheriting branch. One extra
save is enough to leave the secret in plaintext at rest.

Nothing surfaced because loadS3Config falls back to the raw value when
decryption fails, for compatibility with pre-encryption rows. Backups kept
working and the only trace was a line per read:

  [Backup] S3 SecretAccessKey 解密失败(可能是旧的未加密数据):
  decrypt: cipher: message authentication failed

Seen on a live install: the stored value was 64 characters, exactly the
plaintext length of an R2 secret, where AES-GCM + base64 would have been
124. The compatibility fallback turns a one-time write bug into a
permanent plaintext secret.

It also feeds itself: pg_dump includes the settings table, so the backup
uploaded to the bucket carries the credentials for that same bucket.

Fix: hoist the encryption out of the if/else so it runs for a non-empty
secret whichever way it arrived. The ephemeral-key guard (#4524) moves
with it, and the no-secret-at-all case still skips encryption entirely, so
an update with no stored secret keeps working under an auto-generated key.

The existing KeepExistingSecret test passed either way, because it only
asserted loadS3Config returns the plaintext — true with the fallback too.
The new test asserts on the row itself: still ciphertext after a second
save, not double-wrapped after a third.

Checked the new test fails without the fix and passes with it.
2026-07-25 19:57:51 +07:00
1534 changed files with 138118 additions and 9734 deletions
+107
View File
@@ -0,0 +1,107 @@
# Security Policy
[English](#english) | [中文](#中文)
## English
### Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.**
Report them privately through GitHub's private vulnerability reporting:
**[Report a vulnerability](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
(Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue.
If you cannot use GitHub private vulnerability reporting, email **dev@sub2api.org** instead, with `[Security]` in the subject line.
A useful report includes:
- The affected component and version (release tag or commit)
- Impact: what an attacker can do and which privileges they need
- Minimal reproduction steps or a proof of concept
- Any non-default configuration required to trigger the issue
- A suggested fix, if you have one
### Supported Versions
Sub2API releases frequently, and fixes ship in a new release rather than being backported.
| Version | Supported |
| ------- | --------- |
| Latest release | ✅ |
| Older releases | ❌ |
Please confirm that the issue reproduces on the latest release or on `main` before reporting.
### Scope
In scope: the Sub2API backend, frontend, official Docker images, and deployment files in this repository.
Usually out of scope:
- Issues that require already-compromised admin credentials or server access
- Risks arising from explicitly weakening documented security settings (for example, disabling `security.url_allowlist`)
- Vulnerabilities in upstream AI providers or third-party dependencies with no Sub2API-specific impact (please report those upstream)
- Missing best-practice headers or scanner output without a demonstrated impact
### Disclosure Process
1. We acknowledge the report and assess its validity and severity.
2. We develop a fix privately in the advisory and may ask you to verify it.
3. We publish a release containing the fix, then publish the GitHub Security Advisory (with a CVE when applicable), crediting you unless you prefer to stay anonymous.
Please keep details private until the advisory is published. This is a volunteer-maintained project, so response times are best effort.
## 中文
### 报告漏洞
**请不要通过公开的 Issue、Pull Request 或 Discussion 报告安全漏洞。**
请使用 GitHub 私密漏洞报告功能提交:
**[提交漏洞报告](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
(仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。
如果无法使用 GitHub 私密漏洞报告,也可以发送邮件至 **dev@sub2api.org**,邮件标题请注明 `[Security]`。
报告中建议包含:
- 受影响的组件与版本(Release tag 或 commit)
- 影响:攻击者能做什么、需要什么权限
- 最小复现步骤或 PoC
- 触发问题所需的非默认配置
- 修复建议(如有)
### 支持的版本
Sub2API 发版频繁,安全修复随新版本发布,不向旧版本回移。
| 版本 | 是否支持 |
| ---- | -------- |
| 最新 Release | ✅ |
| 更早的版本 | ❌ |
报告前请先确认问题在最新 Release 或 `main` 上仍可复现。
### 范围
范围内:本仓库中的 Sub2API 后端、前端、官方 Docker 镜像与部署文件。
通常不在范围内:
- 需要事先已掌握管理员凭据或服务器权限才能利用的问题
- 因主动关闭文档中的安全配置(如关闭 `security.url_allowlist`)而产生的风险
- 上游 AI 服务商或第三方依赖自身的漏洞,且对 Sub2API 无特定影响(请向上游报告)
- 仅缺少最佳实践响应头或扫描器输出,但未证明实际影响
### 披露流程
1. 我们确认收到报告,并评估其有效性与严重程度。
2. 我们在安全公告中私下开发修复,可能请你协助验证。
3. 我们先发布包含修复的版本,再公开 GitHub 安全公告(适用时申请 CVE),并致谢报告者(如你希望匿名则不署名)。
在公告发布前请勿公开漏洞细节。本项目由志愿者维护,响应时间尽力而为。
+6 -6
View File
@@ -3,16 +3,16 @@ exceptions:
- package: xlsx
advisory: "GHSA-4r6h-8v6p-xvw6"
severity: high
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)"
mitigation: "Load only on export; restrict export permissions and data scope"
expires_on: "2026-10-06"
reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2023-30533)"
mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry"
expires_on: "2027-01-06"
owner: "security@your-domain"
- package: xlsx
advisory: "GHSA-5pgg-2g8v-p4x9"
severity: high
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)"
mitigation: "Load only on export; restrict export permissions and data scope"
expires_on: "2026-10-06"
reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2024-22363)"
mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry"
expires_on: "2027-01-06"
owner: "security@your-domain"
- package: lodash
advisory: "GHSA-r5fr-rjxr-66jc"
+1
View File
@@ -0,0 +1 @@
__pycache__/
+30
View File
@@ -0,0 +1,30 @@
# Release matrix
The release workflow builds the frontend once, then runs each configured Go target on its own Linux runner. `CGO_ENABLED=0` permits cross-compilation. The target matrix is read from `.goreleaser.yaml`, including its exclusions; simple releases select Linux amd64 only.
Each build uses GoReleaser OSS in snapshot mode with the selected release version and one target. Archive naming, bundled files, Go flags and release templates remain in the existing GoReleaser configurations. Every archive is accompanied by its source commit, target, version and SHA256. The publishing job verifies the complete matrix before building images or publishing. It uses GoReleaser's `extra_files` support to publish existing archives and checksums, with builds disabled. No Pro license is needed.
Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Save uses the original restore key, even if a build hook changes `go.sum`. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description.
All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. Helper scripts come from the workflow revision and are passed as a run-local artifact, so older application tags do not need to contain the new scripts. The workflow serializes release runs to prevent simultaneous updates to moving image tags.
## Validate without publication
From a branch containing this workflow:
```bash
gh workflow run release.yml --ref <branch> \
-f tag=<branch> -f dry_run=true -f simple_release=false
```
A dry run builds all selected archives and both runtime images, verifies artifact provenance and produces the final checksum file. It exports images locally as OCI archives instead of pushing them. It skips registry logins, GitHub Release publication, DockerHub description updates, Telegram notifications and VERSION synchronization. Test the simple path separately with `simple_release=true`.
Dry-run artifacts are available in the Actions run, including `release-dry-run-report`. Compare job start/end times, GoReleaser's build duration and cache restore results. Do not present an initial cold-cache run as a warmed-cache benchmark; publishing network time is not measured by dry runs.
Helper checks:
```bash
python -m pip install -r .github/release-tools/requirements-release.txt
python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py'
bash -n .github/release-tools/release-images.sh
```
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
: "${RELEASE_VERSION:?}" "${RELEASE_SHA:?}" "${GITHUB_REPOSITORY:?}" "${RUNNER_TEMP:?}"
owner=${GITHUB_REPOSITORY%%/*}
registries=("ghcr.io/${owner,,}/sub2api")
if [[ ${SIMPLE_RELEASE:-false} != true && ${DOCKERHUB_USERNAME:-skip} != skip ]]; then
registries+=("${DOCKERHUB_USERNAME}/sub2api")
fi
arches=(amd64 arm64)
if [[ ${SIMPLE_RELEASE:-false} == true ]]; then arches=(amd64); fi
for arch in "${arches[@]}"; do
args=(--platform "linux/$arch" --file ".release-context/$arch/Dockerfile"
--label "org.opencontainers.image.version=$RELEASE_VERSION"
--label "org.opencontainers.image.revision=$RELEASE_SHA"
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY")
for registry in "${registries[@]}"; do
args+=(--tag "$registry:$RELEASE_VERSION-$arch")
if [[ ${SIMPLE_RELEASE:-false} == true ]]; then
args+=(--tag "$registry:$RELEASE_VERSION" --tag "$registry:latest")
fi
done
if [[ ${DRY_RUN:-false} == true ]]; then
args+=(--output "type=oci,dest=$RUNNER_TEMP/sub2api-$arch.oci.tar")
else
args+=(--push)
fi
docker buildx build "${args[@]}" ".release-context/$arch"
done
if [[ ${DRY_RUN:-false} != true && ${SIMPLE_RELEASE:-false} != true ]]; then
major=${RELEASE_VERSION%%.*}
minor=${RELEASE_VERSION#*.}; minor=${minor%%.*}
for registry in "${registries[@]}"; do
docker buildx imagetools create \
--tag "$registry:$RELEASE_VERSION" --tag "$registry:latest" \
--tag "$registry:$major.$minor" --tag "$registry:$major" \
"$registry:$RELEASE_VERSION-amd64" "$registry:$RELEASE_VERSION-arm64"
done
fi
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Split GoReleaser builds across runners without requiring GoReleaser Pro."""
import argparse
import hashlib
import itertools
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
from datetime import datetime, timezone
import yaml
FULL_CONFIG = Path('.goreleaser.yaml')
SIMPLE_CONFIG = Path('.goreleaser.simple.yaml')
VERSION_FILE = Path('backend/cmd/server/VERSION')
VERSION_RE = re.compile(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?')
def config(simple=False):
return yaml.safe_load((SIMPLE_CONFIG if simple else FULL_CONFIG).read_text())
def targets(simple=False):
build = config()['builds'][0]
result = []
for goos, goarch in itertools.product(build['goos'], build['goarch']):
item = {'goos': goos, 'goarch': goarch}
if any(all(item.get(k) == v for k, v in rule.items()) for rule in build.get('ignore', [])):
continue
if not simple or item == {'goos': 'linux', 'goarch': 'amd64'}:
result.append(item)
if not result:
raise ValueError('empty release target matrix')
return result
def archive_name(version, target):
if not VERSION_RE.fullmatch(version) or target not in targets():
raise ValueError('invalid release version or target')
suffix = 'zip' if target['goos'] == 'windows' else 'tar.gz'
return f"sub2api_{version}_{target['goos']}_{target['goarch']}.{suffix}"
def sha256(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def plan(args):
sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
if args.dry_run:
version = VERSION_FILE.read_text().strip()
tag = 'v' + version
else:
tag = args.ref
version = tag.removeprefix('v')
if not tag.startswith('v') or not VERSION_RE.fullmatch(version):
raise ValueError('publishing requires a v-prefixed release version tag')
tagged_sha = subprocess.check_output(['git', 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'], text=True).strip()
if sha != tagged_sha:
raise ValueError('checkout does not match the selected release tag')
if not VERSION_RE.fullmatch(version):
raise ValueError('invalid VERSION')
VERSION_FILE.write_text(version + '\n')
result = {'sha': sha, 'tag': tag, 'version': version,
'owner_lower': os.environ.get('GITHUB_REPOSITORY_OWNER', '').lower(),
'simple': str(args.simple).lower(), 'dry_run': str(args.dry_run).lower(),
'date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'matrix': json.dumps({'include': targets(args.simple)}, separators=(',', ':'))}
with Path(os.environ['GITHUB_OUTPUT']).open('a') as output:
for key, value in result.items():
output.write(f'{key}={value}\n')
def generate_config(args):
data = config(args.simple if args.mode == 'publish' else False)
data['snapshot'] = {'version_template': '{{ .Env.RELEASE_VERSION }}'}
data['dockers'] = []
data['docker_manifests'] = []
if args.mode == 'build':
target = {'goos': args.goos, 'goarch': args.goarch}
if target not in targets():
raise ValueError('unsupported build target')
for build in data['builds']:
build['goos'], build['goarch'], build['ignore'] = [args.goos], [args.goarch], []
build['ldflags'] = [re.sub(r'{{\s*\.Date\s*}}', '{{ .Env.RELEASE_DATE }}', flag)
for flag in build.get('ldflags', [])]
else:
# Artifacts are supplied through the OSS extra_files mechanism. No build
# is repeated on the publishing runner, and release templates stay intact.
data['before'] = {'hooks': []}
data['builds'] = [{'id': 'sub2api', 'skip': True}]
data['archives'] = []
extra = [{'glob': 'release-input/sub2api_*.tar.gz'}, {'glob': 'release-input/sub2api_*.zip'}]
if args.simple:
data['checksum'] = {'disable': True}
else:
data['release']['extra_files'] = extra
data['checksum'] = {'name_template': 'checksums.txt', 'algorithm': 'sha256', 'extra_files': extra}
Path(args.output).write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
def collect(args):
target = {'goos': args.goos, 'goarch': args.goarch}
name = archive_name(args.version, target)
source = Path('dist') / name
checksums = {line.split()[1].lstrip('*'): line.split()[0] for line in Path('dist/checksums.txt').read_text().splitlines()}
digest = sha256(source)
if checksums.get(name) != digest:
raise ValueError('archive does not match the build checksum')
output = Path(args.output)
output.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, output / name)
manifest = {'sha': args.sha, 'version': args.version, 'target': target, 'archive': name, 'sha256': digest}
(output / f"manifest-{args.goos}-{args.goarch}.json").write_text(json.dumps(manifest) + '\n')
def verify(args):
directory = Path(args.input)
expected = set()
for target in targets(args.simple):
name = archive_name(args.version, target)
manifest_name = f"manifest-{target['goos']}-{target['goarch']}.json"
expected.update((name, manifest_name))
manifest = json.loads((directory / manifest_name).read_text())
if manifest != {'sha': args.sha, 'version': args.version, 'target': target,
'archive': name, 'sha256': sha256(directory / name)}:
raise ValueError(f'build provenance or checksum mismatch: {name}')
if {p.name for p in directory.iterdir()} != expected:
raise ValueError('missing or unexpected release artifacts')
def contexts(args):
verify(args)
for target in targets(args.simple):
if target['goos'] != 'linux':
continue
dest = Path(args.output) / target['goarch']
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(Path(args.input) / archive_name(args.version, target), 'r:gz') as archive:
members = [member for member in archive.getmembers() if member.name in ('sub2api', './sub2api')]
if len(members) != 1 or not members[0].isfile():
raise ValueError('archive must contain one regular sub2api binary')
with archive.extractfile(members[0]) as source, (dest / 'sub2api').open('wb') as output:
shutil.copyfileobj(source, output)
(dest / 'sub2api').chmod(0o755)
shutil.copy2('Dockerfile.goreleaser', dest / 'Dockerfile')
(dest / 'deploy').mkdir(exist_ok=True)
shutil.copy2('deploy/docker-entrypoint.sh', dest / 'deploy/docker-entrypoint.sh')
shutil.copytree('backend/resources', dest / 'backend/resources', dirs_exist_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='command', required=True)
p = commands.add_parser('plan')
p.add_argument('--ref', required=True)
p.add_argument('--simple', action='store_true')
p.add_argument('--dry-run', action='store_true')
p.set_defaults(run=plan)
p = commands.add_parser('config')
p.add_argument('mode', choices=['build', 'publish'])
p.add_argument('--goos')
p.add_argument('--goarch')
p.add_argument('--simple', action='store_true')
p.add_argument('--output', required=True)
p.set_defaults(run=generate_config)
p = commands.add_parser('collect')
for arg in ('version', 'sha', 'goos', 'goarch', 'output'):
p.add_argument('--' + arg, required=True)
p.set_defaults(run=collect)
for command, handler in [('verify', verify), ('contexts', contexts)]:
p = commands.add_parser(command)
for arg in ('version', 'sha', 'input'):
p.add_argument('--' + arg, required=True)
p.add_argument('--simple', action='store_true')
if command == 'contexts':
p.add_argument('--output', required=True)
p.set_defaults(run=handler)
args = parser.parse_args()
args.run(args)
if __name__ == '__main__':
main()
@@ -0,0 +1 @@
PyYAML==6.0.3
@@ -0,0 +1,191 @@
import argparse
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import patch
import yaml
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('release_matrix', Path(__file__).with_name('release_matrix.py'))
release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release)
class ReleaseMatrixTest(unittest.TestCase):
def setUp(self):
self.previous = Path.cwd()
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
os.chdir(self.temp.name)
self.addCleanup(os.chdir, self.previous)
for name in ('.goreleaser.yaml', '.goreleaser.simple.yaml'):
shutil.copyfile(ROOT / name, name)
Path('backend/cmd/server').mkdir(parents=True)
release.VERSION_FILE.write_text('9.8.7\n')
def fixture_artifacts(self, simple=False):
directory = Path('release-input')
directory.mkdir()
for target in release.targets(simple):
name = release.archive_name('9.8.7', target)
archive = directory / name
if target['goos'] == 'linux':
with tarfile.open(archive, 'w:gz') as out:
info = tarfile.TarInfo('sub2api')
info.size = 7
info.mode = 0o755
out.addfile(info, io.BytesIO(b'fixture'))
else:
archive.write_bytes(b'fixture archive')
metadata = {'version': '9.8.7', 'sha': 'a' * 40, 'target': target,
'archive': name, 'sha256': release.sha256(archive)}
(directory / f"manifest-{target['goos']}-{target['goarch']}.json").write_text(json.dumps(metadata))
return argparse.Namespace(input='release-input', version='9.8.7', sha='a' * 40, simple=simple, output='contexts')
def test_full_and_simple_matrix_match_existing_targets(self):
full = release.targets()
self.assertEqual(len(full), 5)
self.assertNotIn({'goos': 'windows', 'goarch': 'arm64'}, full)
self.assertEqual(release.targets(True), [{'goos': 'linux', 'goarch': 'amd64'}])
def test_leaf_keeps_packaging_and_selects_only_one_target(self):
original = release.config()
release.generate_config(argparse.Namespace(mode='build', simple=False, goos='darwin', goarch='arm64', output='leaf.yaml'))
leaf = yaml.safe_load(Path('leaf.yaml').read_text())
self.assertEqual(leaf['builds'][0]['goos'], ['darwin'])
self.assertEqual(leaf['builds'][0]['goarch'], ['arm64'])
self.assertEqual(leaf['builds'][0]['ignore'], [])
self.assertEqual(leaf['archives'], original['archives'])
self.assertEqual(leaf['release'], original['release'])
self.assertFalse(leaf['dockers'])
self.assertIn('{{ .Env.RELEASE_DATE }}', '\n'.join(leaf['builds'][0]['ldflags']))
def test_publication_config_has_no_compilation_or_docker_work(self):
for simple in (False, True):
with self.subTest(simple=simple):
original = release.config(simple)
release.generate_config(argparse.Namespace(mode='publish', simple=simple, output='publisher.yaml'))
data = yaml.safe_load(Path('publisher.yaml').read_text())
self.assertTrue(data['builds'][0]['skip'])
self.assertFalse(data['archives'])
self.assertFalse(data['dockers'])
self.assertEqual(data['release']['header'], original['release']['header'])
self.assertEqual(data['release']['footer'], original['release']['footer'])
if simple:
self.assertTrue(data['checksum']['disable'])
self.assertTrue(data['release']['skip_upload'])
else:
self.assertEqual(data['checksum']['extra_files'], data['release']['extra_files'])
def test_collect_and_verify_hash_and_source_binding(self):
args = self.fixture_artifacts()
release.verify(args)
file = next(Path(args.input).glob('*.tar.gz'))
file.write_bytes(b'corrupted')
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
release.verify(args)
def test_missing_extra_and_wrong_commit_artifacts_are_rejected(self):
args = self.fixture_artifacts(True)
args.sha = 'b' * 40
with self.assertRaises(ValueError):
release.verify(args)
args.sha = 'a' * 40
Path('release-input/unexpected').write_text('not an asset')
with self.assertRaises(ValueError):
release.verify(args)
Path('release-input/unexpected').unlink()
next(Path('release-input').glob('*.tar.gz')).unlink()
with self.assertRaises(FileNotFoundError):
release.verify(args)
def test_linux_context_preserves_binary_executable_mode(self):
args = self.fixture_artifacts()
Path('Dockerfile.goreleaser').write_text('FROM scratch\nCOPY sub2api /sub2api\n')
Path('deploy').mkdir()
Path('deploy/docker-entrypoint.sh').write_text('#!/bin/sh\nexec /app/sub2api\n')
Path('backend/resources').mkdir()
Path('backend/resources/data').write_text('fixture')
release.contexts(args)
for arch in ('amd64', 'arm64'):
binary = Path('contexts') / arch / 'sub2api'
self.assertEqual(binary.read_bytes(), b'fixture')
self.assertEqual(binary.stat().st_mode & 0o777, 0o755)
def test_plan_requires_a_tag_for_publication(self):
args = argparse.Namespace(ref='main', dry_run=False, simple=False)
with patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'):
with self.assertRaisesRegex(ValueError, 'version tag'):
release.plan(args)
args.ref = 'v9.8.7'
with patch.object(subprocess, 'check_output', side_effect=['a' * 40 + '\n', 'b' * 40 + '\n']):
with self.assertRaisesRegex(ValueError, 'does not match'):
release.plan(args)
def test_dry_run_plan_resolves_matrix_without_a_new_tag(self):
with patch.dict(os.environ, {'GITHUB_OUTPUT': 'outputs', 'GITHUB_REPOSITORY_OWNER': 'ExampleOwner'}), patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'):
release.plan(argparse.Namespace(ref='feature/matrix', dry_run=True, simple=False))
output = dict(line.split('=', 1) for line in Path('outputs').read_text().splitlines())
self.assertEqual(output['dry_run'], 'true')
self.assertEqual(output['owner_lower'], 'exampleowner')
self.assertEqual(len(json.loads(output['matrix'])['include']), 5)
def test_docker_commands_do_not_publish_during_dry_run(self):
fake_bin = Path('bin')
fake_bin.mkdir()
docker = fake_bin / 'docker'
docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n')
docker.chmod(0o755)
env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'],
'DOCKER_LOG': str(Path('docker.log').resolve()), 'RUNNER_TEMP': self.temp.name,
'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api',
'DRY_RUN': 'true', 'SIMPLE_RELEASE': 'false', 'DOCKERHUB_USERNAME': 'skip'}
subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True)
log = Path('docker.log').read_text()
self.assertEqual(log.count('buildx build'), 2)
self.assertIn('linux/arm64', log)
self.assertNotIn('--push', log)
self.assertNotIn('imagetools', log)
self.assertNotIn('skip/sub2api', log)
self.assertIn('ghcr.io/exampleowner/sub2api', log)
def test_published_full_and_simple_image_tags(self):
fake_bin = Path('bin')
fake_bin.mkdir()
docker = fake_bin / 'docker'
docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n')
docker.chmod(0o755)
for simple in (False, True):
with self.subTest(simple=simple):
log_path = Path(f'docker-{simple}.log').resolve()
env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'],
'DOCKER_LOG': str(log_path), 'RUNNER_TEMP': self.temp.name,
'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api',
'DRY_RUN': 'false', 'SIMPLE_RELEASE': str(simple).lower(), 'DOCKERHUB_USERNAME': 'fixturehub'}
subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True)
log = log_path.read_text()
self.assertIn('--push', log)
self.assertEqual(log.count('buildx build'), 1 if simple else 2)
if simple:
self.assertNotIn('fixturehub', log)
self.assertNotIn('imagetools', log)
self.assertIn('ghcr.io/exampleowner/sub2api:latest', log)
else:
self.assertEqual(log.count('imagetools create'), 2)
self.assertIn('fixturehub/sub2api:9.8', log)
self.assertIn('ghcr.io/exampleowner/sub2api:9', log)
if __name__ == '__main__':
unittest.main()
+13
View File
@@ -25,6 +25,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check Docker Compose simple mode environment
run: /bin/sh deploy/tests/docker-compose-simple-mode-env-test.sh
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
@@ -80,3 +82,14 @@ jobs:
version: v2.13
args: --timeout=30m
working-directory: backend
release-helpers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: python -m pip install -r .github/release-tools/requirements-release.txt
- run: python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py'
- run: bash -n .github/release-tools/release-images.sh
+216 -114
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g., v1.0.0)'
description: 'Tag to release (or a branch/ref for dry run)'
required: true
type: string
simple_release:
@@ -15,179 +15,286 @@ on:
required: false
type: boolean
default: false
dry_run:
description: 'Build and verify all selected artifacts without publishing'
required: false
type: boolean
default: false
# 环境变量:合并 workflow_dispatch 输入和 repository variable
# tag push 触发时读取 vars.SIMPLE_RELEASE,workflow_dispatch 时使用输入参数
env:
SIMPLE_RELEASE: ${{ github.event.inputs.simple_release == 'true' || vars.SIMPLE_RELEASE == 'true' }}
concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: write
packages: write
contents: read
env:
SIMPLE_RELEASE: ${{ inputs.simple_release == true || vars.SIMPLE_RELEASE == 'true' }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
jobs:
# Update VERSION file with tag version
update-version:
prepare:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.plan.outputs.sha }}
owner_lower: ${{ steps.plan.outputs.owner_lower }}
tag: ${{ steps.plan.outputs.tag }}
version: ${{ steps.plan.outputs.version }}
date: ${{ steps.plan.outputs.date }}
matrix: ${{ steps.plan.outputs.matrix }}
simple: ${{ steps.plan.outputs.simple }}
dry_run: ${{ steps.plan.outputs.dry_run }}
steps:
- name: Checkout
- name: Checkout workflow tooling
uses: actions/checkout@v6
- name: Update VERSION file
with:
ref: ${{ github.sha }}
- name: Preserve tooling for releases of older tags
run: cp -R .github/release-tools "$RUNNER_TEMP/release-tools"
- name: Checkout selected application source
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt"
- name: Resolve one source commit and target matrix
id: plan
env:
REQUESTED_REF: ${{ inputs.tag || github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION=${{ github.event.inputs.tag }}
VERSION=${VERSION#v}
else
VERSION=${GITHUB_REF#refs/tags/v}
fi
echo "$VERSION" > backend/cmd/server/VERSION
echo "Updated VERSION file to: $VERSION"
- name: Upload VERSION artifact
uses: actions/upload-artifact@v7
args=(--ref "$REQUESTED_REF")
if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi
if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi
python "$RUNNER_TEMP/release-tools/release_matrix.py" plan "${args[@]}"
- uses: actions/upload-artifact@v7
with:
name: release-tooling
path: ${{ runner.temp }}/release-tools/
retention-days: 1
- uses: actions/upload-artifact@v7
with:
name: version-file
path: backend/cmd/server/VERSION
retention-days: 1
build-frontend:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
- uses: pnpm/action-setup@v6
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Build frontend
run: pnpm run build
- run: pnpm run build
working-directory: frontend
- name: Upload frontend artifact
uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7
with:
name: frontend-dist
path: backend/internal/web/dist/
retention-days: 1
release:
needs: [update-version, build-frontend]
build-binaries:
needs: [prepare, build-frontend]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
RELEASE_DATE: ${{ needs.prepare.outputs.date }}
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
fetch-depth: 0
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Download VERSION artifact
uses: actions/download-artifact@v8
- uses: actions/download-artifact@v8
with:
name: release-tooling
path: ${{ runner.temp }}/release-tools/
- uses: actions/download-artifact@v8
with:
name: version-file
path: backend/cmd/server/
- name: Download frontend artifact
uses: actions/download-artifact@v8
- uses: actions/download-artifact@v8
with:
name: frontend-dist
path: backend/internal/web/dist/
- name: Setup Go
uses: actions/setup-go@v6
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
check-latest: false
cache-dependency-path: backend/go.sum
cache: false
- name: Verify Go version
run: |
go version | grep -q 'go1.27.0'
run: go version | grep -q 'go1.27.0'
- name: Restore target-specific Go cache
id: go-cache
uses: actions/cache/restore@v6
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }}
restore-keys: |
release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-
release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt"
- name: Select one GoReleaser target
env:
TARGET_OS: ${{ matrix.goos }}
TARGET_ARCH: ${{ matrix.goarch }}
run: python "$RUNNER_TEMP/release-tools/release_matrix.py" config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output "$RUNNER_TEMP/goreleaser.matrix.yaml"
- name: Build and archive without publishing
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --snapshot --clean --skip=validate,publish,docker,announce --config=${{ runner.temp }}/goreleaser.matrix.yaml
env:
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }}
GITHUB_REPO_NAME: ${{ github.event.repository.name }}
DOCKERHUB_USERNAME: skip
TAG_MESSAGE: ''
- name: Verify and collect the target archive
env:
TARGET_OS: ${{ matrix.goos }}
TARGET_ARCH: ${{ matrix.goarch }}
RELEASE_SHA: ${{ needs.prepare.outputs.sha }}
run: python "$RUNNER_TEMP/release-tools/release_matrix.py" collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output
- uses: actions/upload-artifact@v7
with:
name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }}
path: matrix-output/
if-no-files-found: error
retention-days: 1
- name: Save updated target cache
if: ${{ steps.go-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v6
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ steps.go-cache.outputs.cache-primary-key }}
# Docker setup for GoReleaser
release:
needs: [prepare, build-binaries]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
env:
SIMPLE_RELEASE: ${{ needs.prepare.outputs.simple }}
DRY_RUN: ${{ needs.prepare.outputs.dry_run }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
RELEASE_SHA: ${{ needs.prepare.outputs.sha }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: release-tooling
path: ${{ runner.temp }}/release-tools/
- uses: actions/download-artifact@v8
with:
pattern: release-binary-*
path: release-input
merge-multiple: true
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt"
- name: Verify complete matrix and prepare Docker contexts
run: |
args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context)
if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi
python "$RUNNER_TEMP/release-tools/release_matrix.py" contexts "${args[@]}"
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ env.DOCKERHUB_USERNAME != '' }}
if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }}
uses: docker/login-action@v3
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ env.DRY_RUN != 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch tags with annotations
run: |
# 确保获取完整的 annotated tag 信息
git fetch --tags --force
- name: Get tag message
- name: Build images and publish manifests
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }}
run: bash "$RUNNER_TEMP/release-tools/release-images.sh"
- name: Get annotated tag notes
id: tag_message
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG_NAME=${{ github.event.inputs.tag }}
else
TAG_NAME=${GITHUB_REF#refs/tags/}
fi
echo "Processing tag: $TAG_NAME"
# 获取完整的 tag message(跳过第一行标题)
TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$TAG_NAME")
# 调试输出
echo "Tag message length: ${#TAG_MESSAGE}"
echo "Tag message preview:"
echo "$TAG_MESSAGE" | head -10
# 使用 EOF 分隔符处理多行内容
echo "message<<EOF" >> $GITHUB_OUTPUT
echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Set lowercase owner for GHCR
id: lowercase
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Run GoReleaser
TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$RELEASE_TAG")
DELIMITER="TAG_MESSAGE_$(openssl rand -hex 16)"
{
echo "message<<$DELIMITER"
echo "$TAG_MESSAGE"
echo "$DELIMITER"
} >> "$GITHUB_OUTPUT"
- name: Prepare publication-only GoReleaser config
run: |
args=(publish --output "$RUNNER_TEMP/goreleaser.publisher.yaml")
if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi
python "$RUNNER_TEMP/release-tools/release_matrix.py" config "${args[@]}"
- name: Publish existing archives and release notes
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }}
args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=${{ runner.temp }}/goreleaser.publisher.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }}
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
GITHUB_REPO_OWNER_LOWER: ${{ steps.lowercase.outputs.owner }}
GITHUB_REPO_NAME: ${{ github.event.repository.name }}
GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }}
- name: Upload dry-run checksums and metadata
if: ${{ env.DRY_RUN == 'true' }}
uses: actions/upload-artifact@v7
with:
name: release-dry-run-report
path: |
dist/checksums.txt
dist/artifacts.json
dist/metadata.json
if-no-files-found: error
retention-days: 3
# Update DockerHub description
- name: Update DockerHub description
if: ${{ env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }}
if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }}
uses: peter-evans/dockerhub-description@v5
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -200,8 +307,9 @@ jobs:
# Send Telegram notification
- name: Send Telegram Notification
if: ${{ env.SIMPLE_RELEASE != 'true' }}
if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' }}
env:
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -213,17 +321,12 @@ jobs:
exit 0
fi
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG_NAME=${{ github.event.inputs.tag }}
else
TAG_NAME=${GITHUB_REF#refs/tags/}
fi
TAG_NAME="$RELEASE_TAG"
VERSION=${TAG_NAME#v}
REPO="${{ github.repository }}"
GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase
# 获取 tag message 内容并转义 Markdown 特殊字符
TAG_MESSAGE='${{ steps.tag_message.outputs.message }}'
TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g')
# 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容)
@@ -273,9 +376,13 @@ jobs:
}')"
sync-version-file:
needs: [release]
if: ${{ needs.release.result == 'success' }}
needs: [prepare, release]
if: ${{ needs.release.result == 'success' && needs.prepare.outputs.dry_run != 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Checkout default branch
uses: actions/checkout@v6
@@ -284,12 +391,7 @@ jobs:
- name: Sync VERSION file to released tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION=${{ github.event.inputs.tag }}
VERSION=${VERSION#v}
else
VERSION=${GITHUB_REF#refs/tags/v}
fi
VERSION="$RELEASE_VERSION"
CURRENT_VERSION=$(tr -d '\r\n' < backend/cmd/server/VERSION || true)
if [ "$CURRENT_VERSION" = "$VERSION" ]; then
+4
View File
@@ -141,17 +141,21 @@ docs/*
!docs/COMPOSITE_GROUPS.md
!docs/PLUGIN_DEVELOPMENT.md
!docs/channel-monitor-v2-safe-defaults.md
!docs/codex-credits.md
!docs/legal/
!docs/legal/*.md
!docs/screenshots/
docs/screenshots/*
!docs/screenshots/mobile-account-actions-menu.png
!docs/screenshots/codex-referral-dialog.png
!docs/screenshots/codex-referral-success.png
.serena/
.codex/
frontend/coverage/
aicodex
output/
.codegraph/
issues/
# Vitest / Vite cache at repo root
.vite/
+8
View File
@@ -1,8 +1,16 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/keys.bulkUpdate.spec.ts \
src/components/account/__tests__/OpenAIReferralCell.spec.ts \
src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \
src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts \
src/views/user/__tests__/KeysView.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
+62 -26
View File
@@ -49,8 +49,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
</tr>
<tr>
@@ -58,6 +58,11 @@ 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://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
@@ -91,26 +96,12 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
</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>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 Thanks to FastAIToken for sponsoring this project! <a href="http://www.fastaitoken.com/register">FastAIToken</a> is an AI API aggregation platform for developers, supporting mainstream large models such as OpenAI, Claude, and Gemini. Top-up at 1:1 — 1 CNY = 1 USD of API credit — letting developers use the world's leading large model services at lower cost and with greater convenience.<br>
🚀 The platform offers a variety of channels to choose from: an ultra-low-price 0.02x OpenAI promotional group (limited time), groups as low as 0.25x OpenAI, 0.7x Claude with 95% fixed cache, and a 1.2x Claude Max channel. It also provides a public status page showing real-time availability, latency, and operating status of each group for transparent and reliable service, plus 7×24 human technical support (not bots) with fast responses to developer needs.
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
@@ -122,11 +113,6 @@ Please read the following carefully before using this project:
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Thanks to <a href="https://www.novada.com/?sub2api/">Novada</a> for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows.</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via <a href="https://s.qiniu.com/u6rQrq">this link</a> — enterprise users get 12 million tokens free, and developers get 3 million tokens free.</td>
@@ -162,6 +148,26 @@ Please read the following carefully before using this project:
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> is a source-level API gateway with its own massive account pool, purpose-built as an upstream relay for downstream transit stations and high-frequency developers — saving you the hassle of building and maintaining your own account pool. ✅ Direct from source: self-operated account pool with no middleman markup; 🧧 Cost crusher: combined rate multiplier as low as 0.03x, just 0.35% of official pricing; 🚀 Blazing fast: first-token latency < 1s, on par with the official native API. <a href="https://www.pp.dog/register?aff=SUB2API">Get started with PP.dog now</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
</tr>
</table>
## Overview
@@ -348,8 +354,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here
# Optional: Admin account
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup.
# Avoid guessable values such as admin@example.com: they are brute-force targets.
ADMIN_EMAIL=
ADMIN_PASSWORD=
# Optional: Custom port
SERVER_PORT=8080
@@ -398,9 +406,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
Open `http://YOUR_SERVER_IP:8080` in your browser.
If admin password was auto-generated, find it in logs:
If the admin email (login username) or password was auto-generated, find them in logs:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```
#### Upgrade
@@ -719,7 +727,10 @@ go generate ./cmd/server
Simple Mode is designed for individual developers or internal teams who want quick access without full SaaS features.
- Enable: Set environment variable `RUN_MODE=simple`
- Default groups are seeded on each startup. Set `SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=false` (or YAML `simple_mode.auto_create_default_groups: false`) to manage groups yourself. The default is `true`; disabling it does not delete existing groups or change runtime auto-binding or admin concurrency setup.
- Difference: Hides SaaS-related features and skips billing process
- Optional key windows: Set `SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=true` to enforce each API key's configured 5-hour, daily, and 7-day spending windows. The default is `false`; balance and subscription debit remain bypassed when enabled.
- Window enforcement uses the database as its source of truth and records only API-key window usage. It is a post-request soft cap, so concurrent in-flight requests can overshoot by their final costs. Historical simple-mode usage is not backfilled.
- Security note: In production, you must also set `SIMPLE_MODE_CONFIRM=true` to allow startup
---
@@ -812,12 +823,37 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
---
## TypeSafe / Jev Support
Sub2API supports TypeSafe API-key accounts through Jev's native, non-streaming System One protocol.
- Platform: `typesafe`; account type: API Key
- Default upstream: `https://api.typesafe.ai`
- Public endpoint: `POST /v1/systemone`
- Model: `jev-latest`, also returned by `/v1/models` for TypeSafe groups
- Questions: `noul`, `choice`, and `score`
Requests and successful responses retain the native System One JSON structure. This endpoint is not compatible with Chat Completions, Responses, Anthropic Messages, or streaming clients.
Question validation follows the TypeSafe OpenAPI wire schema (also used by SDK v0.5.7). `instructions` may be omitted or `null` for all question types. Noul `criteria` may be omitted or `null`; its `true`/`false` descriptions and Choice descriptions accept strings, objects, arrays, or `null`. Score `criteria` must be a non-empty array of string, object, or array descriptions; a single level is valid. SDK integer-keyed Score maps are normalized to arrays by the SDK before sending.
```bash
curl https://your-sub2api.example.com/v1/systemone \
-H 'Authorization: Bearer sk-your-sub2api-key' \
-H 'Content-Type: application/json' \
--data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}'
```
The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`.
---
## Antigravity Support
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
+93 -25
View File
@@ -50,8 +50,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
</tr>
<tr>
@@ -59,6 +59,11 @@
<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://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
@@ -94,26 +99,12 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
</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>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 感谢 FastAIToken 对本项目的赞助! <a href="http://www.fastaitoken.com/register">FastAIToken</a> 是面向开发者的 AI API 聚合平台,支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务。<br>
🚀 平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
@@ -125,11 +116,6 @@
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>感谢 <a href="https://www.novada.com/?sub2api/">Novada</a> 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="七牛云AI" width="150"></a></td>
<td>感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过<a href="https://s.qiniu.com/u6rQrq">此链接</a>注册,企业用户免费领1200万Token,开发者免费领300万Token。</td>
@@ -165,6 +151,26 @@
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> 是自建账号池的源头 API 网关,专注为下游中转站与高频开发者提供 API 网关中继服务,帮您省去自建号池的一切麻烦——✅ 源头直供:自持海量账号池,无中间商赚差价;🧧 成本屠夫:综合倍率低至 0.03x,成本仅为官方的千分之3.5;🚀 极速体验:首 Token 延迟 < 1s,流畅媲美官方原生 API。<a href="https://www.pp.dog/register?aff=SUB2API">立即接入PP.dog</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
</tr>
</table>
## 项目概述
@@ -213,6 +219,30 @@ underscores_in_headers on;
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
## Codex Fast/Flex 策略说明
管理员后台的 `系统设置 -> 网关服务 -> OpenAI Fast/Flex 策略` 只负责处理请求体中的 `service_tier`,不会修改 Codex 客户端的模型目录,也不会让 Codex UI 自动出现 Speed 或 `/fast` 选项。
策略支持以下处理方式:
- `pass`:保留客户端传入的 `service_tier`;`fast` 会规范为上游使用的 `priority`。
- `filter`:移除 `service_tier`,按普通优先级请求。
- `block`:拒绝匹配的 Fast/Flex 请求。
- `force_priority`:将匹配请求强制设置为 `priority`,会按 Priority/Fast 价格计费。为避免升级后改变既有规则语义,`all` 只匹配显式存在的 tier;如需让省略 `service_tier` 的 OpenAI 请求也强制升级,必须新增 `service_tier=missing + force_priority` 规则。非 OpenAI 平台不会执行缺省 tier 注入。这种方式可以让请求实际使用 Fast,但 Codex UI 仍可能不显示 Fast 状态。
Codex 的 Fast 入口由客户端模型目录驱动。只有当前模型目录声明了 `additional_speed_tiers: ["fast"]` 和对应的 `service_tiers`,Codex 才会显示 `/fast`。通过 API Key 或自定义模型提供商连接 Sub2API 时,如果模型目录没有这些字段,即使后台配置了 `force_priority`,重启 Codex 后也不会出现 Speed 选项。
客户端可在 `~/.codex/config.toml` 中直接指定默认请求级别:
```toml
service_tier = "fast"
[features]
fast_mode = true
```
其中 `service_tier = "fast"` 会让请求携带 Fast 设置;`features.fast_mode` 只启用客户端 Fast 功能。模型目录没有声明 Fast 能力时,`/fast` 仍可能不显示。可通过 Sub2API 使用记录确认最终 `service_tier` 是否为 `priority`。
---
## 部署方式
@@ -350,8 +380,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here
# 可选:管理员账号
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。
# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。
ADMIN_EMAIL=
ADMIN_PASSWORD=
# 可选:自定义端口
SERVER_PORT=8080
@@ -412,9 +444,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
在浏览器中打开 `http://你的服务器IP:8080`
如果管理员密码是自动生成的,在日志中查找:
如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```
#### 升级
@@ -692,16 +724,52 @@ go generate ./cmd/server
---
## OpenAI 图片模型
支持 `gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 及其 `2026-09-08` 日期快照,可通过 `/v1/images/generations`、`/v1/images/edits` 调用。`quality` 支持 `xhigh`、`max`、`auto`,合法自定义尺寸和图片 usage 明细保持透传。
OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_generation` 工具,默认主控为 `gpt-5.6-luna`。可设置 `SUB2API_IMAGES_MAIN_MODEL` 切换为账号支持的文本模型;Docker Compose 用户修改 `.env` 后执行 `docker compose up -d` 重建容器。该配置不会替换所选图片模型,也不会覆盖 `/v1/responses` 请求中已经提供的文本主控模型。
升级后,无模型限制的账号自动支持新模型。已有显式账号映射或分组白名单需要加入两个 2.5 模型(日期快照按需加入);升级不会自动扩大管理员设置的模型权限。新模型内置价格包含官方文本输入、图片输入和图片输出 token 费率,远端价格表尚未更新时使用内置 2.5 价格;实际按次或按 token 计费仍由既有分组/渠道配置决定。
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
- 启用方式:设置环境变量 `RUN_MODE=simple`
- 默认每次启动会补齐默认分组。设置 `SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=false`(或 YAML `simple_mode.auto_create_default_groups: false`)可自行管理分组。默认值为 `true`;关闭后不删除已有分组,不改变运行时自动绑定或管理员并发设置。
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
- 可选密钥窗口:设置 `SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED=true` 后,按每个 API Key 配置的 5 小时、1 天、7 天消费窗口进行限制,默认值为 `false`;启用后仍跳过余额和订阅扣费。
- 窗口限制以数据库为准,只记录 API Key 窗口用量。它在请求完成后记账,并发中的请求可能以各自最终费用超过窗口上限。启用前的历史用量不会自动补算。
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
---
## TypeSafe / Jev 使用说明
Sub2API 支持使用 TypeSafe API Key 账户,通过 Jev 原生、非流式的 System One 协议调用模型。
- 平台:`typesafe`;账号类型:API Key
- 默认上游:`https://api.typesafe.ai`
- 对外端点:`POST /v1/systemone`
- 模型:`jev-latest`,TypeSafe 分组的 `/v1/models` 也会返回该模型
- 问题类型:`noul`、`choice`、`score`
请求和成功响应保持 System One 原生 JSON 结构。该端点不兼容 Chat Completions、Responses、Anthropic Messages 或流式客户端。
问题校验遵循 TypeSafe OpenAPI 的线上协议 schema(SDK v0.5.7 也使用该 schema)。所有问题的 `instructions` 都可以省略或为 `null`。Noul 的 `criteria` 可以省略或为 `null`,其中 `true`/`false` 的描述和 Choice 描述支持字符串、对象、数组或 `null`。Score 的 `criteria` 必须是至少包含一档描述的数组,每档支持字符串、对象或数组;单档也合法。SDK 的整数键 Score 映射会由 SDK 在发送前转换为数组。
```bash
curl https://your-sub2api.example.com/v1/systemone \
-H 'Authorization: Bearer sk-your-sub2api-key' \
-H 'Content-Type: application/json' \
--data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}'
```
`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。
---
## Antigravity 使用说明
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
+33 -25
View File
@@ -49,8 +49,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
</tr>
<tr>
@@ -58,6 +58,11 @@
<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://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
@@ -93,26 +98,12 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
</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>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 FastAIToken のご支援に感謝します!<a href="http://www.fastaitoken.com/register">FastAIToken</a> は開発者向けの AI API アグリゲーションプラットフォームで、OpenAI、Claude、Gemini などの主要な大規模モデルに対応しています。チャージは 1:1(1 元 = 1 米ドル分の API クレジット)で、開発者がより低コスト・より手軽に世界トップクラスの大規模モデルサービスを利用できます。<br>
🚀 プラットフォームでは多彩なチャネルを自由に選択できます:超低価格の 0.02x OpenAI 特典グループ(期間限定)、最低 0.25x の OpenAI グループ、0.7x Claude(95% 固定キャッシュ)、1.2x Claude Max チャネル。さらに、各グループの可用率・レイテンシ・稼働状況をリアルタイムで表示する公開ステータスページを提供し、透明で信頼できるサービスを実現。7×24 時間の有人テクニカルサポート(ボットではありません)により、開発者のニーズに迅速に対応します。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
@@ -124,11 +115,6 @@
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Novada のご支援に感謝します!<a href="https://www.novada.com/?sub2api/">Novada</a> は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、<a href="https://s.qiniu.com/u6rQrq">こちらのリンク</a>から登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。</td>
@@ -164,6 +150,26 @@
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> は自社運用の大規模アカウントプールを持つソースレベルの API ゲートウェイで、下流の中継ステーションや高頻度開発者向けに API ゲートウェイリレーサービスを提供し、アカウントプールの自主構築にかかるすべての手間を省きます。✅ ソース直供:自社運用の大規模アカウントプール、中間マージンなし;🧧 コスト最強:総合レート倍率わずか 0.03x、コストは公式の 0.35% のみ;🚀 超高速体験:ファーストトークンレイテンシ 1 秒未満、公式ネイティブ API に匹敵するスムーズさ。<a href="https://www.pp.dog/register?aff=SUB2API">今すぐ PP.dog に接続</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
</tr>
</table>
## 概要
@@ -349,8 +355,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here
# オプション: 管理者アカウント
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。
# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。
ADMIN_EMAIL=
ADMIN_PASSWORD=
# オプション: カスタムポート
SERVER_PORT=8080
@@ -399,9 +407,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
管理者パスワードが自動生成された場合は、ログで確認できます:
管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```
#### アップグレード
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.180
0.2.13
+12
View File
@@ -99,6 +99,7 @@ func provideCleanup(
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -124,6 +125,7 @@ func provideCleanup(
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
@@ -272,6 +274,10 @@ func provideCleanup(
codexVersionSync.Stop()
return nil
}},
{"ClaudeCodeVersionSyncService", func() error {
claudeCodeVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
@@ -380,6 +386,12 @@ func provideCleanup(
}
return nil
}},
{"OpenCodeGoUsageService", func() error {
if opencodeGoUsage != nil {
opencodeGoUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+35 -17
View File
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator, ollamaCloudUsageService)
identityCache := repository.NewIdentityCache(redisClient)
identityService := service.NewIdentityService(identityCache)
httpUpstream := repository.NewHTTPUpstream(configConfig)
timingWheelService, err := service.ProvideTimingWheelService()
if err != nil {
return nil, err
@@ -137,7 +139,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
channelCachePubSub := repository.NewChannelCache(redisClient)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
@@ -178,39 +181,40 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service, apiKeyService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig, settingRepository)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
openAIReferralClient := repository.NewOpenAIReferralClient(privacyClientFactory)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIReferralClient, openAIGatewayService)
usageCache := service.NewUsageCache()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
pluginRepository := repository.NewPluginRepository(db)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
pluginKVStore := repository.NewPluginKVStore(redisClient)
pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
dbDumper := repository.NewPgDumper(configConfig, db)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
@@ -283,15 +287,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
@@ -317,7 +323,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
@@ -338,6 +343,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
claudeCodeVersionSyncService := service.ProvideClaudeCodeVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
@@ -347,7 +353,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -402,6 +408,7 @@ func provideCleanup(
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -427,6 +434,7 @@ func provideCleanup(
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
@@ -574,6 +582,10 @@ func provideCleanup(
codexVersionSync.Stop()
return nil
}},
{"ClaudeCodeVersionSyncService", func() error {
claudeCodeVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
@@ -682,6 +694,12 @@ func provideCleanup(
}
return nil
}},
{"OpenCodeGoUsageService", func() error {
if opencodeGoUsage != nil {
opencodeGoUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+3
View File
@@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
)
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
claudeCodeVersionSyncSvc := service.NewClaudeCodeVersionSyncService(nil, nil, nil, time.Second)
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
pricingSvc := service.NewPricingService(cfg, nil)
@@ -68,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
claudeCodeVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
&service.UsageCleanupService{},
@@ -93,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
nil, // opencodeGoUsage
nil, // auditLog
nil, // openAIAutoReset
nil, // promptAudit
@@ -0,0 +1,136 @@
# Upstream error proxy attribution
## Scope
Gateway inference errors are stored in `ops_error_logs.upstream_errors`. Each
array item represents one upstream attempt, including same-account retries and
cross-account failover attempts. Proxy attribution is stored on that item; it
must not be reconstructed later from `accounts.proxy_id`.
This repository has one gateway implementation under `backend/internal/service`.
There is no separate `gateway/backend` source copy to synchronize.
## Event contract
Every newly persisted event contains these fields. `proxy_id` is emitted even
when its value is JSON `null`:
| Field | Meaning |
| --- | --- |
| `proxy_id` | Managed proxy ID used by the attempt; `null` for direct or unknown routes. |
| `proxy_name` | Snapshotted proxy name, `direct/no_proxy`, or `unknown`. |
Only proxy ID and name are stored. Proxy URL, protocol endpoint, host, port,
username, password, and authorization data are excluded.
The two sentinel names have strict meanings:
- `direct/no_proxy`: the transport was explicitly given no proxy.
- `unknown`: the route cannot be proven from event-time evidence. This includes
legacy events, pre-transport credential failures, and OpenAI WebSocket use of
the default HTTP client.
Invariant: `proxy_id` is `null` if and only if `proxy_name` is one of the two
sentinels. A managed proxy with a blank name (defensive only; `proxies.name` is
non-empty) is labeled `proxy`. Both the struct normalizer used at append time
and the JSON normalizer used on detail reads enforce this, so an event can never
carry `proxy_id=null` together with a real proxy name.
## Why the account snapshot is event-time evidence
Account selection loads `ProxyID` and `Proxy` into one in-memory snapshot. HTTP,
TLS-fingerprint, Bedrock, Gemini, Antigravity, OpenAI, and OpenAI WebSocket
forwarding use that snapshot for managed proxy routing.
Inference transports use a managed proxy only when both the binding ID and its
hydrated proxy object are present; the attribution accessors use the same rule.
OpenAI WebSocket additionally retains the existing default-client environment
proxy behavior when that snapshot does not yield a usable proxy. The transport
does not switch to a database backup proxy during a request.
Expired proxy fallback happens before scheduling by atomically rewriting the
account binding:
- backup proxy: `proxy_id` becomes the backup ID and
`proxy_fallback_origin_id` stores the expired proxy ID;
- direct fallback: `proxy_id` becomes null and
`proxy_fallback_origin_id` stores the expired proxy ID.
Custom Anthropic relays receive the same snapshotted proxy as their encoded
relay proxy parameter. OpenAI WebSocket requests keep the existing transport
behavior: configured account proxies use the proxy client, while an empty
account proxy leaves the HTTP client unset so `coder/websocket` uses
`http.DefaultClient`, including its `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
handling.
For WebSocket errors without a usable account proxy, the event stores
`proxy_id=null` and `proxy_name=unknown`. It does not infer whether the default
client selected an environment proxy or a direct connection after the fact.
This applies to every event produced while a request is on the WebSocket
transport, including WS handshake/fallback errors and the first-output timeout
raised by the WS passthrough relay; `opsUpstreamWSProxyAttribution` is the
accessor for those sites and never returns `direct/no_proxy`.
Credential acquisition failures that occur before the inference transport is
opened are also recorded as unknown. The account's proxy binding is not treated
as proof that the credential operation or an inference request used that route.
Each failure site constructs a complete `OpsUpstreamErrorEvent`. Its literal
sets `proxy_id` and `proxy_name` from the same route input used by the attempt.
Credential-free accessors copy only the managed proxy ID and name. A fallback
proxy needs no special event flag: its event-time ID and name are the historical
route evidence. Shared helpers that assemble the event on behalf of a caller
(`handleUpstreamTransportError`, `handleOpenAIUpstreamTransportError`,
`appendOpenAICompactFallbackRetryOps`, `recordOpenAIRawStreamTruncation`,
`newOpenAIFirstOutputTimeoutError`) stamp attribution themselves, so a caller
cannot forget it. Every non-test `OpsUpstreamErrorEvent{` literal in
`backend/internal/service` must either set `ProxyName` or be passed to one of
those helpers; grep for literals missing `ProxyName` before merging changes
that add a failure site.
`appendOpsUpstreamError` accepts only the completed event. It does not receive
an `Account`, query current account state, or infer proxy attribution while
appending. Once appended, the event contains only scalar snapshot values, so
later account mutation or failover cannot change an earlier event.
Queue sanitization keeps attempt events newest-first under hard bounds:
- the newest 16 attempts retain `detail` and `upstream_response_body` (each
capped at the queue body limit) plus `upstream_url`/`message` up to 2048
bytes;
- older attempts keep timestamps, account, proxy, status, kind, stage, scope,
reason and a `upstream_url`/`message` trimmed to 512 bytes; an attempt whose
only payload was a cleared `detail` is still retained;
- at most 256 attempts and roughly 512 KiB of serialized JSON are kept per
request. When either bound drops attempts, the oldest retained event carries
`dropped_earlier_attempts` with the number of discarded earlier attempts. The
newest attempt is always retained because it decides the request outcome.
## Legacy events and analytics
Legacy JSON is not rewritten in the database. Detail reads edit only the
`proxy_id`/`proxy_name` keys of events that lack valid attribution, so keys
written by older struct versions and the stored key order are preserved. The
shared parser applies the same rule to the decoded structs. Both materialize
missing attribution as:
```json
{
"proxy_id": null,
"proxy_name": "unknown"
}
```
Proxy and region aggregation must use the event fields first. A safe grouping
rule is:
1. A non-null event `proxy_id` groups by that ID and uses the event
`proxy_name` as the historical label. A join to `proxies` may enrich current
metadata but must not replace the event label.
2. `proxy_id=null` and `proxy_name=direct/no_proxy` groups as direct.
3. Missing attribution, or `proxy_id=null` and `proxy_name=unknown`, groups as
unknown. Do not join through the account's current `proxy_id` and present it
as historical attribution.
If an operator deliberately produces a current-account cohort for legacy data,
the report must be labeled as a current snapshot; it is not historical proxy
attribution.
+3 -1
View File
@@ -182,6 +182,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -191,7 +193,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -108,6 +108,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -117,7 +119,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+17 -1
View File
@@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery {
return query
}
// QueryPrimaryProxies queries the primary_proxies edge of a Proxy.
func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryBackupProxy queries the backup_proxy edge of a Proxy.
func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
@@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
+58 -12
View File
@@ -122,6 +122,10 @@ type Group struct {
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
// 是否允许此 OpenAI 分组访问 Live 接口
AllowLive bool `json:"allow_live,omitempty"`
// 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority
ForceOpenaiFast bool `json:"force_openai_fast,omitempty"`
// 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费
FreeOpenaiFast bool `json:"free_openai_fast,omitempty"`
// 仅允许非 apikey 类型账号关联到此分组
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
// 调度时仅允许 privacy 已成功设置的账号
@@ -130,13 +134,17 @@ type Group struct {
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
// 分组模型白名单:同时约束模型列表接口与请求准入
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"`
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
RpmLimit int `json:"rpm_limit,omitempty"`
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
@@ -250,15 +258,15 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
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.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
values[i] = new(sql.NullString)
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
values[i] = new(sql.NullTime)
@@ -616,6 +624,18 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.AllowLive = value.Bool
}
case group.FieldForceOpenaiFast:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i])
} else if value.Valid {
_m.ForceOpenaiFast = value.Bool
}
case group.FieldFreeOpenaiFast:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i])
} else if value.Valid {
_m.FreeOpenaiFast = value.Bool
}
case group.FieldRequireOauthOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
@@ -642,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
}
}
case group.FieldModelsListConfig:
case group.FieldModelAllowlist:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
return fmt.Errorf("unmarshal field models_list_config: %w", err)
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
return fmt.Errorf("unmarshal field model_allowlist: %w", err)
}
}
case group.FieldCodexModelsManifestConfig:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil {
return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err)
}
}
case group.FieldRpmLimit:
@@ -662,6 +690,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.MaxReasoningEffort = value.String
}
case group.FieldMaxReasoningEffortOverLimit:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
} else if value.Valid {
_m.MaxReasoningEffortOverLimit = value.String
}
case group.FieldReasoningEffortMappings:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
@@ -955,6 +989,12 @@ func (_m *Group) String() string {
builder.WriteString("allow_live=")
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
builder.WriteString(", ")
builder.WriteString("force_openai_fast=")
builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast))
builder.WriteString(", ")
builder.WriteString("free_openai_fast=")
builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast))
builder.WriteString(", ")
builder.WriteString("require_oauth_only=")
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
builder.WriteString(", ")
@@ -967,8 +1007,11 @@ func (_m *Group) String() string {
builder.WriteString("messages_dispatch_model_config=")
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
builder.WriteString(", ")
builder.WriteString("models_list_config=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
builder.WriteString("model_allowlist=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
builder.WriteString(", ")
builder.WriteString("codex_models_manifest_config=")
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
builder.WriteString(", ")
builder.WriteString("rpm_limit=")
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
@@ -976,6 +1019,9 @@ func (_m *Group) String() string {
builder.WriteString("max_reasoning_effort=")
builder.WriteString(_m.MaxReasoningEffort)
builder.WriteString(", ")
builder.WriteString("max_reasoning_effort_over_limit=")
builder.WriteString(_m.MaxReasoningEffortOverLimit)
builder.WriteString(", ")
builder.WriteString("reasoning_effort_mappings=")
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
builder.WriteString(", ")
+42 -5
View File
@@ -118,6 +118,10 @@ const (
FieldAllowMessagesDispatch = "allow_messages_dispatch"
// FieldAllowLive holds the string denoting the allow_live field in the database.
FieldAllowLive = "allow_live"
// FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database.
FieldForceOpenaiFast = "force_openai_fast"
// FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database.
FieldFreeOpenaiFast = "free_openai_fast"
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
FieldRequireOauthOnly = "require_oauth_only"
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
@@ -126,12 +130,16 @@ const (
FieldDefaultMappedModel = "default_mapped_model"
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
FieldModelsListConfig = "models_list_config"
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
FieldModelAllowlist = "model_allowlist"
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
FieldRpmLimit = "rpm_limit"
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
FieldMaxReasoningEffort = "max_reasoning_effort"
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
FieldReasoningEffortMappings = "reasoning_effort_mappings"
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
@@ -266,13 +274,17 @@ var Columns = []string{
FieldSortOrder,
FieldAllowMessagesDispatch,
FieldAllowLive,
FieldForceOpenaiFast,
FieldFreeOpenaiFast,
FieldRequireOauthOnly,
FieldRequirePrivacySet,
FieldDefaultMappedModel,
FieldMessagesDispatchModelConfig,
FieldModelsListConfig,
FieldModelAllowlist,
FieldCodexModelsManifestConfig,
FieldRpmLimit,
FieldMaxReasoningEffort,
FieldMaxReasoningEffortOverLimit,
FieldReasoningEffortMappings,
FieldProfitControlEnabled,
FieldProfitMinMargin,
@@ -386,6 +398,10 @@ var (
DefaultAllowMessagesDispatch bool
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
DefaultAllowLive bool
// DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field.
DefaultForceOpenaiFast bool
// DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field.
DefaultFreeOpenaiFast bool
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
DefaultRequireOauthOnly bool
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
@@ -396,14 +412,20 @@ var (
DefaultMappedModelValidator func(string) error
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
DefaultModelsListConfig domain.GroupModelsListConfig
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
DefaultModelAllowlist domain.GroupModelAllowlist
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
DefaultRpmLimit int
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
DefaultMaxReasoningEffort string
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
MaxReasoningEffortValidator func(string) error
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
DefaultMaxReasoningEffortOverLimit string
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
MaxReasoningEffortOverLimitValidator func(string) error
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
@@ -657,6 +679,16 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
}
// ByForceOpenaiFast orders the results by the force_openai_fast field.
func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc()
}
// ByFreeOpenaiFast orders the results by the free_openai_fast field.
func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc()
}
// ByRequireOauthOnly orders the results by the require_oauth_only field.
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
@@ -682,6 +714,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
}
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
}
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
+100
View File
@@ -290,6 +290,16 @@ func AllowLive(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
}
// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ.
func ForceOpenaiFast(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
}
// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ.
func FreeOpenaiFast(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
}
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
func RequireOauthOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
@@ -315,6 +325,11 @@ func MaxReasoningEffort(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
}
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
func MaxReasoningEffortOverLimit(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
}
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
func ProfitControlEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
@@ -2270,6 +2285,26 @@ func AllowLiveNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
}
// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field.
func ForceOpenaiFastEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
}
// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field.
func ForceOpenaiFastNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v))
}
// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field.
func FreeOpenaiFastEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
}
// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field.
func FreeOpenaiFastNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v))
}
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
func RequireOauthOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
@@ -2460,6 +2495,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
}
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
}
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
}
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
}
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
func ProfitControlEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
+297 -32
View File
@@ -704,6 +704,34 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
return _c
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate {
_c.mutation.SetForceOpenaiFast(v)
return _c
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate {
if v != nil {
_c.SetForceOpenaiFast(*v)
}
return _c
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate {
_c.mutation.SetFreeOpenaiFast(v)
return _c
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate {
if v != nil {
_c.SetFreeOpenaiFast(*v)
}
return _c
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
_c.mutation.SetRequireOauthOnly(v)
@@ -760,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _c
}
// SetModelsListConfig sets the "models_list_config" field.
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
_c.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
_c.mutation.SetModelAllowlist(v)
return _c
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
if v != nil {
_c.SetModelsListConfig(*v)
_c.SetModelAllowlist(*v)
}
return _c
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate {
_c.mutation.SetCodexModelsManifestConfig(v)
return _c
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate {
if v != nil {
_c.SetCodexModelsManifestConfig(*v)
}
return _c
}
@@ -802,6 +844,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
return _c
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
_c.mutation.SetMaxReasoningEffortOverLimit(v)
return _c
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
if v != nil {
_c.SetMaxReasoningEffortOverLimit(*v)
}
return _c
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
_c.mutation.SetReasoningEffortMappings(v)
@@ -1095,6 +1151,14 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultAllowLive
_c.mutation.SetAllowLive(v)
}
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
v := group.DefaultForceOpenaiFast
_c.mutation.SetForceOpenaiFast(v)
}
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
v := group.DefaultFreeOpenaiFast
_c.mutation.SetFreeOpenaiFast(v)
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
v := group.DefaultRequireOauthOnly
_c.mutation.SetRequireOauthOnly(v)
@@ -1111,9 +1175,13 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultMessagesDispatchModelConfig
_c.mutation.SetMessagesDispatchModelConfig(v)
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
v := group.DefaultModelsListConfig
_c.mutation.SetModelsListConfig(v)
if _, ok := _c.mutation.ModelAllowlist(); !ok {
v := group.DefaultModelAllowlist
_c.mutation.SetModelAllowlist(v)
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
v := group.DefaultCodexModelsManifestConfig
_c.mutation.SetCodexModelsManifestConfig(v)
}
if _, ok := _c.mutation.RpmLimit(); !ok {
v := group.DefaultRpmLimit
@@ -1123,6 +1191,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultMaxReasoningEffort
_c.mutation.SetMaxReasoningEffort(v)
}
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
v := group.DefaultMaxReasoningEffortOverLimit
_c.mutation.SetMaxReasoningEffortOverLimit(v)
}
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
v := group.DefaultReasoningEffortMappings
_c.mutation.SetReasoningEffortMappings(v)
@@ -1286,6 +1358,12 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.AllowLive(); !ok {
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
}
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)}
}
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)}
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
}
@@ -1303,8 +1381,11 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
if _, ok := _c.mutation.ModelAllowlist(); !ok {
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
}
if _, ok := _c.mutation.RpmLimit(); !ok {
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
@@ -1317,6 +1398,14 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
}
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
}
@@ -1560,6 +1649,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
_node.AllowLive = value
}
if value, ok := _c.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
_node.ForceOpenaiFast = value
}
if value, ok := _c.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
_node.FreeOpenaiFast = value
}
if value, ok := _c.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
_node.RequireOauthOnly = value
@@ -1576,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
_node.MessagesDispatchModelConfig = value
}
if value, ok := _c.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
_node.ModelsListConfig = value
if value, ok := _c.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
_node.ModelAllowlist = value
}
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
_node.CodexModelsManifestConfig = value
}
if value, ok := _c.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -1588,6 +1689,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
_node.MaxReasoningEffort = value
}
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
_node.MaxReasoningEffortOverLimit = value
}
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
_node.ReasoningEffortMappings = value
@@ -2618,6 +2723,30 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
return u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert {
u.Set(group.FieldForceOpenaiFast, v)
return u
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert {
u.SetExcluded(group.FieldForceOpenaiFast)
return u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert {
u.Set(group.FieldFreeOpenaiFast, v)
return u
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert {
u.SetExcluded(group.FieldFreeOpenaiFast)
return u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
u.Set(group.FieldRequireOauthOnly, v)
@@ -2666,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
return u
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
u.Set(group.FieldModelsListConfig, v)
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
u.Set(group.FieldModelAllowlist, v)
return u
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
u.SetExcluded(group.FieldModelsListConfig)
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
u.SetExcluded(group.FieldModelAllowlist)
return u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert {
u.Set(group.FieldCodexModelsManifestConfig, v)
return u
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert {
u.SetExcluded(group.FieldCodexModelsManifestConfig)
return u
}
@@ -2708,6 +2849,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
return u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
return u
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
return u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
u.Set(group.FieldReasoningEffortMappings, v)
@@ -3817,6 +3970,34 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
})
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetForceOpenaiFast(v)
})
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateForceOpenaiFast()
})
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetFreeOpenaiFast(v)
})
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateFreeOpenaiFast()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -3873,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetCodexModelsManifestConfig(v)
})
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateCodexModelsManifestConfig()
})
}
@@ -3922,6 +4117,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
})
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetMaxReasoningEffortOverLimit(v)
})
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateMaxReasoningEffortOverLimit()
})
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -5207,6 +5416,34 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
})
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetForceOpenaiFast(v)
})
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateForceOpenaiFast()
})
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetFreeOpenaiFast(v)
})
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateFreeOpenaiFast()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
@@ -5263,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetCodexModelsManifestConfig(v)
})
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateCodexModelsManifestConfig()
})
}
@@ -5312,6 +5563,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
})
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetMaxReasoningEffortOverLimit(v)
})
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateMaxReasoningEffortOverLimit()
})
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+162 -16
View File
@@ -989,6 +989,34 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
return _u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate {
_u.mutation.SetForceOpenaiFast(v)
return _u
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate {
if v != nil {
_u.SetForceOpenaiFast(*v)
}
return _u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate {
_u.mutation.SetFreeOpenaiFast(v)
return _u
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate {
if v != nil {
_u.SetFreeOpenaiFast(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
_u.mutation.SetRequireOauthOnly(v)
@@ -1045,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate {
_u.mutation.SetCodexModelsManifestConfig(v)
return _u
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate {
if v != nil {
_u.SetCodexModelsManifestConfig(*v)
}
return _u
}
@@ -1094,6 +1136,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
return _u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
_u.mutation.SetMaxReasoningEffortOverLimit(v)
return _u
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
if v != nil {
_u.SetMaxReasoningEffortOverLimit(*v)
}
return _u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
_u.mutation.SetReasoningEffortMappings(v)
@@ -1487,6 +1543,11 @@ func (_u *GroupUpdate) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
return nil
}
@@ -1797,6 +1858,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
@@ -1809,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -1821,6 +1891,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
}
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
}
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
}
@@ -3110,6 +3183,34 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
return _u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne {
_u.mutation.SetForceOpenaiFast(v)
return _u
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetForceOpenaiFast(*v)
}
return _u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne {
_u.mutation.SetFreeOpenaiFast(v)
return _u
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetFreeOpenaiFast(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
_u.mutation.SetRequireOauthOnly(v)
@@ -3166,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
_u.mutation.SetCodexModelsManifestConfig(v)
return _u
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
if v != nil {
_u.SetCodexModelsManifestConfig(*v)
}
return _u
}
@@ -3215,6 +3330,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
return _u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
_u.mutation.SetMaxReasoningEffortOverLimit(v)
return _u
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
if v != nil {
_u.SetMaxReasoningEffortOverLimit(*v)
}
return _u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
_u.mutation.SetReasoningEffortMappings(v)
@@ -3621,6 +3750,11 @@ func (_u *GroupUpdateOne) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
return nil
}
@@ -3948,6 +4082,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
@@ -3960,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -3972,6 +4115,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
}
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
}
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
}
+19 -13
View File
@@ -623,7 +623,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
@@ -776,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -954,13 +954,17 @@ var (
{Name: "sort_order", Type: field.TypeInt, Default: 0},
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
{Name: "allow_live", Type: field.TypeBool, Default: false},
{Name: "force_openai_fast", Type: field.TypeBool, Default: false},
{Name: "free_openai_fast", Type: field.TypeBool, Default: false},
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
@@ -1124,6 +1128,7 @@ var (
{Name: "amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
{Name: "pay_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
{Name: "fee_rate", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "bonus_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
{Name: "recharge_code", Type: field.TypeString, Size: 64},
{Name: "out_trade_no", Type: field.TypeString, Size: 64, Default: ""},
{Name: "payment_type", Type: field.TypeString, Size: 30},
@@ -1166,7 +1171,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "payment_orders_users_payment_orders",
Columns: []*schema.Column{PaymentOrdersColumns[39]},
Columns: []*schema.Column{PaymentOrdersColumns[40]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
@@ -1175,7 +1180,7 @@ var (
{
Name: "paymentorder_out_trade_no",
Unique: true,
Columns: []*schema.Column{PaymentOrdersColumns[8]},
Columns: []*schema.Column{PaymentOrdersColumns[9]},
Annotation: &entsql.IndexAnnotation{
Where: "out_trade_no <> ''",
},
@@ -1183,37 +1188,37 @@ var (
{
Name: "paymentorder_user_id",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[39]},
Columns: []*schema.Column{PaymentOrdersColumns[40]},
},
{
Name: "paymentorder_status",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[21]},
Columns: []*schema.Column{PaymentOrdersColumns[22]},
},
{
Name: "paymentorder_expires_at",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[29]},
Columns: []*schema.Column{PaymentOrdersColumns[30]},
},
{
Name: "paymentorder_created_at",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[37]},
Columns: []*schema.Column{PaymentOrdersColumns[38]},
},
{
Name: "paymentorder_paid_at",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[30]},
Columns: []*schema.Column{PaymentOrdersColumns[31]},
},
{
Name: "paymentorder_payment_type_paid_at",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[9], PaymentOrdersColumns[30]},
Columns: []*schema.Column{PaymentOrdersColumns[10], PaymentOrdersColumns[31]},
},
{
Name: "paymentorder_order_type",
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[14]},
Columns: []*schema.Column{PaymentOrdersColumns[15]},
},
},
}
@@ -1409,7 +1414,7 @@ var (
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
{Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"},
{Name: "expiry_warn_days", Type: field.TypeInt, Default: 7},
{Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true},
{Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true},
}
// ProxiesTable holds the schema information for the "proxies" table.
ProxiesTable = &schema.Table{
@@ -1801,6 +1806,7 @@ var (
{Name: "signup_source", Type: field.TypeString, Default: "email"},
{Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "restrict_public_groups", Type: field.TypeBool, Default: false},
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
{Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"},
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
+500 -60
View File
@@ -22158,14 +22158,18 @@ type GroupMutation struct {
addsort_order *int
allow_messages_dispatch *bool
allow_live *bool
force_openai_fast *bool
free_openai_fast *bool
require_oauth_only *bool
require_privacy_set *bool
default_mapped_model *string
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
models_list_config *domain.GroupModelsListConfig
model_allowlist *domain.GroupModelAllowlist
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
rpm_limit *int
addrpm_limit *int
max_reasoning_effort *string
max_reasoning_effort_over_limit *string
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
profit_control_enabled *bool
@@ -24944,6 +24948,78 @@ func (m *GroupMutation) ResetAllowLive() {
m.allow_live = nil
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (m *GroupMutation) SetForceOpenaiFast(b bool) {
m.force_openai_fast = &b
}
// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation.
func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) {
v := m.force_openai_fast
if v == nil {
return
}
return *v, true
}
// OldForceOpenaiFast returns the old "force_openai_fast" 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) OldForceOpenaiFast(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err)
}
return oldValue.ForceOpenaiFast, nil
}
// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field.
func (m *GroupMutation) ResetForceOpenaiFast() {
m.force_openai_fast = nil
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (m *GroupMutation) SetFreeOpenaiFast(b bool) {
m.free_openai_fast = &b
}
// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation.
func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) {
v := m.free_openai_fast
if v == nil {
return
}
return *v, true
}
// OldFreeOpenaiFast returns the old "free_openai_fast" 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) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err)
}
return oldValue.FreeOpenaiFast, nil
}
// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field.
func (m *GroupMutation) ResetFreeOpenaiFast() {
m.free_openai_fast = nil
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
m.require_oauth_only = &b
@@ -25088,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
m.messages_dispatch_model_config = nil
}
// SetModelsListConfig sets the "models_list_config" field.
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
m.models_list_config = &dmlc
// SetModelAllowlist sets the "model_allowlist" field.
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
m.model_allowlist = &dma
}
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
v := m.models_list_config
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
v := m.model_allowlist
if v == nil {
return
}
return *v, true
}
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
// OldModelAllowlist returns the old "model_allowlist" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
}
return oldValue.ModelsListConfig, nil
return oldValue.ModelAllowlist, nil
}
// ResetModelsListConfig resets all changes to the "models_list_config" field.
func (m *GroupMutation) ResetModelsListConfig() {
m.models_list_config = nil
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
func (m *GroupMutation) ResetModelAllowlist() {
m.model_allowlist = nil
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) {
m.codex_models_manifest_config = &dcmmc
}
// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation.
func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) {
v := m.codex_models_manifest_config
if v == nil {
return
}
return *v, true
}
// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" 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) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err)
}
return oldValue.CodexModelsManifestConfig, nil
}
// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field.
func (m *GroupMutation) ResetCodexModelsManifestConfig() {
m.codex_models_manifest_config = nil
}
// SetRpmLimit sets the "rpm_limit" field.
@@ -25216,6 +25328,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
m.max_reasoning_effort = nil
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
m.max_reasoning_effort_over_limit = &s
}
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
v := m.max_reasoning_effort_over_limit
if v == nil {
return
}
return *v, true
}
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" 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) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
}
return oldValue.MaxReasoningEffortOverLimit, nil
}
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
m.max_reasoning_effort_over_limit = nil
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
m.reasoning_effort_mappings = &dem
@@ -25773,7 +25921,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, 62)
fields := make([]string, 0, 66)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25927,6 +26075,12 @@ func (m *GroupMutation) Fields() []string {
if m.allow_live != nil {
fields = append(fields, group.FieldAllowLive)
}
if m.force_openai_fast != nil {
fields = append(fields, group.FieldForceOpenaiFast)
}
if m.free_openai_fast != nil {
fields = append(fields, group.FieldFreeOpenaiFast)
}
if m.require_oauth_only != nil {
fields = append(fields, group.FieldRequireOauthOnly)
}
@@ -25939,8 +26093,11 @@ func (m *GroupMutation) Fields() []string {
if m.messages_dispatch_model_config != nil {
fields = append(fields, group.FieldMessagesDispatchModelConfig)
}
if m.models_list_config != nil {
fields = append(fields, group.FieldModelsListConfig)
if m.model_allowlist != nil {
fields = append(fields, group.FieldModelAllowlist)
}
if m.codex_models_manifest_config != nil {
fields = append(fields, group.FieldCodexModelsManifestConfig)
}
if m.rpm_limit != nil {
fields = append(fields, group.FieldRpmLimit)
@@ -25948,6 +26105,9 @@ func (m *GroupMutation) Fields() []string {
if m.max_reasoning_effort != nil {
fields = append(fields, group.FieldMaxReasoningEffort)
}
if m.max_reasoning_effort_over_limit != nil {
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
}
if m.reasoning_effort_mappings != nil {
fields = append(fields, group.FieldReasoningEffortMappings)
}
@@ -26070,6 +26230,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.AllowMessagesDispatch()
case group.FieldAllowLive:
return m.AllowLive()
case group.FieldForceOpenaiFast:
return m.ForceOpenaiFast()
case group.FieldFreeOpenaiFast:
return m.FreeOpenaiFast()
case group.FieldRequireOauthOnly:
return m.RequireOauthOnly()
case group.FieldRequirePrivacySet:
@@ -26078,12 +26242,16 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.DefaultMappedModel()
case group.FieldMessagesDispatchModelConfig:
return m.MessagesDispatchModelConfig()
case group.FieldModelsListConfig:
return m.ModelsListConfig()
case group.FieldModelAllowlist:
return m.ModelAllowlist()
case group.FieldCodexModelsManifestConfig:
return m.CodexModelsManifestConfig()
case group.FieldRpmLimit:
return m.RpmLimit()
case group.FieldMaxReasoningEffort:
return m.MaxReasoningEffort()
case group.FieldMaxReasoningEffortOverLimit:
return m.MaxReasoningEffortOverLimit()
case group.FieldReasoningEffortMappings:
return m.ReasoningEffortMappings()
case group.FieldProfitControlEnabled:
@@ -26203,6 +26371,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldAllowMessagesDispatch(ctx)
case group.FieldAllowLive:
return m.OldAllowLive(ctx)
case group.FieldForceOpenaiFast:
return m.OldForceOpenaiFast(ctx)
case group.FieldFreeOpenaiFast:
return m.OldFreeOpenaiFast(ctx)
case group.FieldRequireOauthOnly:
return m.OldRequireOauthOnly(ctx)
case group.FieldRequirePrivacySet:
@@ -26211,12 +26383,16 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldDefaultMappedModel(ctx)
case group.FieldMessagesDispatchModelConfig:
return m.OldMessagesDispatchModelConfig(ctx)
case group.FieldModelsListConfig:
return m.OldModelsListConfig(ctx)
case group.FieldModelAllowlist:
return m.OldModelAllowlist(ctx)
case group.FieldCodexModelsManifestConfig:
return m.OldCodexModelsManifestConfig(ctx)
case group.FieldRpmLimit:
return m.OldRpmLimit(ctx)
case group.FieldMaxReasoningEffort:
return m.OldMaxReasoningEffort(ctx)
case group.FieldMaxReasoningEffortOverLimit:
return m.OldMaxReasoningEffortOverLimit(ctx)
case group.FieldReasoningEffortMappings:
return m.OldReasoningEffortMappings(ctx)
case group.FieldProfitControlEnabled:
@@ -26591,6 +26767,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetAllowLive(v)
return nil
case group.FieldForceOpenaiFast:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetForceOpenaiFast(v)
return nil
case group.FieldFreeOpenaiFast:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetFreeOpenaiFast(v)
return nil
case group.FieldRequireOauthOnly:
v, ok := value.(bool)
if !ok {
@@ -26619,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetMessagesDispatchModelConfig(v)
return nil
case group.FieldModelsListConfig:
v, ok := value.(domain.GroupModelsListConfig)
case group.FieldModelAllowlist:
v, ok := value.(domain.GroupModelAllowlist)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetModelsListConfig(v)
m.SetModelAllowlist(v)
return nil
case group.FieldCodexModelsManifestConfig:
v, ok := value.(domain.GroupCodexModelsManifestConfig)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCodexModelsManifestConfig(v)
return nil
case group.FieldRpmLimit:
v, ok := value.(int)
@@ -26640,6 +26837,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetMaxReasoningEffort(v)
return nil
case group.FieldMaxReasoningEffortOverLimit:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetMaxReasoningEffortOverLimit(v)
return nil
case group.FieldReasoningEffortMappings:
v, ok := value.([]domain.ReasoningEffortMapping)
if !ok {
@@ -27332,6 +27536,12 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldAllowLive:
m.ResetAllowLive()
return nil
case group.FieldForceOpenaiFast:
m.ResetForceOpenaiFast()
return nil
case group.FieldFreeOpenaiFast:
m.ResetFreeOpenaiFast()
return nil
case group.FieldRequireOauthOnly:
m.ResetRequireOauthOnly()
return nil
@@ -27344,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldMessagesDispatchModelConfig:
m.ResetMessagesDispatchModelConfig()
return nil
case group.FieldModelsListConfig:
m.ResetModelsListConfig()
case group.FieldModelAllowlist:
m.ResetModelAllowlist()
return nil
case group.FieldCodexModelsManifestConfig:
m.ResetCodexModelsManifestConfig()
return nil
case group.FieldRpmLimit:
m.ResetRpmLimit()
@@ -27353,6 +27566,9 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldMaxReasoningEffort:
m.ResetMaxReasoningEffort()
return nil
case group.FieldMaxReasoningEffortOverLimit:
m.ResetMaxReasoningEffortOverLimit()
return nil
case group.FieldReasoningEffortMappings:
m.ResetReasoningEffortMappings()
return nil
@@ -29897,6 +30113,8 @@ type PaymentOrderMutation struct {
addpay_amount *float64
fee_rate *float64
addfee_rate *float64
bonus_amount *float64
addbonus_amount *float64
recharge_code *string
out_trade_no *string
payment_type *string
@@ -30364,6 +30582,62 @@ func (m *PaymentOrderMutation) ResetFeeRate() {
m.addfee_rate = nil
}
// SetBonusAmount sets the "bonus_amount" field.
func (m *PaymentOrderMutation) SetBonusAmount(f float64) {
m.bonus_amount = &f
m.addbonus_amount = nil
}
// BonusAmount returns the value of the "bonus_amount" field in the mutation.
func (m *PaymentOrderMutation) BonusAmount() (r float64, exists bool) {
v := m.bonus_amount
if v == nil {
return
}
return *v, true
}
// OldBonusAmount returns the old "bonus_amount" field's value of the PaymentOrder entity.
// If the PaymentOrder 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 *PaymentOrderMutation) OldBonusAmount(ctx context.Context) (v float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBonusAmount requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err)
}
return oldValue.BonusAmount, nil
}
// AddBonusAmount adds f to the "bonus_amount" field.
func (m *PaymentOrderMutation) AddBonusAmount(f float64) {
if m.addbonus_amount != nil {
*m.addbonus_amount += f
} else {
m.addbonus_amount = &f
}
}
// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation.
func (m *PaymentOrderMutation) AddedBonusAmount() (r float64, exists bool) {
v := m.addbonus_amount
if v == nil {
return
}
return *v, true
}
// ResetBonusAmount resets all changes to the "bonus_amount" field.
func (m *PaymentOrderMutation) ResetBonusAmount() {
m.bonus_amount = nil
m.addbonus_amount = nil
}
// SetRechargeCode sets the "recharge_code" field.
func (m *PaymentOrderMutation) SetRechargeCode(s string) {
m.recharge_code = &s
@@ -31907,7 +32181,7 @@ func (m *PaymentOrderMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *PaymentOrderMutation) Fields() []string {
fields := make([]string, 0, 39)
fields := make([]string, 0, 40)
if m.user != nil {
fields = append(fields, paymentorder.FieldUserID)
}
@@ -31929,6 +32203,9 @@ func (m *PaymentOrderMutation) Fields() []string {
if m.fee_rate != nil {
fields = append(fields, paymentorder.FieldFeeRate)
}
if m.bonus_amount != nil {
fields = append(fields, paymentorder.FieldBonusAmount)
}
if m.recharge_code != nil {
fields = append(fields, paymentorder.FieldRechargeCode)
}
@@ -32047,6 +32324,8 @@ func (m *PaymentOrderMutation) Field(name string) (ent.Value, bool) {
return m.PayAmount()
case paymentorder.FieldFeeRate:
return m.FeeRate()
case paymentorder.FieldBonusAmount:
return m.BonusAmount()
case paymentorder.FieldRechargeCode:
return m.RechargeCode()
case paymentorder.FieldOutTradeNo:
@@ -32134,6 +32413,8 @@ func (m *PaymentOrderMutation) OldField(ctx context.Context, name string) (ent.V
return m.OldPayAmount(ctx)
case paymentorder.FieldFeeRate:
return m.OldFeeRate(ctx)
case paymentorder.FieldBonusAmount:
return m.OldBonusAmount(ctx)
case paymentorder.FieldRechargeCode:
return m.OldRechargeCode(ctx)
case paymentorder.FieldOutTradeNo:
@@ -32256,6 +32537,13 @@ func (m *PaymentOrderMutation) SetField(name string, value ent.Value) error {
}
m.SetFeeRate(v)
return nil
case paymentorder.FieldBonusAmount:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBonusAmount(v)
return nil
case paymentorder.FieldRechargeCode:
v, ok := value.(string)
if !ok {
@@ -32497,6 +32785,9 @@ func (m *PaymentOrderMutation) AddedFields() []string {
if m.addfee_rate != nil {
fields = append(fields, paymentorder.FieldFeeRate)
}
if m.addbonus_amount != nil {
fields = append(fields, paymentorder.FieldBonusAmount)
}
if m.addplan_id != nil {
fields = append(fields, paymentorder.FieldPlanID)
}
@@ -32523,6 +32814,8 @@ func (m *PaymentOrderMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedPayAmount()
case paymentorder.FieldFeeRate:
return m.AddedFeeRate()
case paymentorder.FieldBonusAmount:
return m.AddedBonusAmount()
case paymentorder.FieldPlanID:
return m.AddedPlanID()
case paymentorder.FieldSubscriptionGroupID:
@@ -32561,6 +32854,13 @@ func (m *PaymentOrderMutation) AddField(name string, value ent.Value) error {
}
m.AddFeeRate(v)
return nil
case paymentorder.FieldBonusAmount:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddBonusAmount(v)
return nil
case paymentorder.FieldPlanID:
v, ok := value.(int64)
if !ok {
@@ -32760,6 +33060,9 @@ func (m *PaymentOrderMutation) ResetField(name string) error {
case paymentorder.FieldFeeRate:
m.ResetFeeRate()
return nil
case paymentorder.FieldBonusAmount:
m.ResetBonusAmount()
return nil
case paymentorder.FieldRechargeCode:
m.ResetRechargeCode()
return nil
@@ -37150,33 +37453,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error {
// ProxyMutation represents an operation that mutates the Proxy nodes in the graph.
type ProxyMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
primary_proxies map[int64]struct{}
removedprimary_proxies map[int64]struct{}
clearedprimary_proxies bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
}
var _ ent.Mutation = (*ProxyMutation)(nil)
@@ -37940,6 +38246,60 @@ func (m *ProxyMutation) ResetAccounts() {
m.removedaccounts = nil
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids.
func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) {
if m.primary_proxies == nil {
m.primary_proxies = make(map[int64]struct{})
}
for i := range ids {
m.primary_proxies[ids[i]] = struct{}{}
}
}
// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) ClearPrimaryProxies() {
m.clearedprimary_proxies = true
}
// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared.
func (m *ProxyMutation) PrimaryProxiesCleared() bool {
return m.clearedprimary_proxies
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs.
func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) {
if m.removedprimary_proxies == nil {
m.removedprimary_proxies = make(map[int64]struct{})
}
for i := range ids {
delete(m.primary_proxies, ids[i])
m.removedprimary_proxies[ids[i]] = struct{}{}
}
}
// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) {
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return
}
// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation.
func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) {
for id := range m.primary_proxies {
ids = append(ids, id)
}
return
}
// ResetPrimaryProxies resets all changes to the "primary_proxies" edge.
func (m *ProxyMutation) ResetPrimaryProxies() {
m.primary_proxies = nil
m.clearedprimary_proxies = false
m.removedprimary_proxies = nil
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (m *ProxyMutation) ClearBackupProxy() {
m.clearedbackup_proxy = true
@@ -38381,10 +38741,13 @@ func (m *ProxyMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *ProxyMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.accounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.primary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.backup_proxy != nil {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38401,6 +38764,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.primary_proxies))
for id := range m.primary_proxies {
ids = append(ids, id)
}
return ids
case proxy.EdgeBackupProxy:
if id := m.backup_proxy; id != nil {
return []ent.Value{*id}
@@ -38411,10 +38780,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *ProxyMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.removedaccounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.removedprimary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
return edges
}
@@ -38428,16 +38800,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.removedprimary_proxies))
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ProxyMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.clearedaccounts {
edges = append(edges, proxy.EdgeAccounts)
}
if m.clearedprimary_proxies {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.clearedbackup_proxy {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38450,6 +38831,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool {
switch name {
case proxy.EdgeAccounts:
return m.clearedaccounts
case proxy.EdgePrimaryProxies:
return m.clearedprimary_proxies
case proxy.EdgeBackupProxy:
return m.clearedbackup_proxy
}
@@ -38474,6 +38857,9 @@ func (m *ProxyMutation) ResetEdge(name string) error {
case proxy.EdgeAccounts:
m.ResetAccounts()
return nil
case proxy.EdgePrimaryProxies:
m.ResetPrimaryProxies()
return nil
case proxy.EdgeBackupProxy:
m.ResetBackupProxy()
return nil
@@ -48422,6 +48808,7 @@ type UserMutation struct {
signup_source *string
last_login_at *time.Time
last_active_at *time.Time
restrict_public_groups *bool
balance_notify_enabled *bool
balance_notify_threshold_type *string
balance_notify_threshold *float64
@@ -49347,6 +49734,42 @@ func (m *UserMutation) ResetLastActiveAt() {
delete(m.clearedFields, user.FieldLastActiveAt)
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (m *UserMutation) SetRestrictPublicGroups(b bool) {
m.restrict_public_groups = &b
}
// RestrictPublicGroups returns the value of the "restrict_public_groups" field in the mutation.
func (m *UserMutation) RestrictPublicGroups() (r bool, exists bool) {
v := m.restrict_public_groups
if v == nil {
return
}
return *v, true
}
// OldRestrictPublicGroups returns the old "restrict_public_groups" field's value of the User entity.
// If the User 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 *UserMutation) OldRestrictPublicGroups(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldRestrictPublicGroups is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldRestrictPublicGroups requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldRestrictPublicGroups: %w", err)
}
return oldValue.RestrictPublicGroups, nil
}
// ResetRestrictPublicGroups resets all changes to the "restrict_public_groups" field.
func (m *UserMutation) ResetRestrictPublicGroups() {
m.restrict_public_groups = nil
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (m *UserMutation) SetBalanceNotifyEnabled(b bool) {
m.balance_notify_enabled = &b
@@ -50373,7 +50796,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 24)
fields := make([]string, 0, 25)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@@ -50428,6 +50851,9 @@ func (m *UserMutation) Fields() []string {
if m.last_active_at != nil {
fields = append(fields, user.FieldLastActiveAt)
}
if m.restrict_public_groups != nil {
fields = append(fields, user.FieldRestrictPublicGroups)
}
if m.balance_notify_enabled != nil {
fields = append(fields, user.FieldBalanceNotifyEnabled)
}
@@ -50490,6 +50916,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.LastLoginAt()
case user.FieldLastActiveAt:
return m.LastActiveAt()
case user.FieldRestrictPublicGroups:
return m.RestrictPublicGroups()
case user.FieldBalanceNotifyEnabled:
return m.BalanceNotifyEnabled()
case user.FieldBalanceNotifyThresholdType:
@@ -50547,6 +50975,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldLastLoginAt(ctx)
case user.FieldLastActiveAt:
return m.OldLastActiveAt(ctx)
case user.FieldRestrictPublicGroups:
return m.OldRestrictPublicGroups(ctx)
case user.FieldBalanceNotifyEnabled:
return m.OldBalanceNotifyEnabled(ctx)
case user.FieldBalanceNotifyThresholdType:
@@ -50694,6 +51124,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetLastActiveAt(v)
return nil
case user.FieldRestrictPublicGroups:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetRestrictPublicGroups(v)
return nil
case user.FieldBalanceNotifyEnabled:
v, ok := value.(bool)
if !ok {
@@ -50953,6 +51390,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldLastActiveAt:
m.ResetLastActiveAt()
return nil
case user.FieldRestrictPublicGroups:
m.ResetRestrictPublicGroups()
return nil
case user.FieldBalanceNotifyEnabled:
m.ResetBalanceNotifyEnabled()
return nil
+12 -1
View File
@@ -33,6 +33,8 @@ type PaymentOrder struct {
PayAmount float64 `json:"pay_amount,omitempty"`
// FeeRate holds the value of the "fee_rate" field.
FeeRate float64 `json:"fee_rate,omitempty"`
// BonusAmount holds the value of the "bonus_amount" field.
BonusAmount float64 `json:"bonus_amount,omitempty"`
// RechargeCode holds the value of the "recharge_code" field.
RechargeCode string `json:"recharge_code,omitempty"`
// OutTradeNo holds the value of the "out_trade_no" field.
@@ -132,7 +134,7 @@ func (*PaymentOrder) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case paymentorder.FieldForceRefund:
values[i] = new(sql.NullBool)
case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldRefundAmount:
case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldBonusAmount, paymentorder.FieldRefundAmount:
values[i] = new(sql.NullFloat64)
case paymentorder.FieldID, paymentorder.FieldUserID, paymentorder.FieldPlanID, paymentorder.FieldSubscriptionGroupID, paymentorder.FieldSubscriptionDays:
values[i] = new(sql.NullInt64)
@@ -204,6 +206,12 @@ func (_m *PaymentOrder) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.FeeRate = value.Float64
}
case paymentorder.FieldBonusAmount:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field bonus_amount", values[i])
} else if value.Valid {
_m.BonusAmount = value.Float64
}
case paymentorder.FieldRechargeCode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field recharge_code", values[i])
@@ -480,6 +488,9 @@ func (_m *PaymentOrder) String() string {
builder.WriteString("fee_rate=")
builder.WriteString(fmt.Sprintf("%v", _m.FeeRate))
builder.WriteString(", ")
builder.WriteString("bonus_amount=")
builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount))
builder.WriteString(", ")
builder.WriteString("recharge_code=")
builder.WriteString(_m.RechargeCode)
builder.WriteString(", ")
+10
View File
@@ -28,6 +28,8 @@ const (
FieldPayAmount = "pay_amount"
// FieldFeeRate holds the string denoting the fee_rate field in the database.
FieldFeeRate = "fee_rate"
// FieldBonusAmount holds the string denoting the bonus_amount field in the database.
FieldBonusAmount = "bonus_amount"
// FieldRechargeCode holds the string denoting the recharge_code field in the database.
FieldRechargeCode = "recharge_code"
// FieldOutTradeNo holds the string denoting the out_trade_no field in the database.
@@ -115,6 +117,7 @@ var Columns = []string{
FieldAmount,
FieldPayAmount,
FieldFeeRate,
FieldBonusAmount,
FieldRechargeCode,
FieldOutTradeNo,
FieldPaymentType,
@@ -166,6 +169,8 @@ var (
UserNameValidator func(string) error
// DefaultFeeRate holds the default value on creation for the "fee_rate" field.
DefaultFeeRate float64
// DefaultBonusAmount holds the default value on creation for the "bonus_amount" field.
DefaultBonusAmount float64
// RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save.
RechargeCodeValidator func(string) error
// DefaultOutTradeNo holds the default value on creation for the "out_trade_no" field.
@@ -249,6 +254,11 @@ func ByFeeRate(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFeeRate, opts...).ToFunc()
}
// ByBonusAmount orders the results by the bonus_amount field.
func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBonusAmount, opts...).ToFunc()
}
// ByRechargeCode orders the results by the recharge_code field.
func ByRechargeCode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRechargeCode, opts...).ToFunc()
+45
View File
@@ -90,6 +90,11 @@ func FeeRate(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldEQ(FieldFeeRate, v))
}
// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ.
func BonusAmount(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v))
}
// RechargeCode applies equality check predicate on the "recharge_code" field. It's identical to RechargeCodeEQ.
func RechargeCode(v string) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v))
@@ -590,6 +595,46 @@ func FeeRateLTE(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldLTE(FieldFeeRate, v))
}
// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field.
func BonusAmountEQ(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v))
}
// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field.
func BonusAmountNEQ(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldNEQ(FieldBonusAmount, v))
}
// BonusAmountIn applies the In predicate on the "bonus_amount" field.
func BonusAmountIn(vs ...float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldIn(FieldBonusAmount, vs...))
}
// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field.
func BonusAmountNotIn(vs ...float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldNotIn(FieldBonusAmount, vs...))
}
// BonusAmountGT applies the GT predicate on the "bonus_amount" field.
func BonusAmountGT(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldGT(FieldBonusAmount, v))
}
// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field.
func BonusAmountGTE(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldGTE(FieldBonusAmount, v))
}
// BonusAmountLT applies the LT predicate on the "bonus_amount" field.
func BonusAmountLT(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldLT(FieldBonusAmount, v))
}
// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field.
func BonusAmountLTE(v float64) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldLTE(FieldBonusAmount, v))
}
// RechargeCodeEQ applies the EQ predicate on the "recharge_code" field.
func RechargeCodeEQ(v string) predicate.PaymentOrder {
return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v))
+85
View File
@@ -81,6 +81,20 @@ func (_c *PaymentOrderCreate) SetNillableFeeRate(v *float64) *PaymentOrderCreate
return _c
}
// SetBonusAmount sets the "bonus_amount" field.
func (_c *PaymentOrderCreate) SetBonusAmount(v float64) *PaymentOrderCreate {
_c.mutation.SetBonusAmount(v)
return _c
}
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
func (_c *PaymentOrderCreate) SetNillableBonusAmount(v *float64) *PaymentOrderCreate {
if v != nil {
_c.SetBonusAmount(*v)
}
return _c
}
// SetRechargeCode sets the "recharge_code" field.
func (_c *PaymentOrderCreate) SetRechargeCode(v string) *PaymentOrderCreate {
_c.mutation.SetRechargeCode(v)
@@ -517,6 +531,10 @@ func (_c *PaymentOrderCreate) defaults() {
v := paymentorder.DefaultFeeRate
_c.mutation.SetFeeRate(v)
}
if _, ok := _c.mutation.BonusAmount(); !ok {
v := paymentorder.DefaultBonusAmount
_c.mutation.SetBonusAmount(v)
}
if _, ok := _c.mutation.OutTradeNo(); !ok {
v := paymentorder.DefaultOutTradeNo
_c.mutation.SetOutTradeNo(v)
@@ -577,6 +595,9 @@ func (_c *PaymentOrderCreate) check() error {
if _, ok := _c.mutation.FeeRate(); !ok {
return &ValidationError{Name: "fee_rate", err: errors.New(`ent: missing required field "PaymentOrder.fee_rate"`)}
}
if _, ok := _c.mutation.BonusAmount(); !ok {
return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PaymentOrder.bonus_amount"`)}
}
if _, ok := _c.mutation.RechargeCode(); !ok {
return &ValidationError{Name: "recharge_code", err: errors.New(`ent: missing required field "PaymentOrder.recharge_code"`)}
}
@@ -725,6 +746,10 @@ func (_c *PaymentOrderCreate) createSpec() (*PaymentOrder, *sqlgraph.CreateSpec)
_spec.SetField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
_node.FeeRate = value
}
if value, ok := _c.mutation.BonusAmount(); ok {
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
_node.BonusAmount = value
}
if value, ok := _c.mutation.RechargeCode(); ok {
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
_node.RechargeCode = value
@@ -1030,6 +1055,24 @@ func (u *PaymentOrderUpsert) AddFeeRate(v float64) *PaymentOrderUpsert {
return u
}
// SetBonusAmount sets the "bonus_amount" field.
func (u *PaymentOrderUpsert) SetBonusAmount(v float64) *PaymentOrderUpsert {
u.Set(paymentorder.FieldBonusAmount, v)
return u
}
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
func (u *PaymentOrderUpsert) UpdateBonusAmount() *PaymentOrderUpsert {
u.SetExcluded(paymentorder.FieldBonusAmount)
return u
}
// AddBonusAmount adds v to the "bonus_amount" field.
func (u *PaymentOrderUpsert) AddBonusAmount(v float64) *PaymentOrderUpsert {
u.Add(paymentorder.FieldBonusAmount, v)
return u
}
// SetRechargeCode sets the "recharge_code" field.
func (u *PaymentOrderUpsert) SetRechargeCode(v string) *PaymentOrderUpsert {
u.Set(paymentorder.FieldRechargeCode, v)
@@ -1711,6 +1754,27 @@ func (u *PaymentOrderUpsertOne) UpdateFeeRate() *PaymentOrderUpsertOne {
})
}
// SetBonusAmount sets the "bonus_amount" field.
func (u *PaymentOrderUpsertOne) SetBonusAmount(v float64) *PaymentOrderUpsertOne {
return u.Update(func(s *PaymentOrderUpsert) {
s.SetBonusAmount(v)
})
}
// AddBonusAmount adds v to the "bonus_amount" field.
func (u *PaymentOrderUpsertOne) AddBonusAmount(v float64) *PaymentOrderUpsertOne {
return u.Update(func(s *PaymentOrderUpsert) {
s.AddBonusAmount(v)
})
}
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
func (u *PaymentOrderUpsertOne) UpdateBonusAmount() *PaymentOrderUpsertOne {
return u.Update(func(s *PaymentOrderUpsert) {
s.UpdateBonusAmount()
})
}
// SetRechargeCode sets the "recharge_code" field.
func (u *PaymentOrderUpsertOne) SetRechargeCode(v string) *PaymentOrderUpsertOne {
return u.Update(func(s *PaymentOrderUpsert) {
@@ -2643,6 +2707,27 @@ func (u *PaymentOrderUpsertBulk) UpdateFeeRate() *PaymentOrderUpsertBulk {
})
}
// SetBonusAmount sets the "bonus_amount" field.
func (u *PaymentOrderUpsertBulk) SetBonusAmount(v float64) *PaymentOrderUpsertBulk {
return u.Update(func(s *PaymentOrderUpsert) {
s.SetBonusAmount(v)
})
}
// AddBonusAmount adds v to the "bonus_amount" field.
func (u *PaymentOrderUpsertBulk) AddBonusAmount(v float64) *PaymentOrderUpsertBulk {
return u.Update(func(s *PaymentOrderUpsert) {
s.AddBonusAmount(v)
})
}
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
func (u *PaymentOrderUpsertBulk) UpdateBonusAmount() *PaymentOrderUpsertBulk {
return u.Update(func(s *PaymentOrderUpsert) {
s.UpdateBonusAmount()
})
}
// SetRechargeCode sets the "recharge_code" field.
func (u *PaymentOrderUpsertBulk) SetRechargeCode(v string) *PaymentOrderUpsertBulk {
return u.Update(func(s *PaymentOrderUpsert) {
+54
View File
@@ -154,6 +154,27 @@ func (_u *PaymentOrderUpdate) AddFeeRate(v float64) *PaymentOrderUpdate {
return _u
}
// SetBonusAmount sets the "bonus_amount" field.
func (_u *PaymentOrderUpdate) SetBonusAmount(v float64) *PaymentOrderUpdate {
_u.mutation.ResetBonusAmount()
_u.mutation.SetBonusAmount(v)
return _u
}
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
func (_u *PaymentOrderUpdate) SetNillableBonusAmount(v *float64) *PaymentOrderUpdate {
if v != nil {
_u.SetBonusAmount(*v)
}
return _u
}
// AddBonusAmount adds value to the "bonus_amount" field.
func (_u *PaymentOrderUpdate) AddBonusAmount(v float64) *PaymentOrderUpdate {
_u.mutation.AddBonusAmount(v)
return _u
}
// SetRechargeCode sets the "recharge_code" field.
func (_u *PaymentOrderUpdate) SetRechargeCode(v string) *PaymentOrderUpdate {
_u.mutation.SetRechargeCode(v)
@@ -881,6 +902,12 @@ func (_u *PaymentOrderUpdate) sqlSave(ctx context.Context) (_node int, err error
if value, ok := _u.mutation.AddedFeeRate(); ok {
_spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
}
if value, ok := _u.mutation.BonusAmount(); ok {
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedBonusAmount(); ok {
_spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
}
if value, ok := _u.mutation.RechargeCode(); ok {
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
}
@@ -1217,6 +1244,27 @@ func (_u *PaymentOrderUpdateOne) AddFeeRate(v float64) *PaymentOrderUpdateOne {
return _u
}
// SetBonusAmount sets the "bonus_amount" field.
func (_u *PaymentOrderUpdateOne) SetBonusAmount(v float64) *PaymentOrderUpdateOne {
_u.mutation.ResetBonusAmount()
_u.mutation.SetBonusAmount(v)
return _u
}
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
func (_u *PaymentOrderUpdateOne) SetNillableBonusAmount(v *float64) *PaymentOrderUpdateOne {
if v != nil {
_u.SetBonusAmount(*v)
}
return _u
}
// AddBonusAmount adds value to the "bonus_amount" field.
func (_u *PaymentOrderUpdateOne) AddBonusAmount(v float64) *PaymentOrderUpdateOne {
_u.mutation.AddBonusAmount(v)
return _u
}
// SetRechargeCode sets the "recharge_code" field.
func (_u *PaymentOrderUpdateOne) SetRechargeCode(v string) *PaymentOrderUpdateOne {
_u.mutation.SetRechargeCode(v)
@@ -1974,6 +2022,12 @@ func (_u *PaymentOrderUpdateOne) sqlSave(ctx context.Context) (_node *PaymentOrd
if value, ok := _u.mutation.AddedFeeRate(); ok {
_spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
}
if value, ok := _u.mutation.BonusAmount(); ok {
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedBonusAmount(); ok {
_spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
}
if value, ok := _u.mutation.RechargeCode(); ok {
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
}
+18 -2
View File
@@ -55,11 +55,13 @@ type Proxy struct {
type ProxyEdges struct {
// Accounts holds the value of the accounts edge.
Accounts []*Account `json:"accounts,omitempty"`
// PrimaryProxies holds the value of the primary_proxies edge.
PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"`
// BackupProxy holds the value of the backup_proxy edge.
BackupProxy *Proxy `json:"backup_proxy,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [2]bool
loadedTypes [3]bool
}
// AccountsOrErr returns the Accounts value or an error if the edge
@@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) {
return nil, &NotLoadedError{edge: "accounts"}
}
// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge
// was not loaded in eager-loading.
func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) {
if e.loadedTypes[1] {
return e.PrimaryProxies, nil
}
return nil, &NotLoadedError{edge: "primary_proxies"}
}
// BackupProxyOrErr returns the BackupProxy value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) {
if e.BackupProxy != nil {
return e.BackupProxy, nil
} else if e.loadedTypes[1] {
} else if e.loadedTypes[2] {
return nil, &NotFoundError{label: proxy.Label}
}
return nil, &NotLoadedError{edge: "backup_proxy"}
@@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery {
return NewProxyClient(_m.config).QueryAccounts(_m)
}
// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity.
func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery {
return NewProxyClient(_m.config).QueryPrimaryProxies(_m)
}
// QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity.
func (_m *Proxy) QueryBackupProxy() *ProxyQuery {
return NewProxyClient(_m.config).QueryBackupProxy(_m)
+28 -1
View File
@@ -45,6 +45,8 @@ const (
FieldExpiryWarnDays = "expiry_warn_days"
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
EdgeAccounts = "accounts"
// EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations.
EdgePrimaryProxies = "primary_proxies"
// EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations.
EdgeBackupProxy = "backup_proxy"
// Table holds the table name of the proxy in the database.
@@ -56,6 +58,10 @@ const (
AccountsInverseTable = "accounts"
// AccountsColumn is the table column denoting the accounts relation/edge.
AccountsColumn = "proxy_id"
// PrimaryProxiesTable is the table that holds the primary_proxies relation/edge.
PrimaryProxiesTable = "proxies"
// PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge.
PrimaryProxiesColumn = "backup_proxy_id"
// BackupProxyTable is the table that holds the backup_proxy relation/edge.
BackupProxyTable = "proxies"
// BackupProxyColumn is the table column denoting the backup_proxy relation/edge.
@@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
}
}
// ByPrimaryProxiesCount orders the results by primary_proxies count.
func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...)
}
}
// ByPrimaryProxies orders the results by primary_proxies terms.
func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByBackupProxyField orders the results by backup_proxy field.
func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn),
)
}
func newPrimaryProxiesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
}
func newBackupProxyStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
}
+24 -1
View File
@@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy {
})
}
// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge.
func HasPrimaryProxies() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates).
func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := newPrimaryProxiesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge.
func HasBackupProxy() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
sqlgraph.HasNeighbors(s, step)
})
+33 -2
View File
@@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate {
return _c.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate {
_c.mutation.AddPrimaryProxyIDs(ids...)
return _c
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate {
return _c.SetBackupProxyID(v.ID)
@@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) {
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+92 -16
View File
@@ -21,13 +21,14 @@ import (
// ProxyQuery is the builder for querying Proxy entities.
type ProxyQuery struct {
config
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withPrimaryProxies *ProxyQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery {
return query
}
// QueryPrimaryProxies chains the current query on the "primary_proxies" edge.
func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryBackupProxy chains the current query on the "backup_proxy" edge.
func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
@@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
@@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery {
return nil
}
return &ProxyQuery{
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withPrimaryProxies: _q.withPrimaryProxies.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery {
return _q
}
// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to
// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withPrimaryProxies = query
return _q
}
// WithBackupProxy tells the query-builder to eager-load the nodes that are connected to
// the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery {
@@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
var (
nodes = []*Proxy{}
_spec = _q.querySpec()
loadedTypes = [2]bool{
loadedTypes = [3]bool{
_q.withAccounts != nil,
_q.withPrimaryProxies != nil,
_q.withBackupProxy != nil,
}
)
@@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
return nil, err
}
}
if query := _q.withPrimaryProxies; query != nil {
if err := _q.loadPrimaryProxies(ctx, query, nodes,
func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} },
func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil {
return nil, err
}
}
if query := _q.withBackupProxy; query != nil {
if err := _q.loadBackupProxy(ctx, query, nodes, nil,
func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil {
@@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod
}
return nil
}
func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Proxy)
for i := range nodes {
fks = append(fks, nodes[i].ID)
nodeids[nodes[i].ID] = nodes[i]
if init != nil {
init(nodes[i])
}
}
if len(query.ctx.Fields) > 0 {
query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID)
}
query.Where(predicate.Proxy(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
fk := n.BackupProxyID
if fk == nil {
return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}
func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*Proxy)
+170 -8
View File
@@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate {
return _u.SetBackupProxyID(v.ID)
@@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate {
_u.mutation.ClearBackupProxy()
@@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne {
return _u.SetBackupProxyID(v.ID)
@@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne {
_u.mutation.ClearBackupProxy()
@@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+60 -34
View File
@@ -1157,52 +1157,70 @@ func init() {
groupDescAllowLive := groupFields[47].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
groupDescForceOpenaiFast := groupFields[48].Descriptor()
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[48].Descriptor()
groupDescRequireOauthOnly := groupFields[50].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[49].Descriptor()
groupDescRequirePrivacySet := groupFields[51].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[50].Descriptor()
groupDescDefaultMappedModel := groupFields[52].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[51].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[52].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
groupDescModelAllowlist := groupFields[54].Descriptor()
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[53].Descriptor()
groupDescRpmLimit := groupFields[56].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
groupDescProfitControlEnabled := groupFields[56].Descriptor()
groupDescProfitControlEnabled := groupFields[60].Descriptor()
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
groupDescProfitMinMargin := groupFields[57].Descriptor()
groupDescProfitMinMargin := groupFields[61].Descriptor()
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1305,70 +1323,74 @@ func init() {
paymentorderDescFeeRate := paymentorderFields[6].Descriptor()
// paymentorder.DefaultFeeRate holds the default value on creation for the fee_rate field.
paymentorder.DefaultFeeRate = paymentorderDescFeeRate.Default.(float64)
// paymentorderDescBonusAmount is the schema descriptor for bonus_amount field.
paymentorderDescBonusAmount := paymentorderFields[7].Descriptor()
// paymentorder.DefaultBonusAmount holds the default value on creation for the bonus_amount field.
paymentorder.DefaultBonusAmount = paymentorderDescBonusAmount.Default.(float64)
// paymentorderDescRechargeCode is the schema descriptor for recharge_code field.
paymentorderDescRechargeCode := paymentorderFields[7].Descriptor()
paymentorderDescRechargeCode := paymentorderFields[8].Descriptor()
// paymentorder.RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save.
paymentorder.RechargeCodeValidator = paymentorderDescRechargeCode.Validators[0].(func(string) error)
// paymentorderDescOutTradeNo is the schema descriptor for out_trade_no field.
paymentorderDescOutTradeNo := paymentorderFields[8].Descriptor()
paymentorderDescOutTradeNo := paymentorderFields[9].Descriptor()
// paymentorder.DefaultOutTradeNo holds the default value on creation for the out_trade_no field.
paymentorder.DefaultOutTradeNo = paymentorderDescOutTradeNo.Default.(string)
// paymentorder.OutTradeNoValidator is a validator for the "out_trade_no" field. It is called by the builders before save.
paymentorder.OutTradeNoValidator = paymentorderDescOutTradeNo.Validators[0].(func(string) error)
// paymentorderDescPaymentType is the schema descriptor for payment_type field.
paymentorderDescPaymentType := paymentorderFields[9].Descriptor()
paymentorderDescPaymentType := paymentorderFields[10].Descriptor()
// paymentorder.PaymentTypeValidator is a validator for the "payment_type" field. It is called by the builders before save.
paymentorder.PaymentTypeValidator = paymentorderDescPaymentType.Validators[0].(func(string) error)
// paymentorderDescPaymentTradeNo is the schema descriptor for payment_trade_no field.
paymentorderDescPaymentTradeNo := paymentorderFields[10].Descriptor()
paymentorderDescPaymentTradeNo := paymentorderFields[11].Descriptor()
// paymentorder.PaymentTradeNoValidator is a validator for the "payment_trade_no" field. It is called by the builders before save.
paymentorder.PaymentTradeNoValidator = paymentorderDescPaymentTradeNo.Validators[0].(func(string) error)
// paymentorderDescOrderType is the schema descriptor for order_type field.
paymentorderDescOrderType := paymentorderFields[14].Descriptor()
paymentorderDescOrderType := paymentorderFields[15].Descriptor()
// paymentorder.DefaultOrderType holds the default value on creation for the order_type field.
paymentorder.DefaultOrderType = paymentorderDescOrderType.Default.(string)
// paymentorder.OrderTypeValidator is a validator for the "order_type" field. It is called by the builders before save.
paymentorder.OrderTypeValidator = paymentorderDescOrderType.Validators[0].(func(string) error)
// paymentorderDescProviderInstanceID is the schema descriptor for provider_instance_id field.
paymentorderDescProviderInstanceID := paymentorderFields[18].Descriptor()
paymentorderDescProviderInstanceID := paymentorderFields[19].Descriptor()
// paymentorder.ProviderInstanceIDValidator is a validator for the "provider_instance_id" field. It is called by the builders before save.
paymentorder.ProviderInstanceIDValidator = paymentorderDescProviderInstanceID.Validators[0].(func(string) error)
// paymentorderDescProviderKey is the schema descriptor for provider_key field.
paymentorderDescProviderKey := paymentorderFields[19].Descriptor()
paymentorderDescProviderKey := paymentorderFields[20].Descriptor()
// paymentorder.ProviderKeyValidator is a validator for the "provider_key" field. It is called by the builders before save.
paymentorder.ProviderKeyValidator = paymentorderDescProviderKey.Validators[0].(func(string) error)
// paymentorderDescStatus is the schema descriptor for status field.
paymentorderDescStatus := paymentorderFields[21].Descriptor()
paymentorderDescStatus := paymentorderFields[22].Descriptor()
// paymentorder.DefaultStatus holds the default value on creation for the status field.
paymentorder.DefaultStatus = paymentorderDescStatus.Default.(string)
// paymentorder.StatusValidator is a validator for the "status" field. It is called by the builders before save.
paymentorder.StatusValidator = paymentorderDescStatus.Validators[0].(func(string) error)
// paymentorderDescRefundAmount is the schema descriptor for refund_amount field.
paymentorderDescRefundAmount := paymentorderFields[22].Descriptor()
paymentorderDescRefundAmount := paymentorderFields[23].Descriptor()
// paymentorder.DefaultRefundAmount holds the default value on creation for the refund_amount field.
paymentorder.DefaultRefundAmount = paymentorderDescRefundAmount.Default.(float64)
// paymentorderDescForceRefund is the schema descriptor for force_refund field.
paymentorderDescForceRefund := paymentorderFields[25].Descriptor()
paymentorderDescForceRefund := paymentorderFields[26].Descriptor()
// paymentorder.DefaultForceRefund holds the default value on creation for the force_refund field.
paymentorder.DefaultForceRefund = paymentorderDescForceRefund.Default.(bool)
// paymentorderDescRefundRequestedBy is the schema descriptor for refund_requested_by field.
paymentorderDescRefundRequestedBy := paymentorderFields[28].Descriptor()
paymentorderDescRefundRequestedBy := paymentorderFields[29].Descriptor()
// paymentorder.RefundRequestedByValidator is a validator for the "refund_requested_by" field. It is called by the builders before save.
paymentorder.RefundRequestedByValidator = paymentorderDescRefundRequestedBy.Validators[0].(func(string) error)
// paymentorderDescClientIP is the schema descriptor for client_ip field.
paymentorderDescClientIP := paymentorderFields[34].Descriptor()
paymentorderDescClientIP := paymentorderFields[35].Descriptor()
// paymentorder.ClientIPValidator is a validator for the "client_ip" field. It is called by the builders before save.
paymentorder.ClientIPValidator = paymentorderDescClientIP.Validators[0].(func(string) error)
// paymentorderDescSrcHost is the schema descriptor for src_host field.
paymentorderDescSrcHost := paymentorderFields[35].Descriptor()
paymentorderDescSrcHost := paymentorderFields[36].Descriptor()
// paymentorder.SrcHostValidator is a validator for the "src_host" field. It is called by the builders before save.
paymentorder.SrcHostValidator = paymentorderDescSrcHost.Validators[0].(func(string) error)
// paymentorderDescCreatedAt is the schema descriptor for created_at field.
paymentorderDescCreatedAt := paymentorderFields[37].Descriptor()
paymentorderDescCreatedAt := paymentorderFields[38].Descriptor()
// paymentorder.DefaultCreatedAt holds the default value on creation for the created_at field.
paymentorder.DefaultCreatedAt = paymentorderDescCreatedAt.Default.(func() time.Time)
// paymentorderDescUpdatedAt is the schema descriptor for updated_at field.
paymentorderDescUpdatedAt := paymentorderFields[38].Descriptor()
paymentorderDescUpdatedAt := paymentorderFields[39].Descriptor()
// paymentorder.DefaultUpdatedAt holds the default value on creation for the updated_at field.
paymentorder.DefaultUpdatedAt = paymentorderDescUpdatedAt.Default.(func() time.Time)
// paymentorder.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
@@ -2217,24 +2239,28 @@ func init() {
user.DefaultSignupSource = userDescSignupSource.Default.(string)
// user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error)
// userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field.
userDescRestrictPublicGroups := userFields[15].Descriptor()
// user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field.
user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool)
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
userDescBalanceNotifyEnabled := userFields[15].Descriptor()
userDescBalanceNotifyEnabled := userFields[16].Descriptor()
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
// userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field.
userDescBalanceNotifyThresholdType := userFields[16].Descriptor()
userDescBalanceNotifyThresholdType := userFields[17].Descriptor()
// user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field.
user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string)
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
userDescBalanceNotifyExtraEmails := userFields[18].Descriptor()
userDescBalanceNotifyExtraEmails := userFields[19].Descriptor()
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
// userDescTotalRecharged is the schema descriptor for total_recharged field.
userDescTotalRecharged := userFields[19].Descriptor()
userDescTotalRecharged := userFields[20].Descriptor()
// user.DefaultTotalRecharged holds the default value on creation for the total_recharged field.
user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64)
// userDescRpmLimit is the schema descriptor for rpm_limit field.
userDescRpmLimit := userFields[20].Descriptor()
userDescRpmLimit := userFields[21].Descriptor()
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
+1 -1
View File
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+18 -4
View File
@@ -242,6 +242,12 @@ func (Group) Fields() []ent.Field {
field.Bool("allow_live").
Default(false).
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
field.Bool("force_openai_fast").
Default(false).
Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"),
field.Bool("free_openai_fast").
Default(false).
Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"),
field.Bool("require_oauth_only").
Default(false).
Comment("仅允许非 apikey 类型账号关联到此分组"),
@@ -256,10 +262,14 @@ func (Group) Fields() []ent.Field {
Default(domain.OpenAIMessagesDispatchModelConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
Default(domain.GroupModelsListConfig{}).
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
Default(domain.GroupModelAllowlist{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
Default(domain.GroupCodexModelsManifestConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"),
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
field.Int("rpm_limit").
@@ -271,10 +281,14 @@ func (Group) Fields() []ent.Field {
MaxLen(20).
Default("").
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
field.String("max_reasoning_effort_over_limit").
MaxLen(20).
Default("downgrade").
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
Default([]domain.ReasoningEffortMapping{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
// 的 token 分组可启用,composite 分组不能直接启用。
+4
View File
@@ -50,6 +50,10 @@ func (PaymentOrder) Fields() []ent.Field {
field.Float("fee_rate").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(0),
// 充值赠送额度(USD)。已计入 amount;单独记录用于订单展示与推广返利基数剔除。
field.Float("bonus_amount").
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
Default(0),
field.String("recharge_code").
MaxLen(64),
+4
View File
@@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge {
// accounts: 使用此代理的账户(反向边)
edge.From("accounts", Account.Type).
Ref("proxy"),
// Directed many-to-one: a backup can serve multiple primary proxies.
// The inverse edge prevents Ent from treating this self-reference as symmetric.
edge.From("primary_proxies", Proxy.Type).
Ref("backup_proxy"),
edge.To("backup_proxy", Proxy.Type).
Field("backup_proxy_id").
Unique(),
+5
View File
@@ -96,6 +96,11 @@ func (User) Fields() []ent.Field {
Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
// 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为),
// 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。
field.Bool("restrict_public_groups").
Default(false),
// 余额不足通知
field.Bool("balance_notify_enabled").
Default(true),
+1 -1
View File
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+12 -1
View File
@@ -53,6 +53,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// LastActiveAt holds the value of the "last_active_at" field.
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
// RestrictPublicGroups holds the value of the "restrict_public_groups" field.
RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"`
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
// BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field.
@@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled:
values[i] = new(sql.NullBool)
case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
values[i] = new(sql.NullFloat64)
@@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
_m.LastActiveAt = new(time.Time)
*_m.LastActiveAt = value.Time
}
case user.FieldRestrictPublicGroups:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i])
} else if value.Valid {
_m.RestrictPublicGroups = value.Bool
}
case user.FieldBalanceNotifyEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
@@ -588,6 +596,9 @@ func (_m *User) String() string {
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("restrict_public_groups=")
builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups))
builder.WriteString(", ")
builder.WriteString("balance_notify_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
builder.WriteString(", ")
+10
View File
@@ -51,6 +51,8 @@ const (
FieldLastLoginAt = "last_login_at"
// FieldLastActiveAt holds the string denoting the last_active_at field in the database.
FieldLastActiveAt = "last_active_at"
// FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database.
FieldRestrictPublicGroups = "restrict_public_groups"
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
FieldBalanceNotifyEnabled = "balance_notify_enabled"
// FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database.
@@ -212,6 +214,7 @@ var Columns = []string{
FieldSignupSource,
FieldLastLoginAt,
FieldLastActiveAt,
FieldRestrictPublicGroups,
FieldBalanceNotifyEnabled,
FieldBalanceNotifyThresholdType,
FieldBalanceNotifyThreshold,
@@ -280,6 +283,8 @@ var (
DefaultSignupSource string
// SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
SignupSourceValidator func(string) error
// DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field.
DefaultRestrictPublicGroups bool
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
DefaultBalanceNotifyEnabled bool
// DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field.
@@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc()
}
// ByRestrictPublicGroups orders the results by the restrict_public_groups field.
func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc()
}
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
+15
View File
@@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastActiveAt, v))
}
// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ.
func RestrictPublicGroups(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
func BalanceNotifyEnabled(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
@@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldLastActiveAt))
}
// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsNEQ(v bool) predicate.User {
return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
func BalanceNotifyEnabledEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
+65
View File
@@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate {
return _c
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate {
_c.mutation.SetRestrictPublicGroups(v)
return _c
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate {
if v != nil {
_c.SetRestrictPublicGroups(*v)
}
return _c
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultSignupSource
_c.mutation.SetSignupSource(v)
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
v := user.DefaultRestrictPublicGroups
_c.mutation.SetRestrictPublicGroups(v)
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
v := user.DefaultBalanceNotifyEnabled
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -730,6 +748,9 @@ func (_c *UserCreate) check() error {
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
}
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)}
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
}
@@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldLastActiveAt, field.TypeTime, value)
_node.LastActiveAt = &value
}
if value, ok := _c.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
_node.RestrictPublicGroups = value
}
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
_node.BalanceNotifyEnabled = value
@@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert {
return u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert {
u.Set(user.FieldRestrictPublicGroups, v)
return u
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert {
u.SetExcluded(user.FieldRestrictPublicGroups)
return u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
u.Set(user.FieldBalanceNotifyEnabled, v)
@@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
+34
View File
@@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
@@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
+17 -18
View File
@@ -31,7 +31,7 @@ require (
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.17.2
github.com/redis/go-redis/v9 v9.22.0
github.com/refraction-networking/utls v1.8.2
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.6
@@ -50,13 +50,13 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.41.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
golang.org/x/mod v0.38.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -101,7 +101,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
@@ -135,7 +134,7 @@ require (
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
@@ -193,20 +192,20 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
+42 -43
View File
@@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
@@ -300,8 +298,8 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -393,8 +391,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -511,30 +509,32 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -562,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
@@ -578,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -601,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -614,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -633,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -648,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -662,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -674,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -690,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -702,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+87 -11
View File
@@ -89,6 +89,7 @@ type Config struct {
Pricing PricingConfig `mapstructure:"pricing"`
Gateway GatewayConfig `mapstructure:"gateway"`
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
APIKeyCreate APIKeyCreateConfig `mapstructure:"api_key_create"`
SubscriptionCache SubscriptionCacheConfig `mapstructure:"subscription_cache"`
SubscriptionMaintenance SubscriptionMaintenanceConfig `mapstructure:"subscription_maintenance"`
Dashboard DashboardCacheConfig `mapstructure:"dashboard_cache"`
@@ -96,6 +97,7 @@ type Config struct {
UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"`
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
SimpleMode SimpleModeConfig `mapstructure:"simple_mode" yaml:"simple_mode"`
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
Gemini GeminiConfig `mapstructure:"gemini"`
@@ -104,6 +106,14 @@ type Config struct {
BatchImage BatchImageConfig `mapstructure:"batch_image"`
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
Plugins PluginConfig `mapstructure:"plugins"`
// Enforce only API-key spending windows in simple mode.
SimpleModeKeyRateLimitEnabled bool `mapstructure:"simple_mode_key_rate_limit_enabled" yaml:"simple_mode_key_rate_limit_enabled"`
}
// SimpleModeConfig controls startup behavior in simple mode.
type SimpleModeConfig struct {
AutoCreateDefaultGroups bool `mapstructure:"auto_create_default_groups" yaml:"auto_create_default_groups"`
}
// PluginConfig 控制管理员手动上传的本地进程插件。
@@ -667,6 +677,8 @@ type PricingConfig struct {
DataDir string `mapstructure:"data_dir"`
// 回退文件路径
FallbackFile string `mapstructure:"fallback_file"`
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
OverrideFile string `mapstructure:"override_file"`
// 更新间隔(小时)
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
// 哈希校验间隔(分钟)
@@ -910,6 +922,29 @@ type BillingConfig struct {
// UserPlatformQuotaSentinelTTLSeconds sentinel(无 limit 占位)entry 的 TTL,
// 显著短于 quota cache 默认 86400s 以控 Redis 内存;默认 3600=1h。
UserPlatformQuotaSentinelTTLSeconds int `mapstructure:"user_platform_quota_sentinel_ttl_seconds"`
// InflightReservation 余额模式在途请求预留(Redis),防止并发请求在预检时看到同一份余额而集体透支。
InflightReservation InflightReservationConfig `mapstructure:"inflight_reservation"`
}
// InflightReservationConfig 余额模式在途预留配置。
// 准入时按 输入估算 + 输出单价 × max_tokens 估算单请求费用,在 Redis 中原子地
// 校验 缓存余额 - 在途预留合计 >= 估算 后登记预留,请求结束(任意路径)释放。
// 估算失败或 Redis 不可用时 fail-open,退回旧的仅余额 > 阈值检查。
type InflightReservationConfig struct {
Enabled bool `mapstructure:"enabled"`
// TTLSeconds 单条预留的最长存活时间;进程崩溃等泄漏的预留到期自动失效。
TTLSeconds int `mapstructure:"ttl_seconds"`
// DefaultMaxTokens 请求未携带 max_tokens 时用于估算的输出 token 数。
DefaultMaxTokens int `mapstructure:"default_max_tokens"`
// MaxOutputTokens 估算输出 token 的上限(max_tokens 超出时截断)。
MaxOutputTokens int `mapstructure:"max_output_tokens"`
// MaxInputTokens 输入 token 估算(请求体字节数 / 4)的上限。
MaxInputTokens int `mapstructure:"max_input_tokens"`
// MaxReservationUSD 单请求预留金额上限;0 表示不设上限。
MaxReservationUSD float64 `mapstructure:"max_reservation_usd"`
// FailClosedOnUnpriced 无法为请求估算费用(模型/分组/渠道均无定价)时是否拒绝请求。
// 默认 false:放行且不预留(fail-open,节流告警日志)。
FailClosedOnUnpriced bool `mapstructure:"fail_closed_on_unpriced"`
}
type CircuitBreakerConfig struct {
@@ -1272,11 +1307,15 @@ type GatewayOpenAIWSConfig struct {
MaxConnsPerAccount int `mapstructure:"max_conns_per_account"`
MinIdlePerAccount int `mapstructure:"min_idle_per_account"`
MaxIdlePerAccount int `mapstructure:"max_idle_per_account"`
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限。
// 旧版及 mode_router_v2 的 ctx_pool 共用此开关和类型系数;关闭后使用 max_conns_per_account。
// mode_router_v2 下并发数 <= 0 的账号仍不可调度。
DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"`
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor))
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor),再受 max_conns_per_account 封顶)。
// ctx_pool 接入下每个客户端会话在整个生命周期(含轮次之间)持有一条上游连接,此上限限制的是同时持有连接的会话数,
// 在飞请求数另由账号并发槽限制;系数 1.0 会让存活会话数一到并发数就返回 1013 busy,默认 5.0。
OAuthMaxConnsFactor float64 `mapstructure:"oauth_max_conns_factor"`
// APIKeyMaxConnsFactor: API Key 账号连接池系数(effective=ceil(concurrency*factor))
// APIKeyMaxConnsFactor: API Key 账号连接池系数,含义与 OAuthMaxConnsFactor 相同,默认 5.0。
APIKeyMaxConnsFactor float64 `mapstructure:"apikey_max_conns_factor"`
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
@@ -1611,10 +1650,10 @@ type OpsCleanupConfig struct {
Enabled bool `mapstructure:"enabled"`
Schedule string `mapstructure:"schedule"`
// Retention days (0 disables that cleanup target).
//
// vNext requirement: default 30 days across ops datasets.
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
// system logs require a positive value because their runtime setting is bounded.
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
}
@@ -1681,6 +1720,14 @@ type APIKeyAuthCacheConfig struct {
InvalidAbuse InvalidAuthAbuseConfig `mapstructure:"invalid_abuse"`
}
// APIKeyCreateConfig 用户创建 API Key 的防滥用限制(0 表示不限制)
type APIKeyCreateConfig struct {
// MaxActivePerUser 单个用户同时存在(未删除)的 API Key 上限
MaxActivePerUser int `mapstructure:"max_active_per_user"`
// MaxPerUserPerHour 单个用户每小时可创建的 API Key 次数(删除不返还次数)
MaxPerUserPerHour int `mapstructure:"max_per_user_per_hour"`
}
type InvalidAuthAbuseConfig struct {
Enabled bool `mapstructure:"enabled"`
Threshold int `mapstructure:"threshold"`
@@ -1986,6 +2033,8 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) {
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
viper.SetDefault("simple_mode.auto_create_default_groups", true)
viper.SetDefault("simple_mode_key_rate_limit_enabled", false)
// Server
viper.SetDefault("server.host", "0.0.0.0")
@@ -2044,7 +2093,9 @@ func setDefaults() {
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"api.minimaxi.com", // MiniMax CN quota + inference
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
"opencode.ai",
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
@@ -2074,6 +2125,13 @@ func setDefaults() {
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
viper.SetDefault("billing.inflight_reservation.enabled", true)
viper.SetDefault("billing.inflight_reservation.ttl_seconds", 900)
viper.SetDefault("billing.inflight_reservation.default_max_tokens", 8192)
viper.SetDefault("billing.inflight_reservation.max_output_tokens", 128000)
viper.SetDefault("billing.inflight_reservation.max_input_tokens", 200000)
viper.SetDefault("billing.inflight_reservation.max_reservation_usd", 0)
viper.SetDefault("billing.inflight_reservation.fail_closed_on_unpriced", false)
// Turnstile
viper.SetDefault("turnstile.required", false)
@@ -2248,6 +2306,7 @@ func setDefaults() {
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
// Retention days: vNext defaults to 30 days across ops datasets.
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
viper.SetDefault("ops.aggregation.enabled", true)
@@ -2279,11 +2338,12 @@ func setDefaults() {
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.data_dir", "./data")
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
viper.SetDefault("pricing.override_file", "")
viper.SetDefault("pricing.update_interval_hours", 24)
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
@@ -2311,6 +2371,8 @@ func setDefaults() {
viper.SetDefault("api_key_auth_cache.invalid_abuse.window_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.block_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.capacity", 16384)
viper.SetDefault("api_key_create.max_active_per_user", 200)
viper.SetDefault("api_key_create.max_per_user_per_hour", 60)
// Subscription auth L1 cache
viper.SetDefault("subscription_cache.l1_size", 16384)
@@ -2370,7 +2432,7 @@ func setDefaults() {
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
viper.SetDefault("gateway.openai_compact_model", "gpt-5.5")
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
@@ -2396,8 +2458,8 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.min_idle_per_account", 4)
viper.SetDefault("gateway.openai_ws.max_idle_per_account", 12)
viper.SetDefault("gateway.openai_ws.dynamic_max_conns_by_account_concurrency_enabled", true)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.dial_timeout_seconds", 10)
viper.SetDefault("gateway.openai_ws.read_timeout_seconds", 900)
viper.SetDefault("gateway.openai_ws.write_timeout_seconds", 120)
@@ -2690,6 +2752,12 @@ func (c *Config) Validate() error {
return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must be positive")
}
}
if c.APIKeyCreate.MaxActivePerUser < 0 {
return fmt.Errorf("api_key_create.max_active_per_user must be non-negative")
}
if c.APIKeyCreate.MaxPerUserPerHour < 0 {
return fmt.Errorf("api_key_create.max_per_user_per_hour must be non-negative")
}
if c.APIKeyAuth.InvalidAbuse.Enabled {
if c.APIKeyAuth.InvalidAbuse.Threshold < 10 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.threshold must be at least 10")
@@ -3041,6 +3109,11 @@ func (c *Config) Validate() error {
if c.Billing.MinimumBalanceReserve < 0 {
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
}
if c.Billing.InflightReservation.TTLSeconds < 0 || c.Billing.InflightReservation.DefaultMaxTokens < 0 ||
c.Billing.InflightReservation.MaxOutputTokens < 0 || c.Billing.InflightReservation.MaxInputTokens < 0 ||
c.Billing.InflightReservation.MaxReservationUSD < 0 {
return fmt.Errorf("billing.inflight_reservation values must be non-negative")
}
if c.Database.MaxOpenConns <= 0 {
return fmt.Errorf("database.max_open_conns must be positive")
}
@@ -3670,6 +3743,9 @@ func (c *Config) Validate() error {
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
}
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
}
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
}
+73 -5
View File
@@ -79,6 +79,15 @@ func TestLoadServerTimingConfig(t *testing.T) {
})
}
func TestLoadSimpleModeKeyRateLimitEnabledFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED", "true")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.SimpleModeKeyRateLimitEnabled)
}
func TestLoadRedisUsernameFromEnvironment(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("REDIS_USERNAME", "app-user")
@@ -452,11 +461,11 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if !cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled {
t.Fatalf("Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = false, want true")
}
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
@@ -574,7 +583,7 @@ func TestLoadDefaultOpenAICompactModel(t *testing.T) {
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
require.Equal(t, "gpt-5.5", cfg.Gateway.OpenAICompactModel)
}
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
@@ -1191,6 +1200,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
}
}
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Ops.Cleanup.Enabled {
t.Fatal("Ops.Cleanup.Enabled = false, want true")
}
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
}
}
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -2137,6 +2161,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
wantErr: "ops.cleanup.error_log_retention_days",
},
{
name: "ops cleanup system log retention",
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
wantErr: "ops.cleanup.system_log_retention_days",
},
{
name: "ops cleanup minute retention",
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
@@ -2610,3 +2639,42 @@ func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) {
t.Fatalf("image stream timeout = %d, want greater than ordinary stream timeout %d", cfg.Gateway.ImageStreamDataIntervalTimeout, cfg.Gateway.StreamDataIntervalTimeout)
}
}
func TestLoadSimpleModeAutoCreateDefaultGroups(t *testing.T) {
for _, loader := range []struct {
name string
load func() (*Config, error)
}{{"Load", Load}, {"LoadForBootstrap", LoadForBootstrap}} {
t.Run(loader.name, func(t *testing.T) {
for _, tt := range []struct {
name string
yaml string
env string
unset bool
want bool
}{
{name: "unset env uses application default", unset: true, want: true},
{name: "empty env uses application default", want: true},
{name: "unset env preserves yaml false", unset: true, yaml: "simple_mode:\n auto_create_default_groups: false\n", want: false},
{name: "empty env preserves yaml false", yaml: "simple_mode:\n auto_create_default_groups: false\n", want: false},
{name: "env false overrides yaml true", yaml: "simple_mode:\n auto_create_default_groups: true\n", env: "false", want: false},
{name: "env false without yaml", env: "false", want: false},
{name: "env true overrides yaml false", yaml: "simple_mode:\n auto_create_default_groups: false\n", env: "true", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS", tt.env)
if tt.unset {
require.NoError(t, os.Unsetenv("SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS"))
}
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("run_mode: simple\n"+tt.yaml), 0o600))
t.Setenv("CONFIG_FILE", path)
cfg, err := loader.load()
require.NoError(t, err)
require.Equal(t, tt.want, cfg.SimpleMode.AutoCreateDefaultGroups)
})
}
})
}
}
@@ -19,7 +19,8 @@ import "time"
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
// - "monthly" 月窗口(OpenCode Go)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
@@ -0,0 +1,15 @@
package domain
// GroupCodexModelsManifestConfig controls which accounts fetch the Codex
// models manifest for an OpenAI group. When enabled, the group's Codex
// /models requests bypass the scheduler and only use the pinned accounts.
type GroupCodexModelsManifestConfig struct {
Enabled bool `json:"enabled"`
// AccountIDs is the ordered list of pinned account IDs. Order decides
// merge precedence for duplicate model slugs.
AccountIDs []int64 `json:"account_ids,omitempty"`
// FallbackToScheduler controls the behavior when no pinned account is
// usable or all upstream fetches fail: fall back to the scheduler loop
// (true) or return the upstream error / 503 (false, default).
FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"`
}
+32 -9
View File
@@ -24,10 +24,15 @@ const (
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformTypeSafe = "typesafe" // TypeSafe AI System One (Jev)
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
PlatformOpenCodeGo = "opencode_go"
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
@@ -35,6 +40,8 @@ const (
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1
AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
@@ -43,7 +50,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -98,6 +105,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
var DefaultAntigravityModelMapping = map[string]string{
// Claude 白名单
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
"claude-fable-5": "claude-fable-5", // 官方模型
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
@@ -105,11 +113,11 @@ var DefaultAntigravityModelMapping = map[string]string{
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
// Claude 详细版本 ID 映射
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
// Claude Haiku → Sonnet(无 Haiku 支持)
"claude-haiku-4-5": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
@@ -144,6 +152,18 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// Gemini 3.7 Flash tiered models
"gemini-3.7-flash": "gemini-3.7-flash",
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered": "gemini-3.7-flash-tiered",
// Gemini 3.8 Flash tiered models
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
@@ -158,7 +178,8 @@ var DefaultAntigravityModelMapping = map[string]string{
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
var DefaultBedrockModelMapping = map[string]string{
// Claude Fable
"claude-fable-5": "anthropic.claude-fable-5",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
// Claude Opus
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
@@ -170,6 +191,8 @@ 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
// Sonnet 5.5 is available on bedrock-runtime through Global inference only.
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
"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",
+38 -4
View File
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5": "claude-fable-5",
"claude-opus-4-8": "claude-opus-4-8",
"claude-fable-5-1": "claude-fable-5-1",
"claude-fable-5": "claude-fable-5",
"claude-opus-4-8": "claude-opus-4-8",
}
for from, want := range cases {
got, ok := DefaultAntigravityModelMapping[from]
@@ -43,6 +44,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
}
for model, want := range cases {
if got := DefaultAntigravityModelMapping[model]; got != want {
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
t.Parallel()
@@ -73,12 +89,30 @@ func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini37FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.7-flash", "gemini-3.7-flash-high", "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
}
for from, want := range cases {
got, ok := DefaultBedrockModelMapping[from]
@@ -0,0 +1,8 @@
package domain
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
type GroupModelAllowlist struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
@@ -1,7 +0,0 @@
package domain
// GroupModelsListConfig controls the optional custom /v1/models response list.
type GroupModelsListConfig struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
+16 -3
View File
@@ -1,8 +1,21 @@
package domain
const (
ReasoningEffortMatchExact = "exact"
ReasoningEffortMatchPrefix = "prefix"
ReasoningEffortMatchSuffix = "suffix"
)
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
// value to another before the group ceiling is applied.
// value to another before the group ceiling is applied. To may also be "deny"
// to reject the request when the matching source value is present.
//
// Model and MatchType optionally scope the rewrite to a request model:
// exact matches the full model id, prefix/suffix match a model-id affix.
// Empty MatchType and empty Model mean the mapping applies to every model.
type ReasoningEffortMapping struct {
From string `json:"from"`
To string `json:"to"`
From string `json:"from"`
To string `json:"to"`
MatchType string `json:"match_type,omitempty"`
Model string `json:"model,omitempty"`
}
+10 -6
View File
@@ -309,15 +309,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: existingExpiresAt,
ClearExpiresAt: existingExpiresAt == nil,
FallbackMode: existingFallbackMode,
BackupProxyID: existingBackupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: existingBackupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: proxy.Name,
Protocol: proxy.Protocol,
Host: proxy.Host,
Port: proxy.Port,
Username: proxy.Username,
Password: proxy.Password,
Username: &proxy.Username,
Password: &proxy.Password,
})
}
}
@@ -383,15 +385,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: expiresAt,
ClearExpiresAt: expiresAt == nil,
FallbackMode: fallbackMode,
BackupProxyID: backupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: backupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: created.Name,
Protocol: created.Protocol,
Host: created.Host,
Port: created.Port,
Username: created.Username,
Password: created.Password,
Username: &created.Username,
Password: &created.Password,
})
}
}
+281 -27
View File
@@ -17,6 +17,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
@@ -26,6 +27,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/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -47,6 +49,7 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
// AccountHandler handles admin account management
type AccountHandler struct {
claudeResetCredits claudeResetReader
adminService service.AdminService
oauthService *service.OAuthService
openaiOAuthService *service.OpenAIOAuthService
@@ -64,6 +67,8 @@ type AccountHandler struct {
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
cfg *config.Config
opencodeGoUsage *service.OpenCodeGoUsageService
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -75,6 +80,10 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs
h.ollamaCloudUsage = usage
}
func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) {
h.opencodeGoUsage = usage
}
// NewAccountHandler creates a new admin account handler
func NewAccountHandler(
adminService service.AdminService,
@@ -190,6 +199,7 @@ type CheckMixedChannelRequest struct {
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
simpleMode bool `json:"-"`
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
@@ -199,6 +209,123 @@ type AccountWithConcurrency struct {
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
}
// AccountListItemWithConcurrency is the compact account-list envelope used
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
// so groups/account_groups never appear in the list payload.
type AccountListItemWithConcurrency struct {
*dto.AccountListItem
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
ActiveSessions *int `json:"active_sessions,omitempty"`
CurrentRPM *int `json:"current_rpm,omitempty"`
}
type simpleModeGroupReference struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type simpleModeAccountGroupReference struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
Group *simpleModeGroupReference `json:"group,omitempty"`
}
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
if group == nil {
return nil
}
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
}
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
visible := make([]int64, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := hidden[groupID]; !ok {
visible = append(visible, groupID)
}
}
return visible
}
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
type alias AccountWithConcurrency
if !a.simpleMode || a.Account == nil {
return json.Marshal(alias(a))
}
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
for _, group := range a.Groups {
if group != nil && group.Platform == service.PlatformComposite {
continue
}
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
groups = append(groups, *ref)
}
}
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
for _, accountGroup := range a.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
continue
}
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
continue
}
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
})
}
return json.Marshal(struct {
alias
GroupIDs []int64 `json:"group_ids,omitempty"`
Groups []simpleModeGroupReference `json:"groups"`
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
}
type AccountSchedulerScore struct {
BaseScore float64 `json:"base_score"`
StickyScore float64 `json:"sticky_score"`
@@ -223,9 +350,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
return out
}
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
out := dto.AccountFromServiceShallow(account)
if out != nil && account != nil {
out.Proxy = dto.ProxyFromService(account.Proxy)
}
if h != nil && h.ollamaCloudUsage != nil && out != nil {
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
}
return out
}
func (h *AccountHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: h.accountResponseFromService(account),
simpleMode: h.isSimpleMode(),
CurrentConcurrency: 0,
}
if account == nil {
@@ -538,14 +681,22 @@ func (h *AccountHandler) List(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if h.ollamaCloudUsage != nil && len(accounts) > 0 {
if len(accounts) > 0 {
accountPointers := make([]*service.Account, len(accounts))
for index := range accounts {
accountPointers[index] = &accounts[index]
}
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil {
response.ErrorFrom(c, err)
return
if h.ollamaCloudUsage != nil {
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil {
response.ErrorFrom(c, err)
return
}
}
if h.opencodeGoUsage != nil {
if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), accountPointers); err != nil {
response.ErrorFrom(c, err)
return
}
}
}
@@ -650,8 +801,16 @@ func (h *AccountHandler) List(c *gin.Context) {
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
acc := &accounts[i]
accountResponse := h.accountResponseFromService(acc)
if lite {
accountResponse = h.accountListResponseFromService(acc)
if h.isSimpleMode() {
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
}
}
item := AccountWithConcurrency{
Account: h.accountResponseFromService(acc),
Account: accountResponse,
simpleMode: h.isSimpleMode(),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
@@ -683,7 +842,34 @@ func (h *AccountHandler) List(c *gin.Context) {
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if lite {
compact := make([]AccountListItemWithConcurrency, len(result))
for i := range result {
item := result[i]
compact[i] = AccountListItemWithConcurrency{
AccountListItem: dto.AccountListItemFromAccount(item.Account),
CurrentConcurrency: item.CurrentConcurrency,
SchedulerScore: item.SchedulerScore,
SchedulerScores: item.SchedulerScores,
CurrentWindowCost: item.CurrentWindowCost,
ActiveSessions: item.ActiveSessions,
CurrentRPM: item.CurrentRPM,
}
}
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Paginated(c, compact, total, page, pageSize)
return
}
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
@@ -696,23 +882,23 @@ func (h *AccountHandler) List(c *gin.Context) {
response.Paginated(c, result, total, page, pageSize)
}
func buildAccountsListETag(
items []AccountWithConcurrency,
func buildAccountsListETag[T any](
items []T,
total int64,
page, pageSize int,
platform, accountType, status, search string,
lite bool,
) string {
payload := struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []AccountWithConcurrency `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []T `json:"items"`
}{
Total: total,
Page: page,
@@ -771,6 +957,12 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
return
}
}
if h.opencodeGoUsage != nil {
if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), []*service.Account{account}); err != nil {
response.ErrorFrom(c, err)
return
}
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}
@@ -837,6 +1029,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -970,6 +1166,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -1366,6 +1566,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
if warning == "missing_project_id_temporary" {
response.Success(c, gin.H{
"account": h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount),
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
"warning": "missing_project_id_temporary",
})
@@ -1424,7 +1625,15 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// Re-auth only returns authentication fields. Preserve account configuration
// stored alongside them (for example model_mapping), while allowing the new
// OAuth values to replace their existing counterparts.
req.Credentials = service.MergeCredentials(existing.Credentials, req.Credentials)
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
@@ -1875,6 +2084,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
}
}
groupIDs := make([]int64, 0)
for _, item := range req.Accounts {
groupIDs = append(groupIDs, item.GroupIDs...)
}
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
@@ -1897,6 +2114,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(item.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
failed++
results = append(results, gin.H{
"name": item.Name,
"success": false,
"error": err.Error(),
})
continue
}
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
@@ -2091,6 +2317,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -2576,6 +2806,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle OpenAI accounts
if account.IsOpenAI() {
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
// retain the legacy local catalog below so the test dialog remains usable.
if h.accountTestService != nil {
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
response.Success(c, models)
return
}
}
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
if account.IsOpenAIPassthroughEnabled() {
response.Success(c, openai.DefaultModels)
@@ -2713,6 +2951,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
// TypeSafe accounts serve only the native System One model.
if account.IsTypeSafe() {
response.Success(c, []claude.Model{{ID: typesafe.JevLatestModel, Type: "model", DisplayName: typesafe.JevLatestModel}})
return
}
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
@@ -2774,13 +3018,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
return
}
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
if err != nil {
var syncErr *service.UpstreamModelSyncError
if errors.As(err, &syncErr) {
switch syncErr.Kind {
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
response.BadRequest(c, syncErr.SafeMessage())
case service.UpstreamModelSyncErrorInternal:
response.InternalError(c, syncErr.SafeMessage())
default:
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
@@ -2793,29 +3039,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
return
}
response.Success(c, gin.H{"models": models})
response.Success(c, catalog)
}
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
// POST /api/v1/admin/accounts/models/sync-upstream-preview
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
var req struct {
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
ModelMapping map[string]string `json:"model_mapping"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
modelMapping := make(map[string]any, len(req.ModelMapping))
for sourceModel, upstreamModel := range req.ModelMapping {
modelMapping[sourceModel] = upstreamModel
}
tempAccount := &service.Account{
Platform: req.Platform,
Type: req.Type,
Credentials: map[string]any{
"api_key": req.APIKey,
"base_url": req.BaseURL,
"api_key": req.APIKey,
"base_url": req.BaseURL,
"model_mapping": modelMapping,
},
}
@@ -2824,13 +3076,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
return
}
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
if err != nil {
var syncErr *service.UpstreamModelSyncError
if errors.As(err, &syncErr) {
switch syncErr.Kind {
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
response.BadRequest(c, syncErr.SafeMessage())
case service.UpstreamModelSyncErrorInternal:
response.InternalError(c, syncErr.SafeMessage())
default:
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
@@ -2843,7 +3097,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
return
}
response.Success(c, gin.H{"models": models})
response.Success(c, catalog)
}
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
}
type syncUpstreamHTTPUpstream struct {
resp *http.Response
err error
resp *http.Response
responses []*http.Response
err error
}
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
if u.err != nil {
return nil, u.err
}
if len(u.responses) > 0 {
resp := u.responses[0]
u.responses = u.responses[1:]
return resp, nil
}
return u.resp, nil
}
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
)
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
return router
}
@@ -347,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
}
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
},
}
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"models":[{
"id":"custom-thinking-model",
"reasoning":true,
"default_reasoning_level":"high",
"supported_reasoning_levels":["low","high"],
"input_modalities":["text","image"],
"context_window":256000
}]}`)),
}}
router := setupSyncUpstreamModelsRouter(svc, upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data service.UpstreamModelCatalog `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
metadata := resp.Data.Metadata["custom-thinking-model"]
require.NotNil(t, metadata.Reasoning)
require.True(t, *metadata.Reasoning)
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
}
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
{
StatusCode: http.StatusNotFound,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{
"configured-provider": {
"api": "https://provider.example/v1",
"models": {
"glm-5.3": {
"id": "glm-5.3",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","high"]}],
"modalities": {"input":["text"],"output":["text"]},
"limit": {"context":1000000,"output":131072}
}
}
}
}`)),
},
}}
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/models/sync-upstream-preview",
strings.NewReader(`{
"platform":"openai",
"type":"apikey",
"base_url":"https://provider.example/v1",
"api_key":"key",
"model_mapping":{"public-glm":"glm-5.3"}
}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data service.UpstreamModelCatalog `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
}
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -377,3 +477,85 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
}
// Scenario: 能力补全失败显示部分成功。
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "opencode-id-only-model-list",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: map[string]any{
"api_key": "opencode-key",
"base_url": "https://opencode.ai/zen/v1",
},
},
}
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
},
{
StatusCode: http.StatusBadGateway,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
},
}}
router := setupSyncUpstreamModelsRouter(svc, upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data struct {
Models []string `json:"models"`
Warnings []struct {
Code string `json:"code"`
} `json:"warnings"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
require.Len(t, resp.Data.Warnings, 1)
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
}
func TestAccountHandlerGetAvailableModels_TypeSafeOnlyReturnsJev(t *testing.T) {
for _, credentials := range []map[string]any{
{"api_key": "ts-secret"},
{"api_key": "ts-secret", "model_mapping": map[string]any{"jev-latest": "jev-latest"}},
} {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "typesafe",
Platform: service.PlatformTypeSafe,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: credentials,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil))
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, "jev-latest", resp.Data[0].ID)
}
}
@@ -1,9 +1,12 @@
package admin
import (
"bytes"
"compress/gzip"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -13,6 +16,101 @@ import (
"github.com/stretchr/testify/require"
)
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
groupID := int64(77)
adminSvc.accounts = []service.Account{{
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
CreatedAt: now, UpdatedAt: now,
}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NotEmpty(t, rec.Header().Get("ETag"))
var litePayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
require.Len(t, litePayload.Data.Items, 1)
liteItem := litePayload.Data.Items[0]
require.Equal(t, float64(501), liteItem["id"])
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
require.Equal(t, true, liteItem["schedulable"])
require.NotContains(t, liteItem, "groups")
require.NotContains(t, liteItem, "account_groups")
credentials, ok := liteItem["credentials"].(map[string]any)
require.True(t, ok)
require.Equal(t, "compact@example.com", credentials["email"])
require.NotContains(t, credentials, "access_token")
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, credentialsStatus["has_access_token"])
// The ETag must represent the same compact body and return 304 on refresh.
rec304 := httptest.NewRecorder()
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
router.ServeHTTP(rec304, req304)
require.Equal(t, http.StatusNotModified, rec304.Code)
// Omitting lite preserves the legacy full response shape.
recFull := httptest.NewRecorder()
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
router.ServeHTTP(recFull, reqFull)
require.Equal(t, http.StatusOK, recFull.Code)
var fullPayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
require.Contains(t, fullPayload.Data.Items[0], "groups")
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
}
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
accounts := make([]service.Account, 20)
for i := range accounts {
id := int64(600 + i)
groupID := int64(800 + i)
accounts[i] = service.Account{
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
CreatedAt: now, UpdatedAt: now,
}
}
adminSvc.accounts = accounts
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Less(t, rec.Body.Len(), 80*1024)
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, err := zw.Write(rec.Body.Bytes())
require.NoError(t, err)
require.NoError(t, zw.Close())
require.Less(t, compressed.Len(), 15*1024)
}
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -142,6 +142,44 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta
require.Zero(t, stub.updateAccountExtraCalls)
}
func TestApplyOAuthCredentialsPreservesExistingNonAuthCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := newStubAdminService()
stub.getAccountResult = &service.Account{
ID: 1,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "old-token",
"refresh_token": "old-refresh-token",
"model_mapping": map[string]any{"gpt-5": "gpt-5"},
"account_id": "existing-account-id",
"password": "must-not-survive",
"sso_token": "must-not-survive",
"cookie": "must-not-survive",
},
}
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.POST("/accounts/:id/apply-oauth-credentials", handler.ApplyOAuthCredentials)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/accounts/1/apply-oauth-credentials", bytes.NewBufferString(
`{"type":"oauth","credentials":{"access_token":"new-token","refresh_token":"new-refresh-token"}}`,
))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, 1, stub.updateAccountCalls)
require.Equal(t, map[string]any{
"access_token": "new-token",
"refresh_token": "new-refresh-token",
"model_mapping": map[string]any{"gpt-5": "gpt-5"},
"account_id": "existing-account-id",
}, stub.lastUpdateAccountInput.Credentials)
}
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
@@ -0,0 +1,240 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type simpleModeAccountService struct {
*stubAdminService
account service.Account
createCalls int
updateCalls int
bulkCalls int
}
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
return &s.account, nil
}
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
return nil, err
}
s.createCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.updateCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.bulkCalls++
return &service.BulkUpdateAccountsResult{}, nil
}
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
for _, id := range groupIDs {
for i := range s.groups {
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
}
}
}
return nil
}
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
gin.SetMode(gin.TestMode)
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9},
Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
r.GET("/accounts/:id", h.GetByID)
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
tests := []struct {
method string
path string
body string
}{
{http.MethodGet, "/accounts", ""},
{http.MethodGet, "/accounts/3", ""},
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
}
for _, tt := range tests {
t.Run(tt.method+tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
data, ok := payload["data"].(map[string]any)
require.True(t, ok)
if items, ok := data["items"].([]any); ok {
require.Len(t, items, 1)
data, ok = items[0].(map[string]any)
require.True(t, ok)
}
groups, ok := data["groups"].([]any)
require.True(t, ok)
require.Len(t, groups, 1)
require.Equal(t, []any{float64(7)}, data["group_ids"])
group, ok := groups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
accountGroups, ok := data["account_groups"].([]any)
require.True(t, ok)
require.Len(t, accountGroups, 1)
accountGroup, ok := accountGroups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
nestedGroup, ok := accountGroup["group"].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
})
}
}
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.NotEmpty(t, res.Header().Get("ETag"))
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
require.NotContains(t, payload.Data.Items[0], "groups")
require.NotContains(t, payload.Data.Items[0], "account_groups")
notModified := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
r.ServeHTTP(notModified, req)
require.Equal(t, http.StatusNotModified, notModified.Code)
}
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct{ name, method, path, body string }{
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
} {
t.Run(tt.name, func(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
r.POST("/accounts/bulk-update", h.BulkUpdate)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
require.Zero(t, svc.updateCalls)
require.Zero(t, svc.bulkCalls)
})
}
}
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.Equal(t, 1, svc.createCalls)
}
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts/batch", h.BatchCreate)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
}
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
require.NoError(t, err)
require.Contains(t, string(raw), `"rate_multiplier":9`)
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
}
@@ -0,0 +1,121 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type openCodeGoUsageAutoRefreshRequest struct {
Enabled *bool `json:"enabled" binding:"required"`
}
func (h *AccountHandler) GetOpenCodeGoUsageSettings(c *gin.Context) {
if h.opencodeGoUsage == nil {
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return
}
settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) UpdateOpenCodeGoUsageSettings(c *gin.Context) {
if h.opencodeGoUsage == nil {
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return
}
var req service.OpenCodeGoUsageSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.opencodeGoUsage.UpdateSettings(c.Request.Context(), &req); err != nil {
response.ErrorFrom(c, err)
return
}
settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) GetOpenCodeGoUsage(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
state, err := h.opencodeGoUsage.GetState(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) SetOpenCodeGoUsageAutoRefresh(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
var req openCodeGoUsageAutoRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
state, err := h.opencodeGoUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) RefreshOpenCodeGoUsage(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
state, err := h.opencodeGoUsage.Refresh(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) requireOpenCodeGoUsage(c *gin.Context) bool {
if h != nil && h.opencodeGoUsage != nil {
return true
}
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return false
}
func openCodeGoUsageAccountID(c *gin.Context) (int64, bool) {
if c == nil {
return 0, false
}
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || accountID <= 0 {
response.BadRequest(c, "Invalid account ID")
return 0, false
}
return accountID, true
}
@@ -0,0 +1,206 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type openCodeGoUsageHandlerTestRepo struct {
service.AccountRepository
account *service.Account
accounts []*service.Account
}
func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
if r.account != nil && r.account.ID == id {
return r.account, nil
}
for _, account := range r.accounts {
if account.ID == id {
return account, nil
}
}
return nil, service.ErrAccountNotFound
}
func (r *openCodeGoUsageHandlerTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*service.Account) ([]service.Account, error) {
wanted := make(map[string]struct{}, len(anchors))
for _, anchor := range anchors {
if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(anchor); ok {
wanted[apiKey] = struct{}{}
}
}
result := make([]service.Account, 0, len(r.accounts)+1)
if r.account != nil {
if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(r.account); ok {
if _, match := wanted[apiKey]; match {
result = append(result, *r.account)
}
}
}
for _, account := range r.accounts {
if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(account); ok {
if _, match := wanted[apiKey]; match {
result = append(result, *account)
}
}
}
return result, nil
}
func openCodeGoUsageHandlerTestAPIKey(account *service.Account) (string, bool) {
if account == nil || account.Credentials == nil {
return "", false
}
apiKey, ok := account.Credentials["api_key"].(string)
return apiKey, ok && apiKey != ""
}
func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error {
return nil
}
func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error {
return nil
}
func (r *openCodeGoUsageHandlerTestRepo) DisableOpenCodeGoUsageAutoRefresh(context.Context, *service.Account) error {
return nil
}
func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) {
return nil, nil
}
func newOpenCodeGoUsageHandlerTestService(t *testing.T) *service.OpenCodeGoUsageService {
t.Helper()
svc := service.NewOpenCodeGoUsageService(nil, nil, nil)
t.Cleanup(svc.Stop)
return svc
}
func newOpenCodeGoUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, target, bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = request
if id != "" {
ctx.Params = gin.Params{{Key: "id", Value: id}}
}
return ctx, recorder
}
func TestOpenCodeGoUsageHandlersValidateRequestsAndDependencies(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newOpenCodeGoUsageHandlerTestService(t)
t.Run("invalid account id", func(t *testing.T) {
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/opencode-go-usage", "", "not-an-id")
(&AccountHandler{opencodeGoUsage: svc}).GetOpenCodeGoUsage(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
})
t.Run("missing enabled", func(t *testing.T) {
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodPut, "/admin/accounts/7/opencode-go-usage/auto-refresh", `{}`, "7")
(&AccountHandler{opencodeGoUsage: svc}).SetOpenCodeGoUsageAutoRefresh(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
})
t.Run("service unavailable", func(t *testing.T) {
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/7/opencode-go-usage", "", "7")
(&AccountHandler{}).GetOpenCodeGoUsage(ctx)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Contains(t, recorder.Body.String(), "OPENCODE_GO_USAGE_UNAVAILABLE")
})
}
func TestGetOpenCodeGoUsageSettingsHandlerSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/opencode-go-usage/settings", "", "")
handler := &AccountHandler{opencodeGoUsage: newOpenCodeGoUsageHandlerTestService(t)}
handler.GetOpenCodeGoUsageSettings(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"enabled":false`)
require.Contains(t, recorder.Body.String(), `"interval_minutes":15`)
}
func TestOpenCodeGoUsageStateEmbeddedInListAndDetail(t *testing.T) {
gin.SetMode(gin.TestMode)
now := time.Now().UTC()
account := &service.Account{
ID: 7, Name: "opencode", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"},
Extra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{
Status: service.OpenCodeGoUsageStatusOK, Data: &service.OpenCodeGoUsageData{
Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6},
},
LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
},
},
Status: service.StatusActive,
}
repo := &openCodeGoUsageHandlerTestRepo{account: account}
adminService := newStubAdminService()
adminService.accounts = []service.Account{*account}
adminService.getAccountResult = account
usageService := service.NewOpenCodeGoUsageService(repo, nil, nil)
t.Cleanup(usageService.Stop)
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler.SetOpenCodeGoUsageService(usageService)
router := gin.New()
router.GET("/accounts", handler.List)
router.GET("/accounts/:id", handler.GetByID)
router.GET("/accounts/:id/opencode-go-usage", handler.GetOpenCodeGoUsage)
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
require.Equal(t, http.StatusOK, listRecorder.Code)
var listPayload struct {
Data struct {
Items []struct {
OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
require.Len(t, listPayload.Data.Items, 1)
require.NotNil(t, listPayload.Data.Items[0].OpenCodeGoUsage)
require.True(t, listPayload.Data.Items[0].OpenCodeGoUsage.AutoRefreshEnabled)
require.Equal(t, 6.0, listPayload.Data.Items[0].OpenCodeGoUsage.Snapshot.Data.Rolling.Percent)
detailRecorder := httptest.NewRecorder()
router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil))
require.Equal(t, http.StatusOK, detailRecorder.Code)
var detailPayload struct {
Data struct {
OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
require.NotNil(t, detailPayload.Data.OpenCodeGoUsage)
require.Equal(t, 6.0, detailPayload.Data.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent)
stateRecorder := httptest.NewRecorder()
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/opencode-go-usage", nil))
require.Equal(t, http.StatusOK, stateRecorder.Code)
var statePayload struct {
Data service.OpenCodeGoUsageState `json:"data"`
}
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OpenCodeGoUsage.Snapshot)
for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} {
require.NotContains(t, body, "test-key")
}
}
@@ -0,0 +1,99 @@
package admin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// upstreamBillingRatesResponse is deliberately smaller than the regular
// account-list response. The frontend uses the returned ID order to detect a
// page boundary/order change and only applies snapshots when the page is
// otherwise stable.
type upstreamBillingRatesResponse struct {
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
// current account-list page. It never probes an upstream and never loads
// today-stats, usage, concurrency, or credentials for the response.
//
// GET /api/v1/admin/accounts/upstream-billing-rates
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
if h.adminService == nil {
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
accountType := c.Query("type")
status := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
if len(search) > 100 {
search = search[:100]
}
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
sortBy := c.DefaultQuery("sort_by", "name")
sortOrder := c.DefaultQuery("sort_order", "asc")
var groupID int64
if groupQuery := c.Query("group"); groupQuery != "" {
if groupQuery == accountListGroupUngroupedQueryValue {
groupID = service.AccountListGroupUngrouped
} else {
parsed, err := strconv.ParseInt(groupQuery, 10, 64)
if err != nil || parsed < 0 {
response.BadRequest(c, "invalid group filter")
return
}
groupID = parsed
}
}
accounts, total, err := h.adminService.ListAccounts(
c.Request.Context(), page, pageSize, platform, accountType, status,
search, groupID, privacyMode, sortBy, sortOrder,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
payload := upstreamBillingRatesResponse{
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
Total: total,
Page: page,
PageSize: pageSize,
}
c.Header("Cache-Control", "private, no-cache")
etag := buildUpstreamBillingRatesETag(payload)
if etag != "" {
c.Header("ETag", etag)
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Success(c, payload)
}
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router.GET("/api/v1/admin/groups", groupHandler.List)
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "gpt-5.5")
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
Rules: []dto.OpenAIFastPolicyRule{
{ServiceTier: "priority", Action: "filter", Scope: "all"},
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
},
}
out := openaiFastPolicySettingsFromDTO(in)
require.Len(t, out.Rules, 3)
require.Len(t, out.Rules, 4)
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
})
}
@@ -24,6 +24,13 @@ type stubAdminService struct {
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdGroups []*service.CreateGroupInput
updatedGroups []*service.UpdateGroupInput
deletedGroupIDs []int64
guardedDeletedGroupIDs []int64
deleteGroupIfEmptyErr error
advancedGroupOperationCalls int
lastListGroupsIsExclusive *bool
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
}
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
s.lastListGroupsIsExclusive = isExclusive
return s.groups, int64(len(s.groups)), nil
}
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
}
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return []service.CompositeModelRoute{
{
ID: 1,
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
}
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: 1,
GroupID: groupID,
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
}
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: routeID,
GroupID: groupID,
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
}
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
s.advancedGroupOperationCalls++
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
if err != nil {
return nil, err
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
}
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
s.createdGroups = append(s.createdGroups, input)
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
s.advancedGroupOperationCalls++
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
s.updatedGroups = append(s.updatedGroups, input)
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
return nil
}
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
return s.deleteGroupIfEmptyErr
}
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
}
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
}
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
return &account, nil
}
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
return &account, nil
@@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
response.Success(c, overview)
}
// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给
// 用户的金额,由 AffiliateService 校验。
type WithdrawQuotaRequest struct {
Amount float64 `json:"amount"`
}
// WithdrawQuota records an offline withdrawal paid outside the site and
// deducts it from the user's available affiliate quota. The required
// Idempotency-Key header identifies one registration: a repeated request with
// the same key deducts nothing and returns the first result with
// X-Idempotency-Replayed: true.
// POST /api/v1/admin/affiliates/users/:user_id/withdraw
func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req WithdrawQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount, c.GetHeader("Idempotency-Key"))
if err != nil {
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result)
}
// ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
@@ -214,7 +250,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListRebateRecords returns all order-level affiliate rebate records.
// ListRebateRecords returns every affiliate rebate accrual, with order details
// when the rebate came from a payment order.
// GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -227,7 +264,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListTransferRecords returns all affiliate quota-to-balance transfer records.
// ListTransferRecords returns affiliate quota outflows: user transfers into
// balance and admin-recorded offline withdrawals.
// GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -0,0 +1,172 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e"
type withdrawQuotaCall struct {
userID int64
amount float64
operationID string
}
type withdrawQuotaRepoStub struct {
service.AffiliateRepository
calls []withdrawQuotaCall
result *service.AffiliateWithdrawResult
err error
}
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID})
return s.result, s.err
}
type withdrawQuotaResponse struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data json.RawMessage `json:"data"`
}
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp withdrawQuotaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return rec, resp
}
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、
// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 0, resp.Code)
var data service.AffiliateWithdrawResult
require.NoError(t, json.Unmarshal(resp.Data, &data))
require.Equal(t, int64(9), data.LedgerID)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
require.Equal(t, int64(42), repo.calls[0].userID)
require.Equal(t, 12.34567891, repo.calls[0].amount)
require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID)
}
// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个
// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。
func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}}
for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} {
rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
}
require.Len(t, repo.calls, 3)
require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID)
require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID)
}
// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时
// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。
func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{
LedgerID: 9,
UserID: 42,
Amount: 10,
AvailableQuotaAfter: 90,
HistoryQuotaAfter: 100,
Replayed: true,
}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, `{
"ledger_id": 9,
"user_id": 42,
"amount": 10,
"available_quota_after": 90,
"frozen_quota_after": 0,
"history_quota_after": 100
}`, string(resp.Data))
}
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、
// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
cases := []struct {
name string
userID string
key string
body string
wantReason string
}{
{name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`},
{name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"},
{name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"},
{name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
{name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{}
rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, repo.calls)
})
}
}
// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、
// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。
func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) {
cases := []struct {
err error
wantStatus int
wantReason string
}{
{err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"},
{err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"},
}
for _, tc := range cases {
t.Run(tc.wantReason, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{err: tc.err}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`)
require.Equal(t, tc.wantStatus, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
})
}
}
@@ -140,7 +140,13 @@ func (h *BackupHandler) DeleteBackup(c *gin.Context) {
response.BadRequest(c, "backup ID is required")
return
}
if err := h.backupService.DeleteBackup(c.Request.Context(), backupID); err != nil {
var err error
if c.Query("delete_archived") == "true" {
err = h.backupService.DeleteArchivedBackup(c.Request.Context(), backupID)
} else {
err = h.backupService.DeleteBackup(c.Request.Context(), backupID)
}
if err != nil {
response.ErrorFrom(c, err)
return
}
@@ -57,20 +57,22 @@ type updateChannelRequest struct {
}
type channelModelPricingRequest struct {
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
}
type channelTimePricingRequest struct {
@@ -92,6 +94,7 @@ type pricingIntervalRequest struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
@@ -127,21 +130,23 @@ type channelResponse struct {
}
type channelModelPricingResponse struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
}
type channelTimePricingResponse struct {
@@ -164,6 +169,7 @@ type pricingIntervalResponse struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
@@ -254,21 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
intervals = append(intervals, intervalToResponse(iv))
}
return channelModelPricingResponse{
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
ReasoningEffortMultipliers: p.ReasoningEffortMultipliers,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
@@ -300,6 +308,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
@@ -334,6 +343,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: inputMultiplier,
OutputMultiplier: outputMultiplier,
@@ -349,20 +359,22 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
flexMultiplier = r.FlexMultiplier
}
result = append(result, service.ChannelModelPricing{
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheWrite1hPrice: r.CacheWrite1hPrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
ReasoningEffortMultipliers: r.ReasoningEffortMultipliers,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
@@ -597,15 +609,25 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
response.Success(c, gin.H{"found": false})
return
}
cacheWritePrice := pricing.CacheCreationPricePerToken
var cacheWrite1hPrice *float64
if pricing.SupportsCacheBreakdown {
if pricing.CacheCreation5mPrice > 0 {
cacheWritePrice = pricing.CacheCreation5mPrice
}
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
}
response.Success(c, gin.H{
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": pricing.CacheCreationPricePerToken,
"cache_read_price": pricing.CacheReadPricePerToken,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": cacheWritePrice,
"cache_write_1h_price": cacheWrite1hPrice,
"cache_read_price": pricing.CacheReadPricePerToken,
"reasoning_effort_multipliers": pricing.ReasoningEffortMultipliers,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
})
}
@@ -620,6 +642,9 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
service.PlatformTypeSafe: "typesafe",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
func TestPricingRequestToService_WithAllFields(t *testing.T) {
reqs := []channelModelPricingRequest{
{
Platform: "openai",
Models: []string{"gpt-4", "gpt-4o"},
BillingMode: "per_request",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.03),
CacheWritePrice: float64Ptr(0.005),
CacheReadPrice: float64Ptr(0.002),
ImageOutputPrice: float64Ptr(0.04),
PerRequestPrice: float64Ptr(0.5),
Platform: "openai",
Models: []string{"gpt-4", "gpt-4o"},
BillingMode: "per_request",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.03),
CacheWritePrice: float64Ptr(0.005),
CacheWrite1hPrice: float64Ptr(0.008),
CacheReadPrice: float64Ptr(0.002),
ImageOutputPrice: float64Ptr(0.04),
PerRequestPrice: float64Ptr(0.5),
},
}
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
require.Equal(t, float64Ptr(0.01), r.InputPrice)
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
BillingMode: "per_request",
Intervals: []pricingIntervalRequest{
{
MinTokens: 0,
MaxTokens: intPtr(2000),
TierLabel: "small",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.02),
CacheWritePrice: float64Ptr(0.003),
CacheReadPrice: float64Ptr(0.001),
PerRequestPrice: float64Ptr(0.1),
SortOrder: 1,
MinTokens: 0,
MaxTokens: intPtr(2000),
TierLabel: "small",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.02),
CacheWritePrice: float64Ptr(0.003),
CacheWrite1hPrice: float64Ptr(0.006),
CacheReadPrice: float64Ptr(0.001),
PerRequestPrice: float64Ptr(0.1),
SortOrder: 1,
},
{
MinTokens: 2000,
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
require.Equal(t, 1, iv0.SortOrder)
@@ -445,14 +449,14 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
require.Nil(t, got[0].TimePricing)
}
// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃,
// 避免渠道倍率意外污染账号成本口径。
// Fast/Flex 和区间倍率只用于渠道售价;思考等级倍率可在账号统计规则中独立配置。
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3},
Intervals: []pricingIntervalRequest{{
MinTokens: 272000,
InputMultiplier: float64Ptr(2),
@@ -465,6 +469,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
require.Equal(t, req.ReasoningEffortMultipliers, allowed[0].ReasoningEffortMultipliers)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
@@ -473,6 +478,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
require.Nil(t, dropped[0].FastMultiplier)
require.Nil(t, dropped[0].FlexMultiplier)
require.Equal(t, req.ReasoningEffortMultipliers, dropped[0].ReasoningEffortMultipliers)
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
@@ -504,6 +510,23 @@ func TestPricingToResponse_TimePricingNil(t *testing.T) {
require.Nil(t, got.TimePricing)
}
func TestPricingRequestAndResponse_ReasoningEffortMultipliers(t *testing.T) {
var req channelModelPricingRequest
require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{"none":0.5,"high":1.5,"max":3}}`), &req))
pricing := pricingRequestToService([]channelModelPricingRequest{req}, true)
got := pricingToResponse(&pricing[0])
require.Equal(t, map[string]float64{"none": 0.5, "high": 1.5, "max": 3}, got.ReasoningEffortMultipliers)
data, err := json.Marshal(got)
require.NoError(t, err)
require.Contains(t, string(data), `"reasoning_effort_multipliers":{"high":1.5,"max":3,"none":0.5}`)
require.NotContains(t, string(data), "max_reasoning_effort_multiplier")
req = channelModelPricingRequest{}
require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{}}`), &req))
pricing = pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Empty(t, pricing[0].ReasoningEffortMultipliers)
}
// ---------------------------------------------------------------------------
// 3. SyncPricingModels handler
// ---------------------------------------------------------------------------
@@ -542,7 +565,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax", "typesafe"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -558,3 +581,52 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
}
}
func setupModelDefaultPricingRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
return router
}
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
router := setupModelDefaultPricingRouter()
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data struct {
Found bool `json:"found"`
CacheWritePrice float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Data.Found)
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
require.NotNil(t, body.Data.CacheWrite1hPrice)
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
require.Empty(t, body.Data.ReasoningEffortMultipliers)
}
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
router := setupModelDefaultPricingRouter()
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data struct {
Found bool `json:"found"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Data.Found)
require.Nil(t, body.Data.CacheWrite1hPrice)
}
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -0,0 +1,56 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"strconv"
)
type claudeResetReader interface {
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
Redeem(context.Context, int64, string) (*service.ClaudeResetOutcome, error)
}
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
h.claudeResetCredits = s
}
func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
if h.claudeResetCredits == nil {
response.Error(c, 503, "Claude reset service unavailable")
return
}
status, err := h.claudeResetCredits.Query(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, status)
}
// RedeemClaudeResetCredit consumes the upstream next reset credit. The request has
// no body: the server picks the grant; the Idempotency-Key header identifies one
// operator confirmation and replays its outcome.
func (h *AccountHandler) RedeemClaudeResetCredit(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
if h.claudeResetCredits == nil {
response.Error(c, 503, "Claude reset service unavailable")
return
}
result, err := h.claudeResetCredits.Redeem(c.Request.Context(), id, c.GetHeader("Idempotency-Key"))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type claudeResetHandlerStub struct {
key string
calls int
}
func (s *claudeResetHandlerStub) Query(context.Context, int64) (*service.ClaudeResetCredits, error) {
return &service.ClaudeResetCredits{Credits: []service.ClaudeResetCredit{}}, nil
}
func (s *claudeResetHandlerStub) Redeem(_ context.Context, _ int64, key string) (*service.ClaudeResetOutcome, error) {
s.calls++
s.key = key
if key == "" {
return nil, service.ErrIdempotencyKeyRequired
}
return &service.ClaudeResetOutcome{Outcome: "reset", Cleared: []string{"five_hour"}}, nil
}
func TestClaudeResetHandlerRedeemContract(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &claudeResetHandlerStub{}
h := &AccountHandler{claudeResetCredits: stub}
r := gin.New()
r.POST("/accounts/:id/claude/reset-credits/redeem", h.RedeemClaudeResetCredit)
req := httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil)
req.Header.Set("Idempotency-Key", "same-operation")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "same-operation", stub.key)
require.Contains(t, w.Body.String(), `"outcome":"reset"`)
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil))
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "IDEMPOTENCY_KEY_REQUIRED")
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/abc/claude/reset-credits/redeem", nil))
require.Equal(t, http.StatusBadRequest, w.Code)
require.Equal(t, 2, stub.calls)
}
@@ -0,0 +1,53 @@
package admin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type moderationEngineSettings struct {
service.SettingRepository
value string
}
func (r *moderationEngineSettings) GetValue(context.Context, string) (string, error) {
return r.value, nil
}
func (r *moderationEngineSettings) Set(_ context.Context, _ string, value string) error {
r.value = value
return nil
}
func TestContentModerationEngineHandlerRoundTrip(t *testing.T) {
gin.SetMode(gin.TestMode)
settings := &moderationEngineSettings{value: `{"api_keys":["legacy-secret"],"model":"omni-moderation-latest"}`}
svc := service.NewContentModerationService(settings, nil, nil, nil, nil, nil, nil, nil)
h := NewContentModerationHandler(svc)
router := gin.New()
router.PUT("/config", h.UpdateConfig)
router.GET("/config", h.GetConfig)
body := `{"engine":"typesafe","engine_configs":{"typesafe":{"base_url":"https://api.typesafe.ai","model":"jev-latest","api_keys":["new-secret"],"thresholds":{"sexual":0.91}}}}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
require.Contains(t, w.Body.String(), `"engine":"typesafe"`)
require.Contains(t, w.Body.String(), `"sexual":0.91`)
require.NotContains(t, w.Body.String(), "new-secret")
require.NotContains(t, w.Body.String(), "legacy-secret")
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config", nil))
require.Equal(t, 200, w.Code)
require.Contains(t, w.Body.String(), `"engine_configs"`)
require.Contains(t, w.Body.String(), `"model":"jev-latest"`)
require.Contains(t, settings.value, "legacy-secret")
require.Contains(t, settings.value, "new-secret")
}
@@ -20,10 +20,12 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
}
type contentModerationConfigRequest struct {
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Engine *string `json:"engine"`
EngineConfigs map[string]service.UpdateContentModerationEngineInput `json:"engine_configs"`
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
// 审计请求使用的代理服务器:null 不修改;0 清除(直连);>0 指定代理。
ProxyID *int64 `json:"proxy_id"`
APIKey *string `json:"api_key"`
@@ -58,13 +60,15 @@ type contentModerationConfigRequest struct {
}
type contentModerationAPIKeyTestRequest struct {
APIKeys []string `json:"api_keys"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
TimeoutMS int `json:"timeout_ms"`
ProxyID *int64 `json:"proxy_id"`
Prompt string `json:"prompt"`
Images []string `json:"images"`
Engine string `json:"engine"`
Thresholds *map[string]float64 `json:"thresholds"`
APIKeys []string `json:"api_keys"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
TimeoutMS int `json:"timeout_ms"`
ProxyID *int64 `json:"proxy_id"`
Prompt string `json:"prompt"`
Images []string `json:"images"`
}
type contentModerationHashRequest struct {
@@ -87,6 +91,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
return
}
cfg, err := h.service.UpdateConfig(c.Request.Context(), service.UpdateContentModerationConfigInput{
Engine: req.Engine, EngineConfigs: req.EngineConfigs,
Enabled: req.Enabled,
Mode: req.Mode,
BaseURL: req.BaseURL,
@@ -134,6 +139,7 @@ func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
return
}
result, err := h.service.TestAPIKeys(c.Request.Context(), service.TestContentModerationAPIKeysInput{
Engine: req.Engine, Thresholds: req.Thresholds,
APIKeys: req.APIKeys,
BaseURL: req.BaseURL,
Model: req.Model,
@@ -262,13 +262,18 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get usage trend")
return
@@ -349,13 +354,18 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get model statistics")
return
@@ -426,13 +436,18 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get group statistics")
return
@@ -485,7 +500,11 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
limit = 12
}
trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
metric := c.DefaultQuery("metric", "tokens")
if metric != "tokens" && metric != "actual_cost" {
metric = "tokens"
}
trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit, metric)
if err != nil {
response.Error(c, 500, "Failed to get user usage trend")
return
@@ -701,6 +720,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
dim.Stream = &s
}
}
if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
value, err := strconv.ParseBool(v)
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
dim.NativeCompactionV2 = &value
}
if v := c.Query("billing_type"); v != "" {
if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
btVal := int8(bt)
@@ -45,6 +45,7 @@ func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend(
startTime, endTime time.Time,
granularity string,
limit int,
metric string,
) ([]usagestats.UserUsageTrendPoint, error) {
r.usersTrendCalls.Add(1)
return []usagestats.UserUsageTrendPoint{{
@@ -115,4 +116,20 @@ func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) {
require.Equal(t, http.StatusOK, rec2.Code)
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
require.Equal(t, int32(1), repo.usersTrendCalls.Load())
for _, tc := range []struct {
metric, cache string
calls int32
}{
{"actual_cost", "miss", 2},
{"actual_cost", "hit", 2},
{"tokens", "hit", 2},
} {
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8&metric="+tc.metric, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, tc.cache, rec.Header().Get("X-Snapshot-Cache"))
require.Equal(t, tc.calls, repo.usersTrendCalls.Load())
}
}
@@ -15,16 +15,19 @@ import (
type dashboardUsageRepoCapture struct {
service.UsageLogRepository
trendRequestType *int16
trendStream *bool
modelRequestType *int16
modelStream *bool
trendMismatch *bool
modelMismatch *bool
groupMismatch *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
trendRequestType *int16
trendStream *bool
trendNativeCompaction *bool
modelRequestType *int16
modelStream *bool
modelNativeCompaction *bool
groupNativeCompaction *bool
trendMismatch *bool
modelMismatch *bool
groupMismatch *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
@@ -35,6 +38,7 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
) ([]usagestats.TrendDataPoint, error) {
s.trendRequestType = filters.RequestType
s.trendStream = filters.Stream
s.trendNativeCompaction = filters.NativeCompactionV2
s.trendMismatch = filters.UpstreamModelMismatch
return []usagestats.TrendDataPoint{}, nil
}
@@ -62,6 +66,7 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
) ([]usagestats.ModelStat, error) {
s.modelRequestType = filters.RequestType
s.modelStream = filters.Stream
s.modelNativeCompaction = filters.NativeCompactionV2
s.modelMismatch = filters.UpstreamModelMismatch
return []usagestats.ModelStat{}, nil
}
@@ -71,6 +76,7 @@ func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
) ([]usagestats.GroupStat, error) {
s.groupNativeCompaction = filters.NativeCompactionV2
s.groupMismatch = filters.UpstreamModelMismatch
return []usagestats.GroupStat{}, nil
}
@@ -208,6 +214,48 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
}
func TestDashboardNativeCompactionFilterPropagatesAlongsideTransport(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?request_type=stream&native_compaction_v2=true",
"/admin/dashboard/models?request_type=stream&native_compaction_v2=true",
"/admin/dashboard/groups?request_type=stream&native_compaction_v2=true",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, path)
}
require.NotNil(t, repo.trendNativeCompaction)
require.True(t, *repo.trendNativeCompaction)
require.NotNil(t, repo.modelNativeCompaction)
require.True(t, *repo.modelNativeCompaction)
require.NotNil(t, repo.groupNativeCompaction)
require.True(t, *repo.groupNativeCompaction)
require.NotNil(t, repo.trendRequestType)
require.Equal(t, int16(service.RequestTypeStream), *repo.trendRequestType)
}
func TestDashboardNativeCompactionFilterRejectsInvalidBoolean(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?native_compaction_v2=invalid",
"/admin/dashboard/models?native_compaction_v2=invalid",
"/admin/dashboard/groups?native_compaction_v2=invalid",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, path)
}
}
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
@@ -28,6 +28,7 @@ type dashboardTrendCacheKey struct {
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
@@ -42,6 +43,7 @@ type dashboardModelGroupCacheKey struct {
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
@@ -51,6 +53,7 @@ type dashboardEntityTrendCacheKey struct {
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
Limit int `json:"limit"`
Metric string `json:"metric,omitempty"`
}
func cacheStatusValue(hit bool) string {
@@ -85,6 +88,7 @@ func (h *DashboardHandler) getUsageTrendCached(
model string,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.TrendDataPoint, bool, error) {
@@ -99,13 +103,14 @@ func (h *DashboardHandler) getUsageTrendCached(
Model: model,
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
Model: model, RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
@@ -123,6 +128,7 @@ func (h *DashboardHandler) getModelStatsCached(
modelSource string,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.ModelStat, bool, error) {
@@ -136,13 +142,14 @@ func (h *DashboardHandler) getModelStatsCached(
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
}, modelSource)
})
@@ -159,6 +166,7 @@ func (h *DashboardHandler) getGroupStatsCached(
userID, apiKeyID, accountID, groupID int64,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.GroupStat, bool, error) {
@@ -171,13 +179,14 @@ func (h *DashboardHandler) getGroupStatsCached(
GroupID: groupID,
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
@@ -205,15 +214,16 @@ func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startT
return trend, hit, err
}
func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) {
func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
Limit: limit,
Metric: metric,
})
entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric)
})
if err != nil {
return nil, hit, err
@@ -44,6 +44,7 @@ type dashboardSnapshotV2Filters struct {
Model string
RequestType *int16
Stream *bool
NativeCompactionV2 *bool
BillingType *int8
UpstreamModelMismatch *bool
}
@@ -59,6 +60,7 @@ type dashboardSnapshotV2CacheKey struct {
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
IncludeStats bool `json:"include_stats"`
@@ -105,6 +107,7 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
NativeCompactionV2: filters.NativeCompactionV2,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
IncludeStats: includeStats,
@@ -186,6 +189,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.Model,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
@@ -207,6 +211,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
usagestats.ModelSourceRequested,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
@@ -227,6 +232,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.GroupID,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
@@ -237,7 +243,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
}
if includeUsersTrend {
usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit)
usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit, "tokens")
if err != nil {
return nil, errors.New("failed to get user usage trend")
}
@@ -296,6 +302,14 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
filters.Stream = &streamVal
}
if nativeCompactionV2Str := strings.TrimSpace(c.Query("native_compaction_v2")); nativeCompactionV2Str != "" {
value, err := strconv.ParseBool(nativeCompactionV2Str)
if err != nil {
return nil, err
}
filters.NativeCompactionV2 = &value
}
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
v, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
// ProvideAccountHandler injects the Grok active prober for production while
// keeping NewAccountHandler convenient for focused unit tests.
func ProvideAccountHandler(
cfg *config.Config,
adminService service.AdminService,
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
tokenCacheInvalidator,
)
handler.grokImportProber = grokQuotaService
handler.cfg = cfg
return handler
}

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