Compare commits

..
Author SHA1 Message Date
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
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
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
aofee d1ba57977d feat: add native TypeSafe Jev System One support 2026-09-20 20:20:16 +08:00
176 changed files with 6801 additions and 475 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"
+31 -4
View File
@@ -354,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
@@ -404,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
@@ -827,6 +829,31 @@ Administrators can override automatic media eligibility through the account crea
---
## 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.
+31 -4
View File
@@ -380,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
@@ -442,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"
```
#### 升级
@@ -743,6 +745,31 @@ OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_gener
---
## 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 模型。
+6 -4
View File
@@ -355,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
@@ -405,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"
```
#### アップグレード
+1 -1
View File
@@ -1 +1 @@
0.2.10
0.2.13
+10 -9
View File
@@ -1128,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},
@@ -1170,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,
},
@@ -1179,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 <> ''",
},
@@ -1187,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]},
},
},
}
+88 -1
View File
@@ -30113,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
@@ -30580,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
@@ -32123,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)
}
@@ -32145,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)
}
@@ -32263,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:
@@ -32350,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:
@@ -32472,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 {
@@ -32713,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)
}
@@ -32739,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:
@@ -32777,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 {
@@ -32976,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
+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)
}
+19 -15
View File
@@ -1323,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.
+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),
+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", "minimax", "opencode_go":
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+1
View File
@@ -28,6 +28,7 @@ const (
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"
@@ -27,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"
@@ -2950,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() {
@@ -526,3 +526,36 @@ func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarnin
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)
}
}
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
service.PlatformTypeSafe: "typesafe",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -565,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", "minimax"} {
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)
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -28,6 +28,7 @@ func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
"typesafe",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -71,8 +72,8 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
func TestCompositeRouteTargetPlatform_AllowsConcreteProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
@@ -125,6 +125,7 @@ type AdminPaymentOrderResult struct {
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
BonusAmount float64 `json:"bonus_amount"`
Currency string `json:"currency"`
RechargeCode string `json:"recharge_code,omitempty"`
OutTradeNo string `json:"out_trade_no"`
@@ -182,6 +183,7 @@ func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPayme
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
BonusAmount: order.BonusAmount,
Currency: service.PaymentOrderCurrency(order),
RechargeCode: order.RechargeCode,
OutTradeNo: order.OutTradeNo,
@@ -362,6 +362,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(paymentCfg.RechargeBonusTiers),
PaymentRechargeBonusMode: rechargeBonusModeToDTO(paymentCfg.RechargeBonusMode),
PaymentRechargeBonusNotice: paymentCfg.RechargeBonusNotice,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
@@ -0,0 +1,33 @@
package admin
import (
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// rechargeBonusTiersFromDTO 请求 nil 表示未携带该字段(保持现值);空数组表示清空阶梯。
func rechargeBonusTiersFromDTO(items *[]dto.RechargeBonusTier) *[]service.RechargeBonusTier {
if items == nil {
return nil
}
out := make([]service.RechargeBonusTier, 0, len(*items))
for _, item := range *items {
out = append(out, service.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent})
}
return &out
}
// rechargeBonusModeToDTO 输出已归一化的模式(空/非法按 bonus)。
func rechargeBonusModeToDTO(mode string) string {
normalized, _ := service.NormalizeRechargeBonusMode(mode)
return normalized
}
// rechargeBonusTiersToDTO 始终返回非 nil 切片,空配置输出 []。
func rechargeBonusTiersToDTO(items []service.RechargeBonusTier) []dto.RechargeBonusTier {
out := make([]dto.RechargeBonusTier, 0, len(items))
for _, item := range items {
out = append(out, dto.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent})
}
return out
}
@@ -312,11 +312,15 @@ type UpdateSettingsRequest struct {
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
PaymentHelpImageURL *string `json:"payment_help_image_url"`
PaymentHelpText *string `json:"payment_help_text"`
// nil 表示不更新;空数组表示清空阶梯
PaymentRechargeBonusTiers *[]dto.RechargeBonusTier `json:"payment_recharge_bonus_tiers"`
PaymentRechargeBonusMode *string `json:"payment_recharge_bonus_mode"`
PaymentRechargeBonusNotice *string `json:"payment_recharge_bonus_notice"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
PaymentHelpImageURL *string `json:"payment_help_image_url"`
PaymentHelpText *string `json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"`
@@ -2128,6 +2132,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
RechargeBonusTiers: rechargeBonusTiersFromDTO(req.PaymentRechargeBonusTiers),
RechargeBonusMode: req.PaymentRechargeBonusMode,
RechargeBonusNotice: req.PaymentRechargeBonusNotice,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
ProductNameSuffix: req.PaymentProductNameSuffix,
@@ -2407,6 +2414,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(updatedPaymentCfg.RechargeBonusTiers),
PaymentRechargeBonusMode: rechargeBonusModeToDTO(updatedPaymentCfg.RechargeBonusMode),
PaymentRechargeBonusNotice: updatedPaymentCfg.RechargeBonusNotice,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
@@ -2483,6 +2493,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
req.PaymentRechargeFeeRate != nil ||
req.PaymentRechargeBonusTiers != nil || req.PaymentRechargeBonusMode != nil || req.PaymentRechargeBonusNotice != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
@@ -3593,3 +3593,20 @@ func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, erro
func (oauthPendingFlowTotpEncryptorStub) Decrypt(ciphertext string) (string, error) {
return ciphertext, nil
}
func (s *oauthPendingFlowEmailCacheStub) IncrVerificationCodeAttempts(_ context.Context, email string) (int, error) {
data := s.verificationCodes[email]
if data == nil {
return 0, errors.New("verification code not found")
}
data.Attempts++
return data.Attempts, nil
}
func (s *oauthPendingFlowEmailCacheStub) IncrNotifyVerifyCodeAttempts(context.Context, string) (int, error) {
return 0, errors.New("notify verification code not found")
}
func (s *oauthPendingFlowEmailCacheStub) ConsumePasswordResetToken(context.Context, string, string) (bool, error) {
return false, nil
}
@@ -0,0 +1,7 @@
package dto
// RechargeBonusTier 充值赠送档位:余额充值支付金额 ≥ MinAmount 时,在到账基数上赠送 BonusPercent%。
type RechargeBonusTier struct {
MinAmount float64 `json:"min_amount"`
BonusPercent float64 `json:"bonus_percent"`
}
+9 -5
View File
@@ -279,11 +279,15 @@ type SystemSettings struct {
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
PaymentHelpImageURL string `json:"payment_help_image_url"`
PaymentHelpText string `json:"payment_help_text"`
// 充值赠送阶梯与活动文案
PaymentRechargeBonusTiers []RechargeBonusTier `json:"payment_recharge_bonus_tiers"`
PaymentRechargeBonusMode string `json:"payment_recharge_bonus_mode"`
PaymentRechargeBonusNotice string `json:"payment_recharge_bonus_notice"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
PaymentHelpImageURL string `json:"payment_help_image_url"`
PaymentHelpText string `json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"`
+6
View File
@@ -16,6 +16,7 @@ import (
const (
EndpointMessages = "/v1/messages"
EndpointSystemOne = "/v1/systemone"
EndpointChatCompletions = "/v1/chat/completions"
EndpointEmbeddings = "/v1/embeddings"
EndpointAlphaSearch = "/v1/alpha/search"
@@ -94,6 +95,8 @@ func NormalizeInboundEndpoint(path string) string {
return EndpointChatCompletions
case strings.Contains(path, EndpointMessages):
return EndpointMessages
case strings.Contains(path, EndpointSystemOne):
return EndpointSystemOne
case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"):
return EndpointImagesGenerations
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
@@ -223,6 +226,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
case service.PlatformAnthropic:
return EndpointMessages
case service.PlatformTypeSafe:
return EndpointSystemOne
case service.PlatformGemini:
return EndpointGeminiModels
@@ -23,6 +23,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
}{
// Direct canonical paths.
{"/v1/messages", EndpointMessages},
{"/v1/systemone", EndpointSystemOne},
{"/v1/chat/completions", EndpointChatCompletions},
{"/v1/embeddings", EndpointEmbeddings},
{"/v1/alpha/search", EndpointAlphaSearch},
@@ -97,6 +98,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
}{
// Anthropic.
{"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages},
{"typesafe system one", EndpointSystemOne, "/v1/systemone", service.PlatformTypeSafe, EndpointSystemOne},
// Gemini.
{"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
+26 -4
View File
@@ -24,6 +24,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"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/securityaudit"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -222,6 +223,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
return
}
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage {
h.anthropicSecurityAuditError(c, decision)
@@ -1162,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
@@ -1206,6 +1210,10 @@ func (h *GatewayHandler) Models(c *gin.Context) {
writeGrokModelsList(c, xai.DefaultModelIDs())
return
}
if platform == service.PlatformTypeSafe {
writeModelsList(c, platform, []string{typesafe.JevLatestModel})
return
}
writeModelsListResponse(c, claude.DefaultModels)
}
@@ -1257,7 +1265,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
platform = group.Platform
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
availableModels := h.compositeAvailableModels(ctx, groupID, false)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.ModelAllowlistEnabled() {
source := availableModels
@@ -1283,14 +1291,20 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
return fallbackModels
}
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
// compositeAvailableModels lists the models the composite group can serve.
// includeSystemOne adds TypeSafe models, which only work through /v1/systemone;
// LLM client catalogs (Codex) must exclude them.
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string {
if h == nil || h.gatewayService == nil {
return nil
}
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} {
if platform == service.PlatformTypeSafe && !includeSystemOne {
continue
}
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
@@ -1474,9 +1488,14 @@ func defaultModelIDsForPlatform(platform string) []string {
return xai.DefaultModelIDs()
case service.PlatformOpenCodeGo:
return service.DefaultOpenCodeGoModelIDs()
case service.PlatformTypeSafe:
return []string{"jev-latest"}
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
// TypeSafe is deliberately absent: jev-latest only works through
// /v1/systemone, so the static fallback never advertises it to LLM
// clients. compositeAvailableModels lists it when the group can serve it.
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
@@ -2162,6 +2181,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
return
}
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
@@ -82,6 +82,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.chatCompletionsErrorResponse) {
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -82,6 +82,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.responsesErrorResponse) {
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -27,7 +27,7 @@ func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag
func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} {
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformTypeSafe, service.PlatformComposite} {
for _, mapped := range []bool{false, true} {
name := platform + "/fallback"
if mapped {
@@ -932,6 +932,10 @@ func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
func TestDefaultModelIDsForPlatform_TypeSafeUsesJev(t *testing.T) {
require.Equal(t, []string{"jev-latest"}, defaultModelIDsForPlatform(service.PlatformTypeSafe))
}
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 130
@@ -1522,3 +1526,47 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
})
}
}
// Scenario: jev-latest only works through /v1/systemone, so Composite groups list
// it in /v1/models only when they can serve it, and never in the Codex manifest.
func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(66)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}},
},
})
newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
return c, rec
}
c, rec := newContext("/v1/models")
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var models gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models))
require.Contains(t, modelIDsForTest(models.Data), "jev-latest")
require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6")
c, rec = newContext("/models?client_version=0.147.0")
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var manifest codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest))
slugs := codexModelSlugsForTest(manifest.Models)
require.Contains(t, slugs, "claude-opus-4-6")
require.NotContains(t, slugs, "jev-latest")
}
func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) {
require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest")
require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest")
}
@@ -0,0 +1,287 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const systemOneOnlyPlatformMessage = "TypeSafe models are only available through POST /v1/systemone"
// rejectSystemOneOnlyPlatform stops TypeSafe traffic from entering a non System
// One protocol chain. TypeSafe accounts only speak the native System One
// protocol; letting them reach the Anthropic/OpenAI converters would send
// foreign payloads (and the account key) to the wrong upstream path and feed
// the resulting auth failures back into account state.
func rejectSystemOneOnlyPlatform(c *gin.Context, apiKey *service.APIKey, writeError func(*gin.Context, int, string, string)) bool {
if _, forced := middleware2.GetForcePlatformFromContext(c); forced {
return false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformTypeSafe {
return false
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
writeError(c, http.StatusNotFound, "not_found_error", systemOneOnlyPlatformMessage)
return true
}
// SystemOne proxies TypeSafe's native, non-streaming System One protocol.
func (h *GatewayHandler) SystemOne(c *gin.Context) {
requestStart := time.Now()
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil || apiKey.Group == nil {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
reqLog := requestLogger(c, "handler.gateway.systemone",
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
)
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
return
}
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
model, err := typesafe.ValidateSystemOneRequest(body)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
ensureCompositeTargetPlatform(c, apiKey, model)
if apiKey.Group.Platform != service.PlatformTypeSafe &&
(apiKey.Group.Platform != service.PlatformComposite || !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformTypeSafe)) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "System One is only available for TypeSafe and compatible Composite groups")
return
}
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolTypeSafeSystemOne, model, body); decision != nil && !decision.AllowNextStage {
h.anthropicSecurityAuditError(c, decision)
return
}
setOpsRequestContext(c, model, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
c.Request = c.Request.WithContext(pricingCtx)
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, model)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
streamStarted := false
userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, false, &streamStarted)
if err != nil {
reqLog.Warn("systemone.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", false)
return
}
// 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏。
userRelease = wrapReleaseOnDone(c.Request.Context(), userRelease)
if userRelease != nil {
defer userRelease()
}
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("systemone.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(model, body))
if err != nil {
reqLog.Info("systemone.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightRelease()
fs := NewFailoverState(h.maxAccountSwitches, false)
for {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, fs.FailedAccountIDs, "", subject.UserID)
if err == nil && (selection == nil || selection.Account == nil) {
err = service.ErrNoAvailableAccounts
}
if err != nil {
if failoverClientGone(c) {
reqLog.Info("systemone.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe)
cls = classifySelectionFailureError(err, cls)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
reqLog.Warn("systemone.select_account_no_available", zap.Bool("model_not_found", cls.ModelNotFound), zap.Error(err))
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
switch fs.HandleSelectionExhausted(c.Request.Context()) {
case FailoverContinue:
continue
case FailoverCanceled:
failoverClientGone(c)
return
default:
if fs.LastFailoverErr != nil {
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
} else {
h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, false)
}
return
}
}
account := selection.Account
accountRelease := selection.ReleaseFunc
if !selection.Acquired {
if selection.WaitPlan == nil {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
accountRelease, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted)
if err != nil {
reqLog.Warn("systemone.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
h.handleConcurrencyError(c, err, "account", false)
return
}
}
// 准入终检:与其他网关入口一致,利润控制否决的账号不得承接本次请求。
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountRelease != nil {
accountRelease()
}
reqLog.Debug("systemone.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("systemone.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
return
}
continue
}
account = latest
accountRelease = wrapReleaseOnDone(c.Request.Context(), accountRelease)
setOpsSelectedAccount(c, account.ID, account.Platform)
service.SetOpsUpstreamModel(c, model)
forwardStart := time.Now()
result, forwardErr := h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body)
if accountRelease != nil {
accountRelease()
}
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
if forwardErr != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(forwardErr, &failoverErr) {
switch fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) {
case FailoverContinue:
reqLog.Warn("systemone.upstream_failover_switching",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("switch_count", fs.SwitchCount),
)
continue
case FailoverExhausted:
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
return
case FailoverCanceled:
failoverClientGone(c)
return
}
}
if failoverClientGone(c) {
return
}
var upstreamErr *service.SystemOneUpstreamError
if errors.As(forwardErr, &upstreamErr) {
status := upstreamErr.StatusCode
if !service.IsSystemOneRequestErrorStatus(status) {
status = http.StatusBadGateway
}
h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request")
return
}
reqLog.Warn("systemone.forward_failed", zap.Int64("account_id", account.ID), zap.Error(forwardErr))
if errors.Is(forwardErr, typesafe.ErrSystemOneResponseTooLarge) {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe response exceeds the gateway size limit")
return
}
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe upstream request failed")
return
}
c.Data(result.StatusCode, result.ContentType, result.Body)
h.recordSystemOneUsage(c, apiKey, account, subscription, channelMapping, model, body, result, subject.UserID, pricingAt)
return
}
}
func (h *GatewayHandler) recordSystemOneUsage(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, mapping service.ChannelMappingResult, model string, body []byte, result *service.SystemOneForwardResult, userID int64, pricingAt time.Time) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: &result.ForwardResult,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: clientRequestedUsageFields(c, mapping, model, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gateway.systemone"),
zap.Int64("user_id", userID),
zap.Int64("api_key_id", apiKey.ID),
zap.Int64("account_id", account.ID),
).Error("systemone.record_usage_failed", zap.Error(err))
}
})
}
@@ -0,0 +1,109 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const validSystemOneHandlerBody = `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":"Evaluate"}}}`
func newSystemOneHandlerContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
return c, recorder
}
func TestSystemOneRequiresAuthentication(t *testing.T) {
c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody)
(&GatewayHandler{}).SystemOne(c)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.Contains(t, recorder.Body.String(), "authentication_error")
}
func TestSystemOneRejectsNonTypeSafeGroupBeforeScheduling(t *testing.T) {
c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody)
groupID := int64(3)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1})
(&GatewayHandler{cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}}).SystemOne(c)
require.Equal(t, http.StatusNotFound, recorder.Code)
require.Contains(t, recorder.Body.String(), "only available for TypeSafe")
}
func newTypeSafeGroupContext(t *testing.T, path, body, groupPlatform string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
c, recorder := newSystemOneHandlerContext(body)
c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
groupID := int64(9)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: groupPlatform}})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1})
return c, recorder
}
func TestRejectSystemOneOnlyPlatform(t *testing.T) {
write := func(c *gin.Context, status int, errType, message string) {
c.JSON(status, gin.H{"type": errType, "message": message})
}
c, recorder := newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformTypeSafe)
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
require.Equal(t, http.StatusNotFound, recorder.Code)
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformComposite)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformTypeSafe))
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformAnthropic)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c, _ = newTypeSafeGroupContext(t, "/antigravity/v1/messages", `{}`, service.PlatformTypeSafe)
c.Set(string(middleware2.ContextKeyForcePlatform), service.PlatformAntigravity)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
}
func mustAPIKey(t *testing.T, c *gin.Context) *service.APIKey {
t.Helper()
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
return apiKey
}
func TestTypeSafeGroupsRejectNonSystemOneProtocolsBeforeScheduling(t *testing.T) {
h := &GatewayHandler{
cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
gatewayService: &service.GatewayService{},
}
for _, tc := range []struct {
name string
path string
body string
handler func(*gin.Context)
}{
{"messages", "/v1/messages", `{"model":"jev-latest","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, h.Messages},
{"count_tokens", "/v1/messages/count_tokens", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.CountTokens},
{"chat_completions", "/v1/chat/completions", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.ChatCompletions},
{"responses", "/v1/responses", `{"model":"jev-latest","input":"hi"}`, h.Responses},
} {
t.Run(tc.name, func(t *testing.T) {
c, recorder := newTypeSafeGroupContext(t, tc.path, tc.body, service.PlatformTypeSafe)
tc.handler(c)
require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
})
}
}
@@ -149,6 +149,9 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
RechargeFeeRate: cfg.RechargeFeeRate,
RechargeBonusTiers: cfg.RechargeBonusTiers,
RechargeBonusMode: cfg.RechargeBonusMode,
RechargeBonusNotice: cfg.RechargeBonusNotice,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
StripePublishableKey: cfg.StripePublishableKey,
@@ -166,6 +169,9 @@ type checkoutInfoResponse struct {
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
RechargeBonusTiers []service.RechargeBonusTier `json:"recharge_bonus_tiers"`
RechargeBonusMode string `json:"recharge_bonus_mode"`
RechargeBonusNotice string `json:"recharge_bonus_notice"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
StripePublishableKey string `json:"stripe_publishable_key"`
@@ -482,6 +488,7 @@ type PublicOrderResult struct {
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
BonusAmount float64 `json:"bonus_amount"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
@@ -517,6 +524,7 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
BonusAmount: order.BonusAmount,
Currency: service.PaymentOrderCurrency(order),
PaymentType: order.PaymentType,
OrderType: order.OrderType,
@@ -625,6 +633,7 @@ type PaymentOrderResult struct {
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
BonusAmount float64 `json:"bonus_amount"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OutTradeNo string `json:"out_trade_no"`
@@ -663,6 +672,7 @@ func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderRes
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
BonusAmount: order.BonusAmount,
Currency: service.PaymentOrderCurrency(order),
PaymentType: order.PaymentType,
OutTradeNo: order.OutTradeNo,
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -811,3 +812,19 @@ func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
}
func (s *userHandlerEmailCacheStub) IncrVerificationCodeAttempts(context.Context, string) (int, error) {
if s.data == nil {
return 0, errors.New("verification code not found")
}
s.data.Attempts++
return s.data.Attempts, nil
}
func (s *userHandlerEmailCacheStub) IncrNotifyVerifyCodeAttempts(context.Context, string) (int, error) {
return 0, errors.New("notify verification code not found")
}
func (s *userHandlerEmailCacheStub) ConsumePasswordResetToken(context.Context, string, string) (bool, error) {
return false, nil
}
@@ -264,10 +264,11 @@ func loginTestUser(t *testing.T) string {
t.Helper()
// 先尝试用管理员账户登录
adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local")
// 管理员邮箱不再有固定默认值(未设置时安装阶段随机生成),需同时提供邮箱与密码。
adminEmail := getEnv("ADMIN_EMAIL", "")
adminPassword := getEnv("ADMIN_PASSWORD", "")
if adminPassword == "" {
if adminEmail == "" || adminPassword == "" {
// 尝试用测试用户
adminEmail = testUserEmail
adminPassword = testUserPassword
@@ -46,6 +46,7 @@ const (
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
PlatformTypeSafe = domain.PlatformTypeSafe
)
// AllPlatforms 返回所有支持的平台列表
@@ -61,6 +62,7 @@ func AllPlatforms() []string {
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
PlatformTypeSafe,
}
}
@@ -18,5 +18,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"deepseek",
"minimax",
"opencode_go",
"typesafe",
}, AllPlatforms())
}
@@ -376,6 +376,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
// url.ParseQuery already decodes values — no additional decode needed.
params := make(map[string]string)
for k := range values {
if !easyPayNotifyAllowedParams[k] {
return nil, fmt.Errorf("unexpected notify param: %s", k)
}
params[k] = values.Get(k)
}
sign := params["sign"]
@@ -601,3 +604,26 @@ func easyPaySign(params map[string]string, pkey string) string {
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
}
// easyPayNotifyAllowedParams is the exact parameter set a genuine EasyPay
// (彩虹易支付-compatible) async notification carries. Order-creation-only
// fields (notify_url, return_url, cid, device, clientip, ...) must never
// appear in a callback: because the sign base string concatenates values
// unescaped, a signed order URL whose return_url embeds e.g.
// "trade_status=TRADE_SUCCESS" could otherwise be replayed as a forged
// payment-success notification (issue #7881). Rejecting unknown keys closes
// the whole smuggling class; genuinely paid orders rejected by an exotic
// upstream variant are still recovered by the upstream QueryOrder reconcile
// path.
var easyPayNotifyAllowedParams = map[string]bool{
"pid": true,
"trade_no": true,
"out_trade_no": true,
"type": true,
"name": true,
"money": true,
"trade_status": true,
"param": true,
"sign": true,
"sign_type": true,
}
@@ -0,0 +1,172 @@
package provider
// Regression tests for the EasyPay forged-callback vulnerability
// (Wei-Shaw/sub2api issue #7875).
//
// Attack recap: the order-creation signature (visible to the payer in the
// submit.php popup URL) signs return_url among other fields, and the sign
// base string concatenates values unescaped. A client-supplied return_url
// ending in "&trade_status=TRADE_SUCCESS" therefore produced a signature
// that was byte-identical to a payment-success notification's signature
// once the callback decoded return_url partially and promoted the smuggled
// pair to a top-level parameter. Merchant-key rotation could not help
// because the attacker never needed the key.
//
// Fixes covered here:
// 1. service.CanonicalizeReturnURL drops client-supplied query parameters
// (see payment_resume_service_test.go).
// 2. VerifyNotification rejects any parameter outside the genuine async
// notify set, so smuggled order-creation fields (return_url et al.)
// fail closed even if a signed value smuggles a fake pair.
import (
"context"
"net/url"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// easyPayPoCProvider returns a provider with a fixed test credential set.
func easyPayPoCProvider() *EasyPay {
return &EasyPay{config: map[string]string{
"pid": "1000",
"pkey": "MERCHANT_SECRET_KEY",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay",
"returnUrl": "https://site.example.com/payment/result",
}}
}
// TestEasyPayNotifyRejectsForgedSignReuseCallback is the exact PoC payload
// that was accepted before the fix: the order's own submit.php signature
// replayed with trade_status smuggled out of the return_url value.
func TestEasyPayNotifyRejectsForgedSignReuseCallback(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
// Order creation (popup mode): return_url carries the smuggled pair as
// its last sorted inner query key, exactly as buildPaymentReturnURL
// would have produced before CanonicalizeReturnURL stripped user query.
returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS"
createParams := map[string]string{
"pid": "1000",
"type": "alipay",
"out_trade_no": "ORDER123",
"notify_url": e.config["notifyUrl"],
"return_url": returnURL,
"name": "balance recharge",
"money": "650.00",
}
sign := easyPaySign(createParams, e.config["pkey"])
// Forged callback: return_url encoded only up to status=success, then a
// raw &trade_status=TRADE_SUCCESS promotes it to a top-level parameter.
prefix := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success"
cb := url.Values{}
cb.Set("pid", "1000")
cb.Set("type", "alipay")
cb.Set("out_trade_no", "ORDER123")
cb.Set("notify_url", e.config["notifyUrl"])
cb.Set("name", "balance recharge")
cb.Set("money", "650.00")
cb.Set("return_url", prefix)
rawCallback := cb.Encode() + "&trade_status=TRADE_SUCCESS" + "&sign=" + sign + "&sign_type=MD5"
if _, err := e.VerifyNotification(context.Background(), rawCallback, nil); err == nil {
t.Fatal("forged sign-reuse callback must be rejected")
}
}
// TestEasyPayNotifyRejectsOrderURLReplay covers the milder variant where the
// attacker replays the complete signed pay URL unchanged: return_url itself
// is not a legitimate notify parameter and must also be rejected.
func TestEasyPayNotifyRejectsOrderURLReplay(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
createParams := map[string]string{
"pid": "1000",
"type": "alipay",
"out_trade_no": "ORDER123",
"notify_url": e.config["notifyUrl"],
"return_url": "https://site.example.com/payment/result",
"name": "balance recharge",
"money": "650.00",
}
createParams["sign"] = easyPaySign(createParams, e.config["pkey"])
createParams["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range createParams {
q.Set(k, v)
}
if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
t.Fatal("replayed order URL must be rejected")
}
}
// TestEasyPayNotifyAcceptsGenuineCallback locks in the legitimate notify
// contract: the canonical parameter set with a valid signature succeeds.
func TestEasyPayNotifyAcceptsGenuineCallback(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
params := map[string]string{
"pid": "1000",
"trade_no": "2026100622001400000001",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "balance recharge",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
sign := easyPaySign(params, e.config["pkey"])
params["sign"] = sign
params["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
n, err := e.VerifyNotification(context.Background(), q.Encode(), nil)
if err != nil {
t.Fatalf("genuine callback rejected: %v", err)
}
if n.Status != payment.ProviderStatusSuccess {
t.Fatalf("status = %v, want success", n.Status)
}
if n.OrderID != "ORDER123" || n.TradeNo != "2026100622001400000001" || n.Amount != 650.00 {
t.Fatalf("unexpected notification: %+v", n)
}
}
// TestEasyPayNotifyRejectsUnknownParam ensures any parameter outside the
// canonical notify set fails closed, including empty-valued ones that the
// signer itself would skip.
func TestEasyPayNotifyRejectsUnknownParam(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
params := map[string]string{
"pid": "1000",
"trade_no": "T1",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "balance recharge",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
sign := easyPaySign(params, e.config["pkey"])
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
q.Set("sign", sign)
q.Set("sign_type", signTypeMD5)
q.Set("device", "") // empty value: invisible to the signer, still rejected
if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
t.Fatal("callback with unknown param must be rejected")
}
}
+95 -8
View File
@@ -13,6 +13,12 @@ import (
"strings"
)
const (
DefaultBaseURL = "https://api.typesafe.ai"
SystemOnePath = "/v1/systemone"
JevLatestModel = "jev-latest"
)
type Question struct {
Type string `json:"type"`
Instructions string `json:"instructions"`
@@ -35,22 +41,103 @@ type Usage struct {
OutputTokens int `json:"output_tokens"`
}
type SystemOneResponse struct {
Body []byte
Model string
Usage Usage
}
func NewSystemOneRequest(ctx context.Context, baseURL, key string, body []byte) (*http.Request, error) {
endpoint, err := url.JoinPath(strings.TrimRight(baseURL, "/"), SystemOnePath)
if err != nil {
return nil, errors.New("typesafe invalid endpoint")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, errors.New("typesafe invalid request")
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// MaxSystemOneResponseBytes bounds a buffered System One response body.
const MaxSystemOneResponseBytes = 4 << 20
var ErrSystemOneResponseTooLarge = errors.New("typesafe response exceeds size limit")
func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) {
body, err := io.ReadAll(io.LimitReader(r, MaxSystemOneResponseBytes+1))
if err != nil {
return nil, errors.New("typesafe invalid response")
}
// A truncated body would otherwise surface as a misleading "invalid JSON".
if len(body) > MaxSystemOneResponseBytes {
return nil, ErrSystemOneResponseTooLarge
}
if !json.Valid(body) {
return nil, errors.New("typesafe invalid response")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
return nil, errors.New("typesafe invalid response")
}
// The upstream already answered (and charged); an unexpected model or usage
// shape must not discard the answer, so both are decoded leniently.
var model string
_ = json.Unmarshal(envelope["model"], &model)
var usage map[string]json.RawMessage
_ = json.Unmarshal(envelope["usage"], &usage)
return &SystemOneResponse{
Body: body,
Model: model,
Usage: Usage{
InputTokens: systemOneTokenCount(usage["input_tokens"]),
OutputTokens: systemOneTokenCount(usage["output_tokens"]),
},
}, nil
}
// maxSystemOneTokenCount bounds a reported token count before int conversion.
const maxSystemOneTokenCount = 1 << 40
// systemOneTokenCount accepts integer, float, or numeric-string token counts.
func systemOneTokenCount(raw json.RawMessage) int {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return 0
}
if raw[0] == '"' {
var text string
if json.Unmarshal(raw, &text) != nil {
return 0
}
raw = json.RawMessage(strings.TrimSpace(text))
}
var number json.Number
if json.Unmarshal(raw, &number) != nil {
return 0
}
value, err := number.Float64()
if err != nil || math.IsNaN(value) || value <= 0 {
return 0
}
if value > maxSystemOneTokenCount {
value = maxSystemOneTokenCount
}
return int(math.Round(value))
}
// Evaluate performs one attempt. The caller owns timeouts, retries and key rotation.
func Evaluate(ctx context.Context, client *http.Client, baseURL, key string, input Request) (*Result, int, error) {
endpoint, err := url.JoinPath(strings.TrimRight(baseURL, "/"), "/v1/systemone")
if err != nil {
return nil, 0, errors.New("typesafe invalid endpoint")
}
body, err := json.Marshal(input)
if err != nil {
return nil, 0, errors.New("typesafe invalid request")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
req, err := NewSystemOneRequest(ctx, baseURL, key, body)
if err != nil {
return nil, 0, errors.New("typesafe invalid request")
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
+202
View File
@@ -0,0 +1,202 @@
package typesafe
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
)
var ErrStreamingUnsupported = errors.New("typesafe system one does not support streaming")
type systemOneEnvelope struct {
Model json.RawMessage `json:"model"`
State json.RawMessage `json:"state"`
Questions json.RawMessage `json:"questions"`
Stream json.RawMessage `json:"stream"`
}
var (
systemOneRequestFields = []string{"model", "state", "questions", "stream"}
systemOneQuestionFields = []string{"type", "instructions", "criteria"}
)
func ValidateSystemOneRequest(body []byte) (string, error) {
var envelope systemOneEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
return "", errors.New("invalid JSON request")
}
// encoding/json matches struct fields case-insensitively and keeps the last
// duplicate, while the raw body is forwarded upstream unchanged. Reject any
// spelling the upstream could read differently from this validator.
if err := checkSystemOneObjectKeys(body, "request", systemOneRequestFields); err != nil {
return "", err
}
model, err := requiredString(envelope.Model, "model")
if err != nil {
return "", err
}
if model != JevLatestModel {
return "", fmt.Errorf("model must be %s", JevLatestModel)
}
if err := validateStringObjectOrArray(envelope.State, "state"); err != nil {
return "", err
}
questionsRaw := bytes.TrimSpace(envelope.Questions)
var questions map[string]json.RawMessage
if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 {
return "", errors.New("questions must be a non-empty object")
}
if err := checkSystemOneObjectKeys(questionsRaw, "questions", nil); err != nil {
return "", err
}
if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" {
var stream bool
if err := json.Unmarshal(envelope.Stream, &stream); err != nil {
return "", errors.New("stream must be a boolean")
}
if stream {
return "", ErrStreamingUnsupported
}
}
for id, raw := range questions {
if err := validateQuestion(id, raw); err != nil {
return "", err
}
}
return model, nil
}
func validateQuestion(id string, raw json.RawMessage) error {
raw = bytes.TrimSpace(raw)
var question struct {
Type json.RawMessage `json:"type"`
Instructions json.RawMessage `json:"instructions"`
Criteria json.RawMessage `json:"criteria"`
}
if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &question) != nil {
return fmt.Errorf("question %q must be an object", id)
}
if err := checkSystemOneObjectKeys(raw, fmt.Sprintf("question %q", id), systemOneQuestionFields); err != nil {
return err
}
typ, err := requiredString(question.Type, "question type")
if err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
// Mirror the wire schema generated from https://api.typesafe.ai/openapi.json
// in TypeSafe SDK v0.5.7: instructions are optional and nullable for all types.
if err := validateOptionalDescription(question.Instructions, "instructions"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
switch typ {
case "noul":
criteria := bytes.TrimSpace(question.Criteria)
if len(criteria) == 0 || bytes.Equal(criteria, []byte("null")) {
return nil
}
var descriptions map[string]json.RawMessage
if criteria[0] != '{' || json.Unmarshal(criteria, &descriptions) != nil {
return fmt.Errorf("question %q: noul criteria must be an object", id)
}
for _, outcome := range []string{"true", "false"} {
if err := validateOptionalDescription(descriptions[outcome], "noul criteria "+outcome); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
case "choice":
var criteria map[string]json.RawMessage
if json.Unmarshal(question.Criteria, &criteria) != nil || criteria == nil {
return fmt.Errorf("question %q: choice criteria must be an object", id)
}
for _, value := range criteria {
if err := validateOptionalDescription(value, "choice criteria value"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
case "score":
var criteria []json.RawMessage
if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) == 0 {
return fmt.Errorf("question %q: score criteria must contain at least one level", id)
}
for _, value := range criteria {
if err := validateStringObjectOrArray(value, "score criteria value"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
default:
return fmt.Errorf("question %q: unsupported type %q", id, typ)
}
return nil
}
func validateOptionalDescription(raw json.RawMessage, name string) error {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
return validateStringObjectOrArray(raw, name)
}
func requiredString(raw json.RawMessage, name string) (string, error) {
var value string
if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%s must be a non-empty string", name)
}
return value, nil
}
func validateStringObjectOrArray(raw json.RawMessage, name string) error {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return fmt.Errorf("%s is required", name)
}
switch raw[0] {
case '{', '[':
return nil
case '"':
if rawString(raw) {
return nil
}
}
return fmt.Errorf("%s must be a string, object, or array", name)
}
func rawString(raw json.RawMessage) bool {
var value string
return json.Unmarshal(raw, &value) == nil
}
// checkSystemOneObjectKeys rejects duplicate keys and non-canonical spellings
// (any case variant) of the known fields in one JSON object level.
func checkSystemOneObjectKeys(raw []byte, scope string, canonical []string) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
return fmt.Errorf("%s must be an object", scope)
}
seen := make(map[string]struct{})
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return errors.New("invalid JSON request")
}
key, _ := token.(string)
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("%s contains duplicate field %q", scope, key)
}
seen[key] = struct{}{}
for _, field := range canonical {
if key != field && strings.EqualFold(key, field) {
return fmt.Errorf("%s field %q must be written as %q", scope, key, field)
}
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return errors.New("invalid JSON request")
}
}
return nil
}
@@ -0,0 +1,124 @@
package typesafe
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateSystemOneRequestValidQuestionTypes(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"noul string state", `{"model":"jev-latest","state":"sample","questions":{"safety":{"type":"noul","instructions":"Evaluate safety","criteria":{"safe":"No harm"}}}}`},
{"choice object state", `{"model":"jev-latest","state":{"text":"sample"},"questions":{"label":{"type":"choice","instructions":{"task":"Classify"},"criteria":{"safe":"Allowed","unsafe":null}}},"stream":false}`},
{"score array state", `{"model":"jev-latest","state":["sample"],"questions":{"quality":{"type":"score","instructions":["Rate quality"],"criteria":["poor","good"]}}}`},
{"noul omitted instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul"}}}`},
{"noul nullable instructions and criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`},
{"noul structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"examples":["yes",null]},"false":["no",null],"extension":42}}}}`},
{"choice structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":{"description":"A","extra":null},"b":["B",null],"c":null}}}}`},
{"choice empty criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`},
{"score one level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`},
{"score object level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`},
{"score array level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`},
{"native extensions preserved", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}},"extension":[1,null]}`},
} {
t.Run(tc.name, func(t *testing.T) {
model, err := ValidateSystemOneRequest([]byte(tc.body))
require.NoError(t, err)
require.Equal(t, JevLatestModel, model)
})
}
}
func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"invalid json", `{`, "invalid JSON"},
{"missing model", `{"state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "model"},
{"illegal model", `{"model":"jev-old","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "jev-latest"},
{"model with whitespace", `{"model":" jev-latest ","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "jev-latest"},
{"missing state", `{"model":"jev-latest","questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"},
{"scalar state", `{"model":"jev-latest","state":42,"questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"},
{"empty questions", `{"model":"jev-latest","state":"x","questions":{}}`, "questions"},
{"array questions", `{"model":"jev-latest","state":"x","questions":[{"type":"noul"}]}`, "questions must be a non-empty object"},
{"null questions", `{"model":"jev-latest","state":"x","questions":null}`, "questions must be a non-empty object"},
{"unknown question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"boolean","instructions":"x"}}}`, "unsupported type"},
{"question type with whitespace", `{"model":"jev-latest","state":"x","questions":{"q":{"type":" noul ","instructions":"x"}}}`, "unsupported type"},
{"noul criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":[]}}}`, "noul criteria"},
{"noul numeric description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"true":1}}}}`, "noul criteria"},
{"noul boolean description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"false":false}}}}`, "noul criteria"},
{"missing choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice"}}}`, "choice criteria"},
{"null choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":null}}}`, "choice criteria"},
{"choice criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":[]}}}`, "choice criteria"},
{"choice numeric value", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{"one":1}}}}`, "choice criteria"},
{"missing score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score"}}}`, "score criteria"},
{"empty score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[]}}}`, "score criteria"},
{"null score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":null}}}`, "score criteria"},
{"score map is not wire protocol", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":{"0":"low"}}}}`, "score criteria"},
{"numeric score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":["low",2]}}}`, "score criteria"},
{"null score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[null]}}}`, "score criteria"},
{"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"},
{"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"},
{"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"},
{"case variant model smuggles upstream model", `{"model":"jev-pro","MODEL":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `must be written as "model"`},
{"case variant stream hides streaming", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}},"stream":true,"Stream":false}`, `must be written as "stream"`},
{"unicode fold variant state", `{"model":"jev-latest","state":"x","ſtate":"y","questions":{"q":{"type":"noul"}}}`, `must be written as "state"`},
{"duplicate model", `{"model":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"escaped duplicate model", `{"\u006dodel":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"duplicate state", `{"model":"jev-latest","state":"benign","state":"payload","questions":{"q":{"type":"noul"}}}`, `duplicate field "state"`},
{"duplicate question id", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"},"q":{"type":"noul"}}}`, `duplicate field "q"`},
{"duplicate question instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"a","instructions":"b"}}}`, `duplicate field "instructions"`},
{"case variant question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","Type":"choice"}}}`, `must be written as "type"`},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ValidateSystemOneRequest([]byte(tc.body))
require.Error(t, err)
require.Contains(t, err.Error(), tc.want)
if tc.name == "stream true" {
require.True(t, errors.Is(err, ErrStreamingUnsupported))
}
})
}
}
func TestDecodeSystemOneResponseToleratesUsageShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
model string
input, output int
}{
{"integers", `{"model":"jev-1","usage":{"input_tokens":12,"output_tokens":3}}`, "jev-1", 12, 3},
{"floats", `{"model":"jev-1","usage":{"input_tokens":12.0,"output_tokens":2.6}}`, "jev-1", 12, 3},
{"numeric strings", `{"usage":{"input_tokens":"15","output_tokens":" 4 "}}`, "", 15, 4},
{"non numeric usage", `{"model":7,"usage":{"input_tokens":"many","output_tokens":null}}`, "", 0, 0},
{"negative usage", `{"usage":{"input_tokens":-5}}`, "", 0, 0},
{"usage not object", `{"model":"jev-1","usage":"none","answers":{}}`, "jev-1", 0, 0},
{"missing usage", `{"answers":{}}`, "", 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
decoded, err := DecodeSystemOneResponse(strings.NewReader(tc.body))
require.NoError(t, err)
require.Equal(t, []byte(tc.body), decoded.Body)
require.Equal(t, tc.model, decoded.Model)
require.Equal(t, tc.input, decoded.Usage.InputTokens)
require.Equal(t, tc.output, decoded.Usage.OutputTokens)
})
}
}
func TestDecodeSystemOneResponseRejectsNonObjects(t *testing.T) {
for _, body := range []string{`[]`, `"text"`, `null`, `{`, `data: {}`} {
_, err := DecodeSystemOneResponse(strings.NewReader(body))
require.Error(t, err, body)
}
_, err := DecodeSystemOneResponse(strings.NewReader(`{"pad":"` + strings.Repeat("a", MaxSystemOneResponseBytes) + `"}`))
require.ErrorIs(t, err, ErrSystemOneResponseTooLarge)
}
+3 -5
View File
@@ -19,10 +19,7 @@ const (
// repository and service layers build their own client identity from it, so
// one bump here covers OAuth traffic and billing probes together.
// Keep in sync with https://x.ai/cli/stable.
CLIClientVersion = "0.2.120"
// billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
// Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
CLIClientVersion = "1.0.46"
BillingWeeklyPath = "/billing?format=credits"
BillingMonthlyPath = "/billing"
@@ -148,7 +145,8 @@ func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue)
req.Header.Set(CLIClientVersionHeader, CLIClientVersion)
req.Header.Set("User-Agent", billingCLIUserAgent)
req.Header.Set("User-Agent", CLIUserAgent(CLIClientVersion))
req.Header.Set("x-grok-client-mode", CLIClientMode)
}
// ParseBillingPayload unmarshals a billing API response body.
+2 -1
View File
@@ -38,7 +38,8 @@ func TestApplyCLIBillingHeaders(t *testing.T) {
require.Equal(t, "Bearer token", req.Header.Get("Authorization"))
require.Equal(t, CLITokenAuthValue, req.Header.Get(CLITokenAuthHeader))
require.Equal(t, CLIClientVersion, req.Header.Get(CLIClientVersionHeader))
require.Equal(t, "grok-pager/"+CLIClientVersion+" grok-shell/"+CLIClientVersion+" (macos; aarch64)", req.UserAgent())
require.Equal(t, CLIUserAgent(CLIClientVersion), req.UserAgent())
require.Equal(t, "interactive", req.Header.Get("x-grok-client-mode"))
}
func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
+22 -7
View File
@@ -3,6 +3,7 @@ package xai
import (
"net/http"
"os"
"runtime"
"strings"
"golang.org/x/mod/semver"
@@ -16,7 +17,7 @@ const (
CLIProxyHost = "cli-chat-proxy.grok.com"
// CLIStableVersion is the known-good minimum client version accepted by cli-chat-proxy.
CLIStableVersion = "0.2.93"
CLIStableVersion = "1.0.13"
// CLIVersionEnv is the optional operator override for CLIStableVersion.
CLIVersionEnv = "XAI_GROK_CLI_VERSION"
@@ -24,11 +25,11 @@ const (
// CLITokenAuth is required by cli-chat-proxy for Grok Build OAuth tokens.
CLITokenAuth = "xai-grok-cli"
// CLIClientIdentifier is the x-grok-client-identifier value used by Grok shell/CLI.
CLIClientIdentifier = "grok-shell"
// CLIClientIdentifier 对齐官方交互式 CLI 主请求的客户端标识。
CLIClientIdentifier = "grok-pager"
// CLIClientMode is used by billing / quota probes on the CLI surface.
CLIClientMode = "cli"
// CLIClientMode 对齐官方 CLI 正常交互模式。
CLIClientMode = "interactive"
)
// ResolveCLIVersion returns a supported CLI client version.
@@ -54,12 +55,24 @@ func IsSupportedCLIVersion(version string) bool {
semver.Compare(canonical, minimum) >= 0
}
// CLIUserAgent builds the workspace-style User-Agent for a CLI client version.
// CLIUserAgent 对齐官方交互式 CLI 的 UA,平台名称使用 Rust 的格式。
func CLIUserAgent(version string) string {
if strings.TrimSpace(version) == "" {
version = CLIClientVersion
}
return "xai-grok-workspace/" + version
platform, arch := runtime.GOOS, runtime.GOARCH
if platform == "darwin" {
platform = "macos"
}
switch arch {
case "amd64":
arch = "x86_64"
case "arm64":
arch = "aarch64"
case "386":
arch = "x86"
}
return "grok-pager/" + version + " grok-shell/" + version + " (" + platform + "; " + arch + ")"
}
// ApplyCLIProxyHeaders stamps the fixed Grok CLI identity when the request
@@ -75,5 +88,7 @@ func ApplyCLIProxyHeaders(req *http.Request) {
req.Header.Set("X-XAI-Token-Auth", CLITokenAuth)
req.Header.Set("x-grok-client-version", version)
req.Header.Set("x-grok-client-identifier", CLIClientIdentifier)
req.Header.Set("x-grok-client-mode", CLIClientMode)
req.Header.Set("x-authenticateresponse", "authenticate-response")
req.Header.Set("User-Agent", CLIUserAgent(version))
}
+42 -9
View File
@@ -2,11 +2,40 @@ package xai
import (
"net/http"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
)
func TestApplyCLIProxyHeadersMeetsUpstreamMinimumVersion(t *testing.T) {
// 上游 426 明确要求至少 1.0.13;断言独立于生产版本常量,防止旧覆盖值绕过下限。
for _, override := range []string{"", "0.2.93", "0.2.120", "1.0.12", "1.0.13-beta.1", "1.0.13", "1.0.14-alpha.1"} {
t.Run("override="+override, func(t *testing.T) {
t.Setenv(CLIVersionEnv, override)
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
ApplyCLIProxyHeaders(req)
version := req.Header.Get("x-grok-client-version")
require.True(t, semver.IsValid("v"+version))
require.GreaterOrEqual(t, semver.Compare("v"+version, "v1.0.13"), 0,
"Grok Responses 会以 426 拒绝版本 %s,最低要求为 1.0.13", version)
require.Contains(t, req.Header.Get("User-Agent"), "grok-shell/"+version+" (")
})
}
}
func TestCLIUserAgentMatchesOfficialInteractiveCapture(t *testing.T) {
// 来源:官方 CLI 1.0.46 交互式界面在 Linux x86_64 的主 Responses 请求抓包。
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
t.Skip("该抓包样本来自 Linux x86_64")
}
require.Equal(t, "grok-pager/1.0.46 grok-shell/1.0.46 (linux; x86_64)", CLIUserAgent("1.0.46"))
}
func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) {
t.Setenv(CLIVersionEnv, "")
// Default advertise pin is CLIClientVersion; CLIStableVersion is only the floor.
@@ -16,18 +45,18 @@ func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) {
}
func TestResolveCLIVersionAcceptsValidOverride(t *testing.T) {
t.Setenv(CLIVersionEnv, "0.2.95-alpha.1")
require.Equal(t, "0.2.95-alpha.1", ResolveCLIVersion())
t.Setenv(CLIVersionEnv, "1.0.14-alpha.1")
require.Equal(t, "1.0.14-alpha.1", ResolveCLIVersion())
}
func TestResolveCLIVersionRejectsUnsafeOrTooOld(t *testing.T) {
for _, version := range []string{
"0.2.92",
"0.2.93-beta.1",
"0.2.95\r\nX-Injected: true",
"0.2.093",
"0.3",
"1",
"1.0.12",
"1.0.13-beta.1",
"1.0.14\r\nX-Injected: true",
"1.0.014",
"1.1",
"2",
} {
t.Run(version, func(t *testing.T) {
t.Setenv(CLIVersionEnv, version)
@@ -48,11 +77,13 @@ func TestApplyCLIProxyHeaders(t *testing.T) {
require.Equal(t, CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, CLIClientIdentifier, req.Header.Get("x-grok-client-identifier"))
require.Equal(t, CLITokenAuth, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "interactive", req.Header.Get("x-grok-client-mode"))
require.Equal(t, "authenticate-response", req.Header.Get("x-authenticateresponse"))
require.Equal(t, CLIUserAgent(CLIClientVersion), req.Header.Get("User-Agent"))
}
func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
t.Setenv(CLIVersionEnv, "0.2.95")
t.Setenv(CLIVersionEnv, "1.0.14")
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
@@ -63,5 +94,7 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("x-grok-client-identifier"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Empty(t, req.Header.Get("x-grok-client-mode"))
require.Empty(t, req.Header.Get("x-authenticateresponse"))
require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent"))
}
@@ -652,6 +652,17 @@ func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
if sortBy == "group" {
// Sort before pagination, keeping ungrouped keys last in either direction.
opts := []entsql.OrderTermOption{entsql.OrderNullsLast()}
tieOrder := dbent.Asc(apikey.FieldID)
if sortOrder == pagination.SortOrderDesc {
opts = append(opts, entsql.OrderDesc())
tieOrder = dbent.Desc(apikey.FieldID)
}
return []func(*entsql.Selector){apikey.ByGroupField(group.FieldName, opts...), tieOrder}
}
var field string
switch sortBy {
case "name":
@@ -0,0 +1,78 @@
package repository
import (
"context"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAPIKeyRepositoryListByUserIDSortByGroup(t *testing.T) {
repo, client := newAPIKeyRepoSQLite(t)
ctx := context.Background()
user := mustCreateAPIKeyRepoUser(t, ctx, client, "group-sort@test.com")
otherUser := mustCreateAPIKeyRepoUser(t, ctx, client, "other-group-sort@test.com")
createGroup := func(name string) *dbent.Group {
g, err := client.Group.Create().SetName(name).Save(ctx)
require.NoError(t, err)
return g
}
// Create groups in reverse name order so sorting by group ID cannot pass.
zulu := createGroup("Zulu")
alpha := createGroup("Alpha")
createKey := func(userID int64, name string, groupID *int64, status string) int64 {
key := &service.APIKey{
UserID: userID, Key: "sk-" + name, Name: name, GroupID: groupID, Status: status,
}
require.NoError(t, repo.Create(ctx, key))
return key.ID
}
zuluFirst := createKey(user.ID, "match-zulu-first", &zulu.ID, service.StatusActive)
ungroupedFirst := createKey(user.ID, "match-ungrouped-first", nil, service.StatusActive)
alphaFirst := createKey(user.ID, "match-alpha-first", &alpha.ID, service.StatusActive)
zuluSecond := createKey(user.ID, "match-zulu-second", &zulu.ID, service.StatusActive)
alphaSecond := createKey(user.ID, "match-alpha-second", &alpha.ID, service.StatusActive)
ungroupedSecond := createKey(user.ID, "match-ungrouped-second", nil, service.StatusActive)
createKey(otherUser.ID, "match-other-user", &alpha.ID, service.StatusActive)
createKey(user.ID, "excluded-search", &alpha.ID, service.StatusActive)
createKey(user.ID, "match-inactive", &alpha.ID, service.StatusDisabled)
deleted := createKey(user.ID, "match-deleted", &alpha.ID, service.StatusActive)
require.NoError(t, repo.Delete(ctx, deleted))
ungroupedID := int64(0)
for _, tc := range []struct {
name string
order string
groupID *int64
want []int64
}{
{"ascending", "asc", nil, []int64{alphaFirst, alphaSecond, zuluFirst, zuluSecond, ungroupedFirst, ungroupedSecond}},
{"descending", "desc", nil, []int64{zuluSecond, zuluFirst, alphaSecond, alphaFirst, ungroupedSecond, ungroupedFirst}},
{"group filter", "asc", &alpha.ID, []int64{alphaFirst, alphaSecond}},
{"ungrouped filter", "desc", &ungroupedID, []int64{ungroupedSecond, ungroupedFirst}},
} {
t.Run(tc.name, func(t *testing.T) {
var got []int64
const pageSize = 3
pages := (len(tc.want) + pageSize - 1) / pageSize
for page := 1; page <= pages; page++ {
keys, result, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{
Page: page, PageSize: pageSize, SortBy: "group", SortOrder: tc.order,
}, service.APIKeyListFilters{Search: "match", Status: service.StatusActive, GroupID: tc.groupID})
require.NoError(t, err)
require.EqualValues(t, len(tc.want), result.Total)
require.Equal(t, pages, result.Pages)
for _, key := range keys {
got = append(got, key.ID)
if key.GroupID != nil {
require.NotNil(t, key.Group, "sorting must preserve group preloading")
}
}
}
require.Equal(t, tc.want, got)
})
}
}
+92 -25
View File
@@ -17,8 +17,42 @@ const (
passwordResetKeyPrefix = "password_reset:"
passwordResetSentAtKeyPrefix = "password_reset_sent:"
notifyCodeUserRateKeyPrefix = "notify_code_user_rate:"
// attemptsKeySuffix stores the failed-attempt counter next to a verification code.
// Kept in a separate key so it can be incremented atomically with INCR.
attemptsKeySuffix = ":attempts"
)
// incrAttemptsScript atomically increments the attempt counter for an existing
// verification code and aligns the counter TTL with the code TTL.
// KEYS[1] = code key, KEYS[2] = attempts key. Returns -1 when the code is missing.
var incrAttemptsScript = redis.NewScript(`
if redis.call('EXISTS', KEYS[1]) == 0 then
return -1
end
local n = redis.call('INCR', KEYS[2])
local ttl = redis.call('PTTL', KEYS[1])
if ttl > 0 then
redis.call('PEXPIRE', KEYS[2], ttl)
end
return n
`)
// consumeResetTokenScript atomically compares the stored token hash and deletes it.
// KEYS[1] = reset key, ARGV[1] = expected token hash. Returns 1 on success, 0 otherwise.
var consumeResetTokenScript = redis.NewScript(`
local v = redis.call('GET', KEYS[1])
if not v then
return 0
end
local ok, d = pcall(cjson.decode, v)
if not ok or type(d) ~= 'table' or d['Token'] ~= ARGV[1] then
return 0
end
redis.call('DEL', KEYS[1])
return 1
`)
// verifyCodeKey generates the Redis key for email verification code.
// Email is lowercased for case-insensitive consistency.
func verifyCodeKey(email string) string {
@@ -50,8 +84,7 @@ func NewEmailCache(rdb *redis.Client) service.EmailCache {
return &emailCache{rdb: rdb}
}
func (c *emailCache) GetVerificationCode(ctx context.Context, email string) (*service.VerificationCodeData, error) {
key := verifyCodeKey(email)
func (c *emailCache) getCode(ctx context.Context, key string) (*service.VerificationCodeData, error) {
val, err := c.rdb.Get(ctx, key).Result()
if err != nil {
return nil, err
@@ -60,21 +93,56 @@ func (c *emailCache) GetVerificationCode(ctx context.Context, email string) (*se
if err := json.Unmarshal([]byte(val), &data); err != nil {
return nil, err
}
if n, err := c.rdb.Get(ctx, key+attemptsKeySuffix).Int(); err == nil && n > data.Attempts {
data.Attempts = n
}
return &data, nil
}
func (c *emailCache) SetVerificationCode(ctx context.Context, email string, data *service.VerificationCodeData, ttl time.Duration) error {
key := verifyCodeKey(email)
func (c *emailCache) setCode(ctx context.Context, key string, data *service.VerificationCodeData, ttl time.Duration) error {
val, err := json.Marshal(data)
if err != nil {
return err
}
return c.rdb.Set(ctx, key, val, ttl).Err()
pipe := c.rdb.TxPipeline()
pipe.Set(ctx, key, val, ttl)
pipe.Del(ctx, key+attemptsKeySuffix)
if data.Attempts > 0 {
pipe.Set(ctx, key+attemptsKeySuffix, data.Attempts, ttl)
}
_, err = pipe.Exec(ctx)
return err
}
func (c *emailCache) incrCodeAttempts(ctx context.Context, key string) (int, error) {
n, err := incrAttemptsScript.Run(ctx, c.rdb, []string{key, key + attemptsKeySuffix}).Int()
if err != nil {
return 0, err
}
if n < 0 {
return 0, redis.Nil
}
return n, nil
}
func (c *emailCache) deleteCode(ctx context.Context, key string) error {
return c.rdb.Del(ctx, key, key+attemptsKeySuffix).Err()
}
func (c *emailCache) GetVerificationCode(ctx context.Context, email string) (*service.VerificationCodeData, error) {
return c.getCode(ctx, verifyCodeKey(email))
}
func (c *emailCache) SetVerificationCode(ctx context.Context, email string, data *service.VerificationCodeData, ttl time.Duration) error {
return c.setCode(ctx, verifyCodeKey(email), data, ttl)
}
func (c *emailCache) IncrVerificationCodeAttempts(ctx context.Context, email string) (int, error) {
return c.incrCodeAttempts(ctx, verifyCodeKey(email))
}
func (c *emailCache) DeleteVerificationCode(ctx context.Context, email string) error {
key := verifyCodeKey(email)
return c.rdb.Del(ctx, key).Err()
return c.deleteCode(ctx, verifyCodeKey(email))
}
// Password reset token methods
@@ -101,6 +169,16 @@ func (c *emailCache) SetPasswordResetToken(ctx context.Context, email string, da
return c.rdb.Set(ctx, key, val, ttl).Err()
}
// ConsumePasswordResetToken atomically deletes the stored reset token when its
// stored hash equals tokenHash. Returns true only for the single winning caller.
func (c *emailCache) ConsumePasswordResetToken(ctx context.Context, email, tokenHash string) (bool, error) {
n, err := consumeResetTokenScript.Run(ctx, c.rdb, []string{passwordResetKey(email)}, tokenHash).Int()
if err != nil {
return false, err
}
return n == 1, nil
}
func (c *emailCache) DeletePasswordResetToken(ctx context.Context, email string) error {
key := passwordResetKey(email)
return c.rdb.Del(ctx, key).Err()
@@ -122,30 +200,19 @@ func (c *emailCache) SetPasswordResetEmailCooldown(ctx context.Context, email st
// Notify email verification code methods
func (c *emailCache) GetNotifyVerifyCode(ctx context.Context, email string) (*service.VerificationCodeData, error) {
key := notifyVerifyKey(email)
val, err := c.rdb.Get(ctx, key).Result()
if err != nil {
return nil, err
}
var data service.VerificationCodeData
if err := json.Unmarshal([]byte(val), &data); err != nil {
return nil, err
}
return &data, nil
return c.getCode(ctx, notifyVerifyKey(email))
}
func (c *emailCache) SetNotifyVerifyCode(ctx context.Context, email string, data *service.VerificationCodeData, ttl time.Duration) error {
key := notifyVerifyKey(email)
val, err := json.Marshal(data)
if err != nil {
return err
}
return c.rdb.Set(ctx, key, val, ttl).Err()
return c.setCode(ctx, notifyVerifyKey(email), data, ttl)
}
func (c *emailCache) IncrNotifyVerifyCodeAttempts(ctx context.Context, email string) (int, error) {
return c.incrCodeAttempts(ctx, notifyVerifyKey(email))
}
func (c *emailCache) DeleteNotifyVerifyCode(ctx context.Context, email string) error {
key := notifyVerifyKey(email)
return c.rdb.Del(ctx, key).Err()
return c.deleteCode(ctx, notifyVerifyKey(email))
}
// User-level rate limiting for notify email verification codes
@@ -0,0 +1,150 @@
package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func newMiniredisEmailCache(t *testing.T) (service.EmailCache, *miniredis.Miniredis, *redis.Client) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
return NewEmailCache(rdb), mr, rdb
}
func TestEmailCache_ConcurrentWrongCodesCannotExceedAttemptCap(t *testing.T) {
cache, _, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "user@example.com"
svc := service.NewEmailService(nil, cache)
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{
Code: "123456",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(15 * time.Minute),
}, 15*time.Minute))
const workers = 50
var invalid, maxed atomic.Int32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := svc.VerifyCode(ctx, email, "000000")
switch {
case errors.Is(err, service.ErrInvalidVerifyCode):
invalid.Add(1)
case errors.Is(err, service.ErrVerifyCodeMaxAttempts):
maxed.Add(1)
default:
t.Errorf("unexpected result: %v", err)
}
}()
}
wg.Wait()
// Only attempts 1..4 may return "invalid"; every other guess is rejected by the cap.
require.LessOrEqual(t, int(invalid.Load()), 4)
require.Equal(t, workers, int(invalid.Load()+maxed.Load()))
// Even the correct code is now rejected.
require.ErrorIs(t, svc.VerifyCode(ctx, email, "123456"), service.ErrVerifyCodeMaxAttempts)
data, err := cache.GetVerificationCode(ctx, email)
require.NoError(t, err)
require.GreaterOrEqual(t, data.Attempts, 5)
}
func TestEmailCache_AttemptsResetOnNewCodeAndTTLFollowsCode(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "User@Example.com"
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{Code: "1"}, time.Minute))
n, err := cache.IncrVerificationCodeAttempts(ctx, email)
require.NoError(t, err)
require.Equal(t, 1, n)
require.Greater(t, mr.TTL(verifyCodeKey(email)+attemptsKeySuffix), time.Duration(0))
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{Code: "2"}, time.Minute))
data, err := cache.GetVerificationCode(ctx, email)
require.NoError(t, err)
require.Equal(t, 0, data.Attempts)
require.NoError(t, cache.DeleteVerificationCode(ctx, email))
_, err = cache.IncrVerificationCodeAttempts(ctx, email)
require.Error(t, err)
require.False(t, mr.Exists(verifyCodeKey(email)+attemptsKeySuffix))
}
func TestEmailCache_PasswordResetTokenHashedAndSingleUse(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "reset@example.com"
svc := service.NewEmailService(nil, cache)
// Seed the token the same way SendPasswordResetEmail does (hash only).
token, err := svc.GeneratePasswordResetToken()
require.NoError(t, err)
sum := sha256.Sum256([]byte(token))
require.NoError(t, cache.SetPasswordResetToken(ctx, email, &service.PasswordResetTokenData{
Token: hex.EncodeToString(sum[:]), CreatedAt: time.Now(),
}, 30*time.Minute))
raw, err := mr.Get(passwordResetKey(email))
require.NoError(t, err)
require.False(t, strings.Contains(raw, token), "plaintext token must not be stored")
require.NoError(t, svc.VerifyPasswordResetToken(ctx, email, token))
require.ErrorIs(t, svc.ConsumePasswordResetToken(ctx, email, "wrong"), service.ErrInvalidResetToken)
const workers = 30
var ok atomic.Int32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if svc.ConsumePasswordResetToken(ctx, email, token) == nil {
ok.Add(1)
}
}()
}
wg.Wait()
require.Equal(t, int32(1), ok.Load())
require.False(t, mr.Exists(passwordResetKey(email)))
}
func TestEmailCache_ConsumePasswordResetTokenMismatchKeepsToken(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "keep@example.com"
require.NoError(t, cache.SetPasswordResetToken(ctx, email, &service.PasswordResetTokenData{Token: "abc"}, time.Minute))
ok, err := cache.ConsumePasswordResetToken(ctx, email, "xyz")
require.NoError(t, err)
require.False(t, ok)
require.True(t, mr.Exists(passwordResetKey(email)))
ok, err = cache.ConsumePasswordResetToken(ctx, email, "abc")
require.NoError(t, err)
require.True(t, ok)
ok, err = cache.ConsumePasswordResetToken(ctx, email, "abc")
require.NoError(t, err)
require.False(t, ok)
}
@@ -466,6 +466,8 @@ func newGrokOfficialAPIFallbackRequest(req *http.Request) (*http.Request, error)
for _, header := range []string{
"X-XAI-Token-Auth",
"X-Grok-Client-Version",
"X-Grok-Client-Mode",
"X-Authenticateresponse",
"X-Grok-Client-Surface",
"X-UserID",
"X-Email",
@@ -531,6 +533,8 @@ func applyGrokCLIProxyHeaders(req *http.Request) {
req.Header.Set("X-XAI-Token-Auth", xai.CLITokenAuth)
req.Header.Set("x-grok-client-version", version)
req.Header.Set("x-grok-client-identifier", xai.CLIClientIdentifier)
req.Header.Set("x-grok-client-mode", xai.CLIClientMode)
req.Header.Set("x-authenticateresponse", "authenticate-response")
req.Header.Set("User-Agent", xai.CLIUserAgent(version))
}
@@ -237,6 +237,10 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
require.NoError(t, resp.Body.Close())
require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version"))
require.Equal(t, "1.0.46", capturedHeaders.Get("x-grok-client-version"))
require.Equal(t, "grok-pager", capturedHeaders.Get("x-grok-client-identifier"))
require.Equal(t, "interactive", capturedHeaders.Get("x-grok-client-mode"))
require.Equal(t, "authenticate-response", capturedHeaders.Get("x-authenticateresponse"))
require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent"))
})
@@ -309,6 +313,8 @@ func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T)
require.Equal(t, "Bearer oauth-token", fallbackHeaders.Get("Authorization"))
require.Empty(t, fallbackHeaders.Get("X-XAI-Token-Auth"))
require.Empty(t, fallbackHeaders.Get("x-grok-client-version"))
require.Empty(t, fallbackHeaders.Get("x-grok-client-mode"))
require.Empty(t, fallbackHeaders.Get("x-authenticateresponse"))
require.Empty(t, fallbackHeaders.Get("User-Agent"))
}
@@ -465,18 +471,18 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
})
t.Run("accepts a valid operator override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1")
t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47-alpha.1")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
require.NoError(t, err)
applyGrokCLIProxyHeaders(req)
require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent"))
require.Equal(t, "1.0.47-alpha.1", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("1.0.47-alpha.1"), req.Header.Get("User-Agent"))
})
t.Run("rejects an unsafe override", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true")
t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47\r\nX-Injected: true")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
@@ -487,7 +493,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
})
t.Run("rejects an override below the supported minimum", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119")
t.Setenv("XAI_GROK_CLI_VERSION", "1.0.45")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
@@ -498,7 +504,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
})
t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1")
t.Setenv("XAI_GROK_CLI_VERSION", "1.0.46-beta.1")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
@@ -511,11 +517,11 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
// Every entry sits above the pinned minimum, so a rejection here can only be
// caused by the malformed semver and never by the version being too old.
for _, version := range []string{
"0.2.0121",
"0.2.121-alpha..1",
"0.3",
"1",
"0.2.121+build.1",
"1.0.047",
"1.0.47-alpha..1",
"1.1",
"2",
"1.0.47+build.1",
} {
t.Run("rejects invalid semver "+version, func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", version)
@@ -530,7 +536,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
}
t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95")
t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47")
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "direct-api-client/1.0")
@@ -187,16 +187,17 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
result.BalanceOverdrafted = !sufficient
}
// Key 已不存在时跳过其自身的额度/限速计数,其余结算项不受影响。
if cmd.APIKeyQuotaCost > 0 {
exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)
if err != nil {
if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) {
return err
}
result.APIKeyQuotaExhausted = exhausted
}
if cmd.APIKeyRateLimitCost > 0 {
if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil {
if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) {
return err
}
}
@@ -162,6 +162,61 @@ func TestUsageBillingRepositoryApply_RequestFingerprintConflict(t *testing.T) {
require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict)
}
func TestUsageBillingRepositoryApply_DeletedAPIKeyStillBillsBalance(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewUsageBillingRepository(client, integrationDB)
user := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("usage-billing-deleted-key-user-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Balance: 100,
})
apiKey := mustCreateApiKey(t, client, &service.APIKey{
UserID: user.ID,
Key: "sk-usage-billing-deleted-key-" + uuid.NewString(),
Name: "billing-deleted-key",
Quota: 50,
RateLimit5h: 50,
})
account := mustCreateAccount(t, client, &service.Account{
Name: "usage-billing-deleted-key-account-" + uuid.NewString(),
Type: service.AccountTypeAPIKey,
})
_, err := integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", apiKey.ID)
require.NoError(t, err)
requestID := uuid.NewString()
result, err := repo.Apply(ctx, &service.UsageBillingCommand{
RequestID: requestID,
APIKeyID: apiKey.ID,
UserID: user.ID,
AccountID: account.ID,
AccountType: service.AccountTypeAPIKey,
BalanceCost: 1.25,
APIKeyQuotaCost: 1.25,
APIKeyRateLimitCost: 1.25,
})
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Applied)
require.False(t, result.APIKeyQuotaExhausted)
var balance float64
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance))
require.InDelta(t, 98.75, balance, 0.000001)
var quotaUsed, usage5h float64
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT quota_used, usage_5h FROM api_keys WHERE id = $1", apiKey.ID).Scan(&quotaUsed, &usage5h))
require.InDelta(t, 0, quotaUsed, 0.000001)
require.InDelta(t, 0, usage5h, 0.000001)
var dedupCount int
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2", requestID, apiKey.ID).Scan(&dedupCount))
require.Equal(t, 1, dedupCount)
}
func TestUsageBillingRepositoryApply_UpdatesAccountQuota(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
@@ -20,6 +20,8 @@ const (
captureBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance\s+\+ CASE WHEN \$1 > \$2 THEN \$1 - \$2 ELSE 0 END\s+- CASE WHEN \$2 > \$1 THEN \$2 - \$1 ELSE 0 END,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$3 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
releaseBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance \+ \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
userExistsForBillingSQL = `(?s)SELECT 1\s+FROM users\s+WHERE id = \$1 AND deleted_at IS NULL`
apiKeyQuotaIncrementSQL = `(?s)UPDATE api_keys\s+SET quota_used = quota_used \+ \$1,.*WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING`
apiKeyRateLimitIncrementSQL = `(?s)UPDATE api_keys SET\s+usage_5h = .*WHERE id = \$2 AND deleted_at IS NULL`
)
func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) {
@@ -99,6 +101,66 @@ func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyUsageBillingEffects_DeletedAPIKeyStillBillsBalance(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
mock.ExpectBegin()
tx, err := db.BeginTx(ctx, nil)
require.NoError(t, err)
mock.ExpectQuery(conditionalBalanceDeductSQL).
WithArgs(10.0, int64(42)).
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(90.0))
mock.ExpectQuery(apiKeyQuotaIncrementSQL).
WithArgs(10.0, int64(7), service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted).
WillReturnError(sql.ErrNoRows)
mock.ExpectExec(apiKeyRateLimitIncrementSQL).
WithArgs(10.0, int64(7)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
result := &service.UsageBillingApplyResult{Applied: true}
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
UserID: 42,
APIKeyID: 7,
BalanceCost: 10,
APIKeyQuotaCost: 10,
APIKeyRateLimitCost: 10,
}, result)
require.NoError(t, err)
require.NotNil(t, result.NewBalance)
require.InDelta(t, 90.0, *result.NewBalance, 0.000001)
require.False(t, result.APIKeyQuotaExhausted)
require.NoError(t, tx.Commit())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyUsageBillingEffects_APIKeyCounterErrorStillFails(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
mock.ExpectBegin()
tx, err := db.BeginTx(ctx, nil)
require.NoError(t, err)
mock.ExpectExec(apiKeyRateLimitIncrementSQL).
WithArgs(10.0, int64(7)).
WillReturnError(sql.ErrConnDone)
mock.ExpectRollback()
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
UserID: 42,
APIKeyID: 7,
APIKeyRateLimitCost: 10,
}, &service.UsageBillingApplyResult{Applied: true})
require.ErrorIs(t, err, sql.ErrConnDone)
require.NoError(t, tx.Rollback())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
@@ -106,6 +106,8 @@ func extractProtocolSegments(protocol string, document any) []promptSegment {
return append(extractInstructions(root["instructions"]), extractResponses(root["input"])...)
case "openai_images", "grok_media", "media", "images":
return userPromptSegments(extractMediaPrompts(root))
case "typesafe_systemone":
return extractSystemOneSegments(root)
default:
if segments := extractChatLikeSegments(root); len(segments) > 0 {
return segments
@@ -120,6 +122,79 @@ func extractProtocolSegments(protocol string, document any) []promptSegment {
}
}
// extractSystemOneSegments collects every client-controlled text of a TypeSafe
// System One request: question IDs, every question field except the validated
// type, unknown top-level extension fields, and the evaluated state. Object keys
// are text too (Jev reads the whole JSON), so they are collected with values.
// The state comes last so it is the prioritized segment, and keys are visited
// in sorted order to keep the prompt hash stable across requests.
func extractSystemOneSegments(root map[string]any) []promptSegment {
if root == nil {
return nil
}
texts := make([]string, 0, 4)
questions, isObject := root["questions"].(map[string]any)
if !isObject {
texts = appendJSONStringLeaves(texts, root["questions"])
}
for _, id := range sortedJSONKeys(questions) {
texts = appendJSONStringLeaves(texts, id)
question, ok := questions[id].(map[string]any)
if !ok {
texts = appendJSONStringLeaves(texts, questions[id])
continue
}
for _, field := range sortedJSONKeys(question) {
switch field {
case "type":
continue
case "instructions", "criteria":
default:
texts = appendJSONStringLeaves(texts, field)
}
texts = appendJSONStringLeaves(texts, question[field])
}
}
for _, field := range sortedJSONKeys(root) {
switch field {
case "model", "stream", "state", "questions":
continue
}
texts = appendJSONStringLeaves(texts, field)
texts = appendJSONStringLeaves(texts, root[field])
}
texts = appendJSONStringLeaves(texts, root["state"])
return userPromptSegments(texts)
}
func appendJSONStringLeaves(texts []string, value any) []string {
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
texts = append(texts, text)
}
case []any:
for _, item := range typed {
texts = appendJSONStringLeaves(texts, item)
}
case map[string]any:
for _, key := range sortedJSONKeys(typed) {
texts = appendJSONStringLeaves(texts, key)
texts = appendJSONStringLeaves(texts, typed[key])
}
}
return texts
}
func sortedJSONKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// clientInstructionRoles are roles a client may freely populate. Attackers can
// place jailbreak/PII text in assistant/tool turns, so blocking audit must scan
// them too—not only user/system/developer instructions.
@@ -412,3 +412,53 @@ func mustJSON(t *testing.T, value string) []byte {
func metadataTextForTest(scanText string) string {
return strings.Replace(scanText, promptAuditPrioritySeparator, "\n\n", 1)
}
func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) {
body := `{"model":"jev-latest","state":{"STATE_KEY":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` +
`"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` +
`"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"},"EXTENSION_KEY":"EXTENSION_VALUE"},` +
`"QUESTION_ID":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}},"TOP_EXTENSION":{"x":"TOP_VALUE"}}`
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)})
require.NoError(t, err)
for _, text := range []string{"STATE_KEY", "STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC",
"NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "EXTENSION_KEY", "EXTENSION_VALUE", "QUESTION_ID", "SCORE_LOW", "SCORE_HIGH",
"TOP_EXTENSION", "TOP_VALUE"} {
require.Contains(t, snapshot.ScanText, text)
}
// Canonical field names and validated enum values are not client text.
for _, text := range []string{"jev-latest", "instructions", "criteria", "questions", "choice", "score"} {
require.NotContains(t, snapshot.ScanText, text)
}
// Map iteration order must not change the audited text or its hash.
for range 20 {
again, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)})
require.NoError(t, err)
require.Equal(t, snapshot.PromptHash, again.PromptHash)
require.Equal(t, snapshot.ScanText, again.ScanText)
}
blocking, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, true)
require.NoError(t, err)
require.Contains(t, blocking.ScanText, "STATE_TITLE")
require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS")
require.Contains(t, blocking.ScanText, "QUESTION_ID")
}
func TestPromptSnapshotTypeSafeSystemOneStringStateIsPrioritized(t *testing.T) {
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)})
require.NoError(t, err)
require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state"))
require.Equal(t, 2, snapshot.MessageCount)
}
func TestPromptSnapshotTypeSafeSystemOneAuditsKeyOnlyPayloads(t *testing.T) {
body := `{"model":"jev-latest","state":{"HIDDEN_STATE_KEY":1},"questions":{"HIDDEN_QUESTION_ID":{"type":"noul"}}}`
for _, latestTurnOnly := range []bool{false, true} {
snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, latestTurnOnly)
require.NoError(t, err)
require.Contains(t, snapshot.ScanText, "HIDDEN_STATE_KEY")
require.Contains(t, snapshot.ScanText, "HIDDEN_QUESTION_ID")
}
}
+8 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -971,6 +971,9 @@ func TestAPIContracts(t *testing.T) {
"payment_balance_recharge_multiplier": 0,
"payment_subscription_usd_to_cny_rate": 0,
"payment_recharge_fee_rate": 0,
"payment_recharge_bonus_tiers": [],
"payment_recharge_bonus_mode": "bonus",
"payment_recharge_bonus_notice": "",
"payment_load_balance_strategy": "",
"payment_product_name_prefix": "",
"payment_product_name_suffix": "",
@@ -1186,7 +1189,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1291,6 +1294,9 @@ func TestAPIContracts(t *testing.T) {
"payment_balance_recharge_multiplier": 0,
"payment_subscription_usd_to_cny_rate": 0,
"payment_recharge_fee_rate": 0,
"payment_recharge_bonus_tiers": [],
"payment_recharge_bonus_mode": "bonus",
"payment_recharge_bonus_notice": "",
"payment_load_balance_strategy": "",
"payment_product_name_prefix": "",
"payment_product_name_suffix": "",
+1 -1
View File
@@ -129,7 +129,7 @@ func registerRoutes(
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter, redisClient)
handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
}
@@ -202,6 +202,8 @@ func RegisterGatewayRoutes(
}
h.Gateway.Messages(c)
})
// System One carries only JSON text, so it uses the text body limit.
gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne)
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
// locally, and Anthropic-compatible platforms retain their existing path.
gateway.POST("/messages/count_tokens", countTokensHandler)
@@ -152,6 +152,7 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) {
{http.MethodGet, "/realtime?model=gpt-4.1", ""},
{http.MethodPost, "/v1/responses", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/v1/messages", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/v1/systemone", `{"model":"gpt-4.1","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`},
{http.MethodPost, "/v1/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`},
{http.MethodPost, "/v1/chat/completions", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
+21 -1
View File
@@ -1,12 +1,25 @@
package routes
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
ratelimit "github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// publicOrderVerifyRateLimit caps anonymous legacy out_trade_no lookups per
// client IP. The payment result page polls at most a handful of times per
// order, so this leaves ample headroom for real users while making
// out_trade_no enumeration impractical.
const (
publicOrderVerifyRateLimit = 20
publicOrderVerifyRateLimitWindow = time.Minute
)
// RegisterPaymentRoutes registers all payment-related routes:
@@ -21,6 +34,7 @@ func RegisterPaymentRoutes(
auditLog middleware.AuditLogMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
redisClient *redis.Client,
) {
// --- User-facing payment endpoints (authenticated) ---
authenticated := v1.Group("/payment")
@@ -50,9 +64,15 @@ func RegisterPaymentRoutes(
// Signed resume-token recovery is the preferred public lookup path.
// The legacy anonymous out_trade_no verify endpoint remains available as a
// persisted-state compatibility path for staggered upgrades.
// The anonymous verify endpoint is IP rate-limited to prevent out_trade_no
// enumeration. It fails open on Redis errors so an outage never blocks a
// user who is mid-payment from seeing their result.
publicRateLimiter := ratelimit.NewRateLimiter(redisClient)
public := v1.Group("/payment/public")
{
public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
public.POST("/orders/verify",
publicRateLimiter.Limit("payment-public-order-verify", publicOrderVerifyRateLimit, publicOrderVerifyRateLimitWindow),
paymentHandler.VerifyOrderPublic)
public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
}
@@ -0,0 +1,84 @@
package routes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func newPaymentRoutesTestRouter(redisClient *redis.Client) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
noop := func(c *gin.Context) { c.Next() }
RegisterPaymentRoutes(
v1,
&handler.PaymentHandler{},
&handler.PaymentWebhookHandler{},
&admin.PaymentHandler{},
servermiddleware.JWTAuthMiddleware(noop),
servermiddleware.AdminAuthMiddleware(noop),
servermiddleware.AuditLogMiddleware(noop),
nil,
nil,
redisClient,
)
return router
}
func postPublicOrderVerify(router *gin.Engine, remoteAddr string) *httptest.ResponseRecorder {
// Empty body fails binding in the handler, so no service is touched and a
// non-429 response proves the request passed the limiter.
req := httptest.NewRequest(http.MethodPost, "/api/v1/payment/public/orders/verify", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestPublicOrderVerifyRateLimitedPerIP(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
router := newPaymentRoutesTestRouter(rdb)
for i := 1; i <= publicOrderVerifyRateLimit; i++ {
w := postPublicOrderVerify(router, "198.51.100.20:1234")
require.Equal(t, http.StatusBadRequest, w.Code, "request %d should reach the handler", i)
}
w := postPublicOrderVerify(router, "198.51.100.20:1234")
require.Equal(t, http.StatusTooManyRequests, w.Code)
require.Contains(t, w.Body.String(), "rate limit exceeded")
// A different client IP has its own budget.
w = postPublicOrderVerify(router, "198.51.100.21:1234")
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestPublicOrderVerifyRateLimitFailsOpenWhenRedisUnavailable(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
DialTimeout: 50 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
})
t.Cleanup(func() { _ = rdb.Close() })
router := newPaymentRoutesTestRouter(rdb)
w := postPublicOrderVerify(router, "203.0.113.30:1234")
require.Equal(t, http.StatusBadRequest, w.Code, "users mid-payment must not be blocked by a Redis outage")
}
@@ -29,6 +29,7 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) {
audited := map[string][]string{
"/messages": {"gateway_handler.go", "openai_gateway_handler.go"},
"/systemone": {"gateway_systemone.go"},
"/responses": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/responses/*subpath": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/chat/completions": {"gateway_handler_chat_completions.go", "openai_chat_completions.go"},
+31
View File
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
@@ -268,6 +269,10 @@ func (a *Account) IsGrok() bool {
return a.Platform == PlatformGrok
}
func (a *Account) IsTypeSafe() bool {
return a != nil && a.Platform == PlatformTypeSafe
}
func (a *Account) IsGrokOAuth() bool {
return a.IsGrok() && a.Type == AccountTypeOAuth
}
@@ -980,6 +985,10 @@ func (a *Account) GetBaseURL() string {
}
baseURL := a.GetCredential("base_url")
if baseURL == "" {
// TypeSafe keys must never fall back to the Anthropic host.
if a.Platform == PlatformTypeSafe {
return typesafe.DefaultBaseURL
}
return "https://api.anthropic.com"
}
if a.Platform == PlatformAntigravity {
@@ -1001,6 +1010,28 @@ func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
return baseURL
}
func (a *Account) GetTypeSafeBaseURL() string {
if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey {
return ""
}
baseURL := strings.TrimRight(strings.TrimSpace(a.GetCredential("base_url")), "/")
// The System One path already carries /v1; accept a base URL pasted with it.
if len(baseURL) >= 3 && strings.EqualFold(baseURL[len(baseURL)-3:], "/v1") {
baseURL = strings.TrimRight(baseURL[:len(baseURL)-3], "/")
}
if baseURL == "" {
return typesafe.DefaultBaseURL
}
return baseURL
}
func (a *Account) GetTypeSafeAPIKey() string {
if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey {
return ""
}
return strings.TrimSpace(a.GetCredential("api_key"))
}
func (a *Account) GetExtraString(key string) string {
if a.Extra == nil {
return ""
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"time"
@@ -223,6 +224,9 @@ func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository)
// Create 创建账号
func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) {
if req.Platform == PlatformTypeSafe && req.Type != AccountTypeAPIKey {
return nil, errors.New("typesafe accounts only support apikey credentials")
}
// 验证分组是否存在(如果指定了分组)
if len(req.GroupIDs) > 0 {
if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil {
@@ -515,6 +519,9 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformTypeSafe:
// TypeSafe credentials are API keys; inference failures drive health and cooldown state.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
// 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
@@ -416,6 +416,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt)
}
if account.IsTypeSafe() {
return s.testTypeSafeAccountConnection(c, account, prompt)
}
return s.testClaudeAccountConnection(c, account, modelID)
}
@@ -0,0 +1,95 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
)
const (
typeSafeTestDefaultState = "Sub2API connection test"
typeSafeTestQuestionID = "connection_test"
typeSafeTestMaxPreviewBytes = 2000
)
// testTypeSafeAccountConnection probes a TypeSafe account with a minimal native
// System One request. TypeSafe accounts never speak the Claude protocol, so
// they must not fall through to testClaudeAccountConnection (which would send
// the key to /v1/messages and could misclassify the account).
func (s *AccountTestService) testTypeSafeAccountConnection(c *gin.Context, account *Account, prompt string) error {
ctx := c.Request.Context()
if account.Type != AccountTypeAPIKey {
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
}
apiKey := account.GetTypeSafeAPIKey()
if apiKey == "" {
return s.sendErrorAndEnd(c, "No API key available")
}
baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL())
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
}
state := strings.TrimSpace(prompt)
if state == "" {
state = typeSafeTestDefaultState
}
payload, err := json.Marshal(map[string]any{
"model": typesafe.JevLatestModel,
"state": state,
"questions": map[string]any{
typeSafeTestQuestionID: map[string]any{
"type": "noul",
"instructions": "Is this text a connection test?",
},
},
})
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create test payload")
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: typesafe.JevLatestModel})
req, err := typesafe.NewSystemOneRequest(ctx, baseURL, apiKey, payload)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create request")
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", sanitizeUpstreamErrorMessage(err.Error())))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, truncateString(string(body), typeSafeTestMaxPreviewBytes))
// 401/403 表示 API Key 无效或被上游拒绝,标记为 error 状态。
if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil {
_ = s.accountRepo.SetError(ctx, account.ID, errMsg)
}
return s.sendErrorAndEnd(c, errMsg)
}
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid System One response: %s", err.Error()))
}
s.sendEvent(c, TestEvent{Type: "content", Text: truncateString(string(decoded.Body), typeSafeTestMaxPreviewBytes)})
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
}
@@ -0,0 +1,92 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newTypeSafeAccountTestFixture(t *testing.T, status int, responseBody string) (*AccountTestService, *systemOnePolicyAccountRepo, *[]*http.Request, *gin.Context, *httptest.ResponseRecorder) {
t.Helper()
account := &Account{ID: 31, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
var requests []*http.Request
upstream := &systemOneHTTPUpstream{do: func(req *http.Request) (*http.Response, error) {
requests = append(requests, req)
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responseBody))}, nil
}}
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: upstream,
cfg: &config.Config{},
}
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/31/test", nil)
return svc, repo, &requests, c, rec
}
func TestTypeSafeAccountTestSendsNativeSystemOneRequest(t *testing.T) {
svc, repo, requests, c, rec := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"model":"jev-1.13.0","answers":{"connection_test":{"type":"noul"}},"usage":{"input_tokens":9}}`)
// The Claude default model must be ignored: TypeSafe only serves jev-latest.
require.NoError(t, svc.TestAccountConnection(c, 31, "claude-sonnet-4-5", "", AccountTestModeDefault))
require.Len(t, *requests, 1)
req := (*requests)[0]
require.Equal(t, typesafe.DefaultBaseURL+typesafe.SystemOnePath, req.URL.String())
require.Equal(t, "Bearer ts-secret", req.Header.Get("Authorization"))
require.Empty(t, req.Header.Get("x-api-key"))
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
model, err := typesafe.ValidateSystemOneRequest(body)
require.NoError(t, err)
require.Equal(t, typesafe.JevLatestModel, model)
output := rec.Body.String()
require.Contains(t, output, `"type":"test_start"`)
require.Contains(t, output, `"model":"jev-latest"`)
require.Contains(t, output, `"type":"test_complete"`)
require.Contains(t, output, "jev-1.13.0")
require.Zero(t, repo.errorCalls)
}
func TestTypeSafeAccountTestMarksRejectedKey(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
t.Run(http.StatusText(status), func(t *testing.T) {
svc, repo, _, c, rec := newTypeSafeAccountTestFixture(t, status, `{"detail":"invalid key"}`)
require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault))
require.Equal(t, 1, repo.errorCalls)
responseText, errMsg := parseTestSSEOutput(rec.Body.String())
require.Empty(t, responseText)
require.Contains(t, errMsg, "API returned")
})
}
}
func TestTypeSafeAccountTestTransientFailureKeepsAccountState(t *testing.T) {
svc, repo, _, c, _ := newTypeSafeAccountTestFixture(t, http.StatusServiceUnavailable, `{"detail":"busy"}`)
require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault))
require.Zero(t, repo.errorCalls)
}
func TestTypeSafeAccountTestUsesPromptAsState(t *testing.T) {
svc, _, requests, c, _ := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"answers":{}}`)
require.NoError(t, svc.TestAccountConnection(c, 31, "", "custom state", AccountTestModeDefault))
body, err := io.ReadAll((*requests)[0].Body)
require.NoError(t, err)
var payload struct {
State string `json:"state"`
}
require.NoError(t, json.Unmarshal(body, &payload))
require.Equal(t, "custom state", payload.State)
}
@@ -411,6 +411,9 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat
// Grok media eligibility helpers live in account_grok_media_eligibility.go.
func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
if input.Platform == PlatformTypeSafe && input.Type != AccountTypeAPIKey {
return nil, errors.New("typesafe accounts only support apikey credentials")
}
// Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey)
@@ -576,6 +579,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if err != nil {
return nil, err
}
if account.Platform == PlatformTypeSafe && input.Type != "" && input.Type != AccountTypeAPIKey {
return nil, errors.New("typesafe accounts only support apikey credentials")
}
var normalizedExtra map[string]any
if input.Extra != nil {
normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input)
+6
View File
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
@@ -296,6 +297,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return xai.DefaultModelIDs()
case PlatformOpenCodeGo:
return DefaultOpenCodeGoModelIDs()
case PlatformTypeSafe:
return []string{typesafe.JevLatestModel}
case PlatformComposite:
return compositeDefaultModelsListCandidateIDs()
default:
@@ -316,6 +319,9 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
// TypeSafe stays out of the static composite candidates (jev-latest only works
// through /v1/systemone); groups with TypeSafe accounts still get it from the
// account model mappings collected by GetGroupModelsListCandidates.
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
@@ -194,7 +194,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
// 处理错误响应
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
contentType := resp.Header.Get("Content-Type")
// 尽早关闭原始响应体,释放连接;后续逻辑仍可能需要读取 body,因此用内存副本重新包装。
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBody))
@@ -301,7 +300,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
Header: retryResp.Header.Clone(),
Body: io.NopCloser(bytes.NewReader(retryRespBody)),
}
contentType = resp.Header.Get("Content-Type")
}
} else {
if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok {
@@ -393,9 +391,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
})
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps}
}
if contentType == "" {
contentType = "application/json"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
@@ -410,7 +405,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
})
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500))
MarkResponseCommitted(c)
c.Data(resp.StatusCode, contentType, unwrappedForOps)
// 原始上游错误体仅保留在 ops 日志中;返回客户端的错误体需脱敏,避免泄露账号池身份(项目号/服务账号等)
c.Data(resp.StatusCode, "application/json", buildAntigravityClientErrorBody(resp.StatusCode, unwrappedForOps))
return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode)
}
@@ -0,0 +1,97 @@
package service
import (
"encoding/json"
"net/http"
"regexp"
"strings"
)
var (
antigravityProjectRefRegex = regexp.MustCompile(`(?i)\bprojects/[a-z0-9][a-z0-9._:-]*`)
antigravityEmailRegex = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
antigravityConsumerRegex = regexp.MustCompile(`(?i)\b(consumer|project(?:[ _-]?(?:id|number))?)(\s*[:=]?\s*['"]?)[0-9]{6,}`)
)
// sanitizeAntigravityErrorText 清除上游错误文本中的 GCP 项目号/项目 ID、服务账号邮箱及敏感查询参数。
func sanitizeAntigravityErrorText(msg string) string {
if msg == "" {
return msg
}
msg = sanitizeUpstreamErrorMessage(msg)
msg = antigravityProjectRefRegex.ReplaceAllString(msg, "projects/***")
msg = antigravityEmailRegex.ReplaceAllString(msg, "***")
msg = antigravityConsumerRegex.ReplaceAllString(msg, "$1$2***")
return msg
}
// buildAntigravityClientErrorBody 为客户端构造 Gemini 风格的错误体:
// 仅保留 code/status/message(message 已脱敏),丢弃 details 等可能含账号身份的字段。
func buildAntigravityClientErrorBody(statusCode int, body []byte) []byte {
code := statusCode
status := ""
message := ""
var parsed struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil {
if parsed.Error.Code != 0 {
code = parsed.Error.Code
}
status = parsed.Error.Status
message = parsed.Error.Message
}
if strings.TrimSpace(message) == "" {
message = strings.TrimSpace(extractUpstreamErrorMessage(body))
}
if strings.TrimSpace(message) == "" {
message = http.StatusText(statusCode)
if message == "" {
message = "Upstream request failed"
}
}
if status == "" {
status = antigravityGeminiStatusFromHTTP(statusCode)
}
out, err := json.Marshal(map[string]any{
"error": map[string]any{
"code": code,
"message": sanitizeAntigravityErrorText(message),
"status": status,
},
})
if err != nil {
return []byte(`{"error":{"code":500,"message":"Upstream request failed","status":"INTERNAL"}}`)
}
return out
}
func antigravityGeminiStatusFromHTTP(statusCode int) string {
switch statusCode {
case http.StatusBadRequest:
return "INVALID_ARGUMENT"
case http.StatusUnauthorized:
return "UNAUTHENTICATED"
case http.StatusForbidden:
return "PERMISSION_DENIED"
case http.StatusNotFound:
return "NOT_FOUND"
case http.StatusTooManyRequests:
return "RESOURCE_EXHAUSTED"
case http.StatusServiceUnavailable:
return "UNAVAILABLE"
case http.StatusGatewayTimeout:
return "DEADLINE_EXCEEDED"
default:
if statusCode >= 500 {
return "INTERNAL"
}
return "UNKNOWN"
}
}
@@ -0,0 +1,50 @@
//go:build unit
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestBuildAntigravityClientErrorBody_ScrubsPoolIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := []byte(`{"error":{"code":403,"message":"Permission denied on resource project projects/123456789 for consumer: projects/123456789; caller pool-sa@my-gcp-proj.iam.gserviceaccount.com","status":"PERMISSION_DENIED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"consumer":"projects/123456789","service":"cloudcode-pa.googleapis.com"}}]}}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Data(http.StatusForbidden, "application/json", buildAntigravityClientErrorBody(http.StatusForbidden, upstream))
require.Equal(t, http.StatusForbidden, rec.Code)
out := rec.Body.String()
require.NotContains(t, out, "123456789")
require.NotContains(t, out, "pool-sa@")
require.NotContains(t, out, "gserviceaccount.com")
require.NotContains(t, out, "details")
var parsed struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parsed))
require.Equal(t, 403, parsed.Error.Code)
require.Equal(t, "PERMISSION_DENIED", parsed.Error.Status)
require.True(t, strings.Contains(parsed.Error.Message, "Permission denied"))
}
func TestBuildAntigravityClientErrorBody_NonJSONBody(t *testing.T) {
out := string(buildAntigravityClientErrorBody(http.StatusTooManyRequests, []byte("quota exceeded for consumer 987654321 sa@x.iam.gserviceaccount.com")))
require.NotContains(t, out, "987654321")
require.NotContains(t, out, "gserviceaccount")
require.Contains(t, out, `"status":"RESOURCE_EXHAUSTED"`)
require.Contains(t, out, `"code":429`)
}
@@ -1165,3 +1165,19 @@ func cloneEmailBindUser(user *service.User) *service.User {
cloned := *user
return &cloned
}
func (s *emailBindCacheStub) IncrVerificationCodeAttempts(context.Context, string) (int, error) {
if s.data == nil {
return 0, errors.New("verification code not found")
}
s.data.Attempts++
return s.data.Attempts, nil
}
func (s *emailBindCacheStub) IncrNotifyVerifyCodeAttempts(context.Context, string) (int, error) {
return 0, errors.New("notify verification code not found")
}
func (s *emailBindCacheStub) ConsumePasswordResetToken(context.Context, string, string) (bool, error) {
return false, nil
}
@@ -1010,3 +1010,19 @@ func TestCanBypassRegistrationDisabledForOAuth(t *testing.T) {
})
}
}
func (s *emailCacheStub) IncrVerificationCodeAttempts(context.Context, string) (int, error) {
if s.data == nil {
return 0, errors.New("verification code not found")
}
s.data.Attempts++
return s.data.Attempts, nil
}
func (s *emailCacheStub) IncrNotifyVerifyCodeAttempts(context.Context, string) (int, error) {
return 0, errors.New("notify verification code not found")
}
func (s *emailCacheStub) ConsumePasswordResetToken(context.Context, string, string) (bool, error) {
return false, nil
}
@@ -698,6 +698,12 @@ func (s *BillingService) initFallbackPricing() {
SupportsCacheBreakdown: false,
}
// TypeSafe Jev bills input tokens only: $0.042 per million tokens.
s.fallbackPrices["jev-latest"] = &ModelPricing{
InputPricePerToken: 0.042 / 1_000_000,
OutputPricePerToken: 0,
}
// ---- 智谱 GLM(Z.AI)----
// Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens)
// 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。
@@ -994,6 +1000,9 @@ func (s *BillingService) initFallbackPricing() {
// getFallbackPricing 根据模型系列获取回退价格
func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
modelLower := strings.ToLower(model)
if modelLower == "jev-latest" {
return s.fallbackPrices["jev-latest"]
}
// 按模型系列匹配
if isClaudeFable51Model(modelLower) {
@@ -130,6 +130,20 @@ func TestGetModelPricing_CaseInsensitive(t *testing.T) {
require.Equal(t, p1.InputPricePerToken, p2.InputPricePerToken)
}
func TestGetModelPricing_JevLatestInputOnlyAndChannelOverride(t *testing.T) {
svc := newTestBillingService()
pricing, err := svc.GetModelPricing("jev-latest")
require.NoError(t, err)
require.InDelta(t, 0.042/1_000_000, pricing.InputPricePerToken, 1e-15)
require.Zero(t, pricing.OutputPricePerToken)
input, output := 0.25/1_000_000, 0.5/1_000_000
pricing, err = svc.GetModelPricingWithChannel("jev-latest", &ChannelModelPricing{InputPrice: &input, OutputPrice: &output})
require.NoError(t, err)
require.Equal(t, input, pricing.InputPricePerToken)
require.Equal(t, output, pricing.OutputPricePerToken)
}
// issue #3394: fallback warn 应按模型名去重,每个模型每进程最多打一条,
// 避免热路径每请求刷屏 ops_system_logs。
func TestGetModelPricing_FallbackWarnLoggedOncePerModel(t *testing.T) {
+1 -1
View File
@@ -368,7 +368,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
// fallback used before a request target has been resolved.
func matchingPlatforms(groupPlatform string) []string {
if groupPlatform == PlatformComposite {
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}
}
return []string{groupPlatform}
}
@@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) {
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}},
}
for _, tt := range tests {
@@ -114,6 +114,8 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformDeepseek, true
case "minimax":
return PlatformMiniMax, true
case "typesafe", "jev":
return PlatformTypeSafe, true
}
if rest != "" {
normalized = strings.TrimPrefix(rest, "models/")
@@ -155,6 +157,8 @@ func DetectModelPlatform(model string) (string, bool) {
strings.HasPrefix(normalized, "abab6"),
strings.HasPrefix(normalized, "abab7"):
return PlatformMiniMax, true
case normalized == "jev-latest" || strings.HasPrefix(normalized, "jev-"):
return PlatformTypeSafe, true
default:
return "", false
}
@@ -202,7 +206,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou
func isConcreteRequestPlatform(platform string) bool {
switch platform {
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe:
return true
default:
return false
@@ -180,6 +180,8 @@ func TestDetectModelPlatform(t *testing.T) {
{name: "minimax prefix", model: "minimax/MiniMax-M2.5", platform: PlatformMiniMax, ok: true},
{name: "abab legacy", model: "abab6.5-chat", platform: PlatformMiniMax, ok: true},
{name: "abab7 legacy", model: "abab7-chat-preview", platform: PlatformMiniMax, ok: true},
{name: "jev", model: "jev-latest", platform: PlatformTypeSafe, ok: true},
{name: "typesafe prefix", model: "typesafe/jev-latest", platform: PlatformTypeSafe, ok: true},
{name: "abab unrelated namespace", model: "abab-other", ok: false},
{name: "unknown k3 alias", model: "k3-preview", ok: false},
{name: "unknown", model: "llama-4-maverick", ok: false},
@@ -216,13 +218,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
platforms = append(platforms, platform)
}
require.ElementsMatch(t,
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo},
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe},
platforms,
)
}
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} {
require.True(t, isConcreteRequestPlatform(platform))
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
}
@@ -56,6 +56,7 @@ const (
ContentModerationProtocolOpenAIChat = "openai_chat_completions"
ContentModerationProtocolGemini = "gemini"
ContentModerationProtocolOpenAIImages = "openai_images"
ContentModerationProtocolTypeSafeSystemOne = "typesafe_systemone"
defaultContentModerationBaseURL = "https://api.openai.com"
defaultContentModerationModel = "omni-moderation-latest"
@@ -46,6 +46,10 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders
case ContentModerationProtocolOpenAIImages:
collector.addModerationText(&parts, gjson.GetBytes(body, "prompt").String())
collector.collectContentValue(gjson.GetBytes(body, "images"), &parts, &images)
case ContentModerationProtocolTypeSafeSystemOne:
// System One carries no client-harness reminder blocks, so a literal
// <system-reminder> is ordinary user text and must never be skipped.
moderationTextCollector{}.collectSystemOneInput(body, &parts)
default:
collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images)
@@ -61,6 +65,67 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders
return out
}
// collectSystemOneInput moderates every client-controlled text of a System One
// request: question IDs, every question field except the validated type,
// unknown top-level extension fields, and the evaluated state. Object keys are
// sent to Jev as part of the JSON, so they are moderated like values.
func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) {
root := gjson.ParseBytes(body)
questions := root.Get("questions")
if !questions.IsObject() {
collector.collectSystemOneText(questions, parts)
}
questions.ForEach(func(id, question gjson.Result) bool {
collector.addModerationText(parts, id.String())
if !question.IsObject() {
collector.collectSystemOneText(question, parts)
return true
}
question.ForEach(func(field, value gjson.Result) bool {
switch field.String() {
case "type":
return true
case "instructions", "criteria":
default:
collector.addModerationText(parts, field.String())
}
collector.collectSystemOneText(value, parts)
return true
})
return true
})
root.ForEach(func(field, value gjson.Result) bool {
switch field.String() {
case "model", "stream", "state", "questions":
return true
}
collector.addModerationText(parts, field.String())
collector.collectSystemOneText(value, parts)
return true
})
collector.collectSystemOneText(root.Get("state"), parts)
}
func (collector moderationTextCollector) collectSystemOneText(value gjson.Result, parts *[]string) {
switch {
case !value.Exists():
return
case value.Type == gjson.String:
collector.addModerationText(parts, value.String())
case value.IsArray():
value.ForEach(func(_, child gjson.Result) bool {
collector.collectSystemOneText(child, parts)
return true
})
case value.IsObject():
value.ForEach(func(key, child gjson.Result) bool {
collector.addModerationText(parts, key.String())
collector.collectSystemOneText(child, parts)
return true
})
}
}
func (collector moderationTextCollector) collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) {
if !messages.IsArray() {
return
@@ -0,0 +1,34 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"string", `{"state":"plain text"}`, "plain text"},
{"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "title hello nested body world"},
{"array", `{"state":["first",{"text":"second"},3]}`, "first text second"},
{"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"},"ext":"extra"}},"top":{"k":"v"}}`, "c pick one label a description desc a label b s low text high n judge true yes ext extra top k v state text"},
{"key only payload", `{"state":{"hidden state key":1},"questions":{"hidden question id":{"type":"noul"}}}`, "hidden question id hidden state key"},
} {
t.Run(tc.name, func(t *testing.T) {
input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body))
require.Equal(t, tc.want, input.Text)
require.Empty(t, input.Images)
})
}
}
func TestExtractContentModerationInputTypeSafeSystemOneKeepsReminderText(t *testing.T) {
body := `{"state":"<system-reminder>hidden payload</system-reminder>","questions":{"q":{"type":"noul","instructions":"<system-reminder>hidden instructions</system-reminder>"}}}`
input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(body))
require.Contains(t, input.Text, "hidden payload")
require.Contains(t, input.Text, "hidden instructions")
}
@@ -48,6 +48,7 @@ const (
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformTypeSafe = domain.PlatformTypeSafe
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
PlatformComposite = domain.PlatformComposite
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
@@ -135,6 +136,7 @@ var AllowedQuotaPlatforms = []string{
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
PlatformTypeSafe,
}
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
+67 -50
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
@@ -37,16 +38,23 @@ type EmailCache interface {
GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error)
SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
DeleteVerificationCode(ctx context.Context, email string) error
// IncrVerificationCodeAttempts atomically increments the attempt counter of an
// existing code and returns the new count. Returns an error if the code is missing.
IncrVerificationCodeAttempts(ctx context.Context, email string) (int, error)
// Notify email verification code methods
GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error)
SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
DeleteNotifyVerifyCode(ctx context.Context, email string) error
IncrNotifyVerifyCodeAttempts(ctx context.Context, email string) (int, error)
// Password reset token methods
GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error)
SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error
DeletePasswordResetToken(ctx context.Context, email string) error
// ConsumePasswordResetToken atomically compares the stored token hash with
// tokenHash and deletes it on match. Only one concurrent caller can succeed.
ConsumePasswordResetToken(ctx context.Context, email, tokenHash string) (bool, error)
// Password reset email cooldown methods
// Returns true if in cooldown period (email was sent recently)
@@ -68,6 +76,7 @@ type VerificationCodeData struct {
// PasswordResetTokenData represents password reset token data
type PasswordResetTokenData struct {
// Token holds the hex-encoded SHA-256 hash of the reset token (never the plaintext).
Token string
CreatedAt time.Time
}
@@ -376,35 +385,49 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin
// VerifyCode 验证验证码
func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error {
data, err := s.cache.GetVerificationCode(ctx, email)
return verifyCodeWithAttempts(ctx, email, code,
s.cache.GetVerificationCode, s.cache.IncrVerificationCodeAttempts,
func() {
if err := s.cache.DeleteVerificationCode(ctx, email); err != nil {
slog.Error("failed to delete verification code after success", "email", email, "error", err)
}
})
}
// verifyCodeWithAttempts checks a verification code while enforcing the attempt cap
// atomically: each check first reserves an attempt via an atomic increment, so
// concurrent guesses can never evaluate more than maxVerifyCodeAttempts codes.
func verifyCodeWithAttempts(
ctx context.Context, email, code string,
get func(context.Context, string) (*VerificationCodeData, error),
incr func(context.Context, string) (int, error),
onSuccess func(),
) error {
data, err := get(ctx, email)
if err != nil || data == nil {
return ErrInvalidVerifyCode
}
// 检查是否已达到最大尝试次数
if data.Attempts >= maxVerifyCodeAttempts {
return ErrVerifyCodeMaxAttempts
}
attempts, err := incr(ctx, email)
if err != nil {
return ErrInvalidVerifyCode
}
if attempts > maxVerifyCodeAttempts {
return ErrVerifyCodeMaxAttempts
}
// 验证码不匹配 (constant-time comparison to prevent timing attacks)
// constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
data.Attempts++
remaining := time.Until(data.ExpiresAt)
if remaining <= 0 {
return ErrInvalidVerifyCode
}
if err := s.cache.SetVerificationCode(ctx, email, data, remaining); err != nil {
slog.Error("failed to update verification attempt count", "email", email, "error", err)
}
if data.Attempts >= maxVerifyCodeAttempts {
if attempts >= maxVerifyCodeAttempts {
return ErrVerifyCodeMaxAttempts
}
return ErrInvalidVerifyCode
}
// 验证成功,删除验证码
if err := s.cache.DeleteVerificationCode(ctx, email); err != nil {
slog.Error("failed to delete verification code after success", "email", email, "error", err)
if onSuccess != nil {
onSuccess()
}
return nil
}
@@ -480,33 +503,18 @@ func (s *EmailService) GeneratePasswordResetToken() (string, error) {
// SendPasswordResetEmail sends a password reset email with a reset link
func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string, locale ...string) error {
var token string
var needSaveToken bool
// Check if token already exists
existing, err := s.cache.GetPasswordResetToken(ctx, email)
if err == nil && existing != nil {
// Token exists, reuse it (allows resending email without generating new token)
token = existing.Token
needSaveToken = false
} else {
// Generate new token
token, err = s.GeneratePasswordResetToken()
if err != nil {
return fmt.Errorf("generate token: %w", err)
}
needSaveToken = true
// Only the SHA-256 hash of the token is stored, so an existing token cannot be
// re-sent; always issue a fresh token (the email cooldown bounds resend frequency).
token, err := s.GeneratePasswordResetToken()
if err != nil {
return fmt.Errorf("generate token: %w", err)
}
// Save token to Redis (only if new token generated)
if needSaveToken {
data := &PasswordResetTokenData{
Token: token,
CreatedAt: time.Now(),
}
if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil {
return fmt.Errorf("save reset token: %w", err)
}
data := &PasswordResetTokenData{
Token: hashPasswordResetToken(token),
CreatedAt: time.Now(),
}
if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil {
return fmt.Errorf("save reset token: %w", err)
}
// Build full reset URL with URL-encoded token and email
@@ -566,31 +574,40 @@ func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, e
return nil
}
// hashPasswordResetToken returns the hex-encoded SHA-256 of a reset token.
func hashPasswordResetToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// VerifyPasswordResetToken verifies the password reset token without consuming it
func (s *EmailService) VerifyPasswordResetToken(ctx context.Context, email, token string) error {
data, err := s.cache.GetPasswordResetToken(ctx, email)
if err != nil || data == nil {
if err != nil || data == nil || token == "" {
return ErrInvalidResetToken
}
// Use constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(data.Token), []byte(token)) != 1 {
if subtle.ConstantTimeCompare([]byte(data.Token), []byte(hashPasswordResetToken(token))) != 1 {
return ErrInvalidResetToken
}
return nil
}
// ConsumePasswordResetToken verifies and deletes the token (one-time use)
// ConsumePasswordResetToken verifies and deletes the token atomically (one-time use).
func (s *EmailService) ConsumePasswordResetToken(ctx context.Context, email, token string) error {
// Verify first
// Constant-time pre-check in Go; the atomic compare-and-delete below is authoritative.
if err := s.VerifyPasswordResetToken(ctx, email, token); err != nil {
return err
}
// Delete after verification (one-time use)
if err := s.cache.DeletePasswordResetToken(ctx, email); err != nil {
slog.Error("failed to delete password reset token after consumption", "email", email, "error", err)
ok, err := s.cache.ConsumePasswordResetToken(ctx, email, hashPasswordResetToken(token))
if err != nil {
slog.Error("failed to consume password reset token", "email", email, "error", err)
return ErrInvalidResetToken
}
if !ok {
return ErrInvalidResetToken
}
return nil
}
@@ -0,0 +1,50 @@
//go:build unit
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
)
type resetTokenCacheStub struct {
emailCacheStub
stored *PasswordResetTokenData
consumedHash string
}
func (s *resetTokenCacheStub) GetPasswordResetToken(context.Context, string) (*PasswordResetTokenData, error) {
return s.stored, nil
}
func (s *resetTokenCacheStub) ConsumePasswordResetToken(_ context.Context, _ string, tokenHash string) (bool, error) {
s.consumedHash = tokenHash
if s.stored == nil || s.stored.Token != tokenHash {
return false, nil
}
s.stored = nil
return true, nil
}
func TestConsumePasswordResetToken_ComparesHashNotPlaintext(t *testing.T) {
token := "deadbeef"
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
require.Equal(t, hash, hashPasswordResetToken(token))
require.NotEqual(t, token, hashPasswordResetToken(token))
cache := &resetTokenCacheStub{stored: &PasswordResetTokenData{Token: hash}}
svc := NewEmailService(nil, cache)
require.NoError(t, svc.ConsumePasswordResetToken(context.Background(), "a@b.c", token))
require.Equal(t, hash, cache.consumedHash)
require.ErrorIs(t, svc.ConsumePasswordResetToken(context.Background(), "a@b.c", token), ErrInvalidResetToken)
// A legacy plaintext value (issued before upgrade) no longer validates.
cache.stored = &PasswordResetTokenData{Token: token}
require.ErrorIs(t, svc.ConsumePasswordResetToken(context.Background(), "a@b.c", token), ErrInvalidResetToken)
}
@@ -0,0 +1,182 @@
package service
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
)
type SystemOneForwardResult struct {
ForwardResult
StatusCode int
Body []byte
ContentType string
}
type SystemOneUpstreamError struct {
StatusCode int
}
const TypeSafeCredentialRejectedReason GatewayFailureReason = "typesafe_api_key_rejected"
func (e *SystemOneUpstreamError) Error() string {
return fmt.Sprintf("typesafe upstream rejected request with status %d", e.StatusCode)
}
func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, account *Account, body []byte) (*SystemOneForwardResult, error) {
started := time.Now()
if account == nil || !account.IsTypeSafe() || account.Type != AccountTypeAPIKey {
return nil, errors.New("invalid typesafe account")
}
key := account.GetTypeSafeAPIKey()
if key == "" {
return nil, errors.New("typesafe api key is missing")
}
baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL())
if err != nil {
return nil, err
}
req, err := typesafe.NewSystemOneRequest(ctx, baseURL, key, body)
if err != nil {
return nil, err
}
upstreamURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return nil, s.handleUpstreamTransportError(ctx, c, account, err, OpsUpstreamErrorEvent{
Passthrough: true,
UpstreamURL: upstreamURL,
})
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, s.handleSystemOneErrorResponse(ctx, c, account, resp, upstreamURL)
}
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
// The upstream accepted (and may have charged) this request but the
// gateway cannot relay it; keep an ops trail for reconciliation.
setOpsUpstreamError(c, resp.StatusCode, err.Error(), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "response_error",
Message: err.Error(),
})
return nil, err
}
return &SystemOneForwardResult{
ForwardResult: ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header.Clone(),
Usage: ClaudeUsage{InputTokens: decoded.Usage.InputTokens, OutputTokens: decoded.Usage.OutputTokens},
Model: typesafe.JevLatestModel,
UpstreamResponseModel: decoded.Model,
Duration: time.Since(started),
},
StatusCode: resp.StatusCode,
Body: decoded.Body,
ContentType: systemOneResponseContentType(resp.Header.Get("Content-Type")),
}, nil
}
// IsSystemOneRequestErrorStatus reports upstream statuses that describe the
// caller's own payload (malformed, unprocessable, or too large).
func IsSystemOneRequestErrorStatus(status int) bool {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity:
return true
default:
return false
}
}
// handleSystemOneErrorResponse applies the shared account error policy to a
// non-2xx System One response. 400/413/422 describe the caller's own payload, so
// they never touch account state (a tenant must not be able to disable an
// account with bad input) and are not retried elsewhere. Every other status
// goes through the account error policy (custom error codes, temporary
// unschedulable rules, pool mode) and fails over when the status is retryable
// or the policy took the account out of rotation.
func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, upstreamURL string) error {
respBody, _ := s.readUpstreamErrorBody(resp)
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
event := OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "http_error",
Message: upstreamMsg,
}
if IsSystemOneRequestErrorStatus(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
shouldDisable := false
if s.rateLimitService != nil {
shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, typesafe.JevLatestModel)
}
if !shouldDisable && !s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
event.Kind = "failover"
appendOpsUpstreamError(c, event)
failoverErr := &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
ResponseHeaders: resp.Header.Clone(),
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
if resp.StatusCode == http.StatusUnauthorized {
failoverErr.Stage = GatewayFailureStageAccountAuth
failoverErr.Scope = GatewayFailureScopeAccount
failoverErr.Reason = TypeSafeCredentialRejectedReason
failoverErr.NextAccountAction = NextAccountRetry
}
return failoverErr
}
// systemOneResponseContentType keeps the upstream JSON media type (and its
// charset) but never relays a non-JSON type for a body already validated as
// JSON, so the gateway origin cannot be made to serve it as HTML.
func systemOneResponseContentType(raw string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(raw))
if err != nil {
return "application/json"
}
if mediaType == "application/json" || (strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json")) {
return strings.TrimSpace(raw)
}
return "application/json"
}
@@ -0,0 +1,402 @@
package service
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type systemOneHTTPUpstream struct {
do func(*http.Request) (*http.Response, error)
}
type systemOnePolicyAccountRepo struct {
AccountRepository
account *Account
rateLimitedCalls int
overloadedCalls int
errorCalls int
}
func (r *systemOnePolicyAccountRepo) GetByID(context.Context, int64) (*Account, error) {
return r.account, nil
}
func (r *systemOnePolicyAccountRepo) SetRateLimited(context.Context, int64, time.Time) error {
r.rateLimitedCalls++
return nil
}
func (r *systemOnePolicyAccountRepo) SetOverloaded(context.Context, int64, time.Time) error {
r.overloadedCalls++
return nil
}
func (r *systemOnePolicyAccountRepo) SetError(context.Context, int64, string) error {
r.errorCalls++
return nil
}
func (u *systemOneHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return u.do(req)
}
func (u *systemOneHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.do(req)
}
func newSystemOneTestService(upstream HTTPUpstream) *GatewayService {
return &GatewayService{
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{AllowInsecureHTTP: true}}},
httpUpstream: upstream,
}
}
func newSystemOneTestContext() *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/systemone", nil)
return c
}
func TestForwardSystemOneForwardsNativeProtocolAndUsage(t *testing.T) {
requestBody := []byte(`{"model":"jev-latest","state":{"text":"sample"},"questions":{"q":{"type":"choice","instructions":"Pick","criteria":{"a":"A","b":"B"}}}}`)
responseBody := []byte(`{"model":"jev-1.13.0","answers":{"q":{"type":"choice","choice":"a"}},"usage":{"input_tokens":123,"output_tokens":7},"provider_extension":{"kept":true}}`)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, typesafeSystemOnePathForTest, r.URL.Path)
require.Equal(t, "Bearer ts-secret", r.Header.Get("Authorization"))
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Equal(t, requestBody, got)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("x-request-id", "req-jev")
_, err = w.Write(responseBody)
require.NoError(t, err)
}))
defer server.Close()
svc := newSystemOneTestService(&systemOneHTTPUpstream{do: server.Client().Do})
account := &Account{ID: 7, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": server.URL, "api_key": "ts-secret"}}
result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, requestBody)
require.NoError(t, err)
require.Equal(t, responseBody, result.Body)
require.Equal(t, http.StatusOK, result.StatusCode)
require.Equal(t, "application/json; charset=utf-8", result.ContentType)
require.Equal(t, "req-jev", result.RequestID)
require.Equal(t, "jev-latest", result.Model)
require.Equal(t, "jev-1.13.0", result.UpstreamResponseModel)
require.Equal(t, 123, result.Usage.InputTokens)
require.Equal(t, 7, result.Usage.OutputTokens)
}
func TestForwardSystemOneAllowsSuccessfulResponseWithoutModel(t *testing.T) {
responseBody := []byte(`{"answers":{"q":{"type":"noul","answer":"ok"}},"usage":{"input_tokens":12}}`)
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(responseBody)))}, nil
}}
svc := newSystemOneTestService(upstream)
account := &Account{ID: 11, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, responseBody, result.Body)
require.Empty(t, result.UpstreamResponseModel)
require.Equal(t, 12, result.Usage.InputTokens)
}
const typesafeSystemOnePathForTest = "/v1/systemone"
func TestForwardSystemOneSchemaConformancePassthrough(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"omitted noul instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}}}`},
{"nullable noul fields", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`},
{"structured noul descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"reason":"Yes"},"false":["No",null]}}}}`},
{"object choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{"a":{"description":"A","extra":null}}}}}`},
{"array choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":["A",null],"b":null}}}}`},
{"empty choice criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`},
{"one-level score", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`},
{"object score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`},
{"array score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`},
} {
t.Run(tc.name, func(t *testing.T) {
requestBody := []byte(tc.body)
_, err := typesafe.ValidateSystemOneRequest(requestBody)
require.NoError(t, err)
responseBody := []byte(`{"answers":{},"usage":{"input_tokens":12}}`)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/systemone", r.URL.Path)
require.Equal(t, "Bearer ts-mock-key", r.Header.Get("Authorization"))
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Equal(t, requestBody, got)
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(responseBody)
require.NoError(t, err)
}))
defer server.Close()
svc := newSystemOneTestService(&systemOneHTTPUpstream{do: server.Client().Do})
account := &Account{ID: 12, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": server.URL, "api_key": "ts-mock-key"}}
result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, requestBody)
require.NoError(t, err)
require.Equal(t, responseBody, result.Body)
require.Equal(t, 12, result.Usage.InputTokens)
})
}
}
func TestForwardSystemOneErrorPolicy(t *testing.T) {
for _, status := range []int{400, 422, 401, 429, 529, 500, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("private upstream body ts-secret"))}, nil
}}
svc := newSystemOneTestService(upstream)
account := &Account{ID: 8, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{"private":"request"}`))
require.Nil(t, result)
require.Error(t, err)
require.NotContains(t, err.Error(), "private")
require.NotContains(t, err.Error(), "ts-secret")
if status == 400 || status == 422 {
var upstreamErr *SystemOneUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, status, upstreamErr.StatusCode)
return
}
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.ShouldRetryNextAccount())
if status == http.StatusUnauthorized {
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
require.Equal(t, TypeSafeCredentialRejectedReason, failoverErr.Reason)
}
})
}
}
func TestForwardSystemOneAppliesExistingAccountStatePolicy(t *testing.T) {
for _, tc := range []struct {
name string
status int
wantRateLimited int
wantOverloaded int
wantError int
}{
{name: "unauthorized", status: http.StatusUnauthorized, wantError: 1},
{name: "rate limited", status: http.StatusTooManyRequests, wantRateLimited: 1},
{name: "overloaded", status: 529, wantOverloaded: 1},
} {
t.Run(tc.name, func(t *testing.T) {
account := &Account{ID: 10, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"private"}`))}, nil
}}
svc := newSystemOneTestService(upstream)
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.Error(t, err)
require.Equal(t, tc.wantRateLimited, repo.rateLimitedCalls)
require.Equal(t, tc.wantOverloaded, repo.overloadedCalls)
require.Equal(t, tc.wantError, repo.errorCalls)
})
}
}
func TestForwardSystemOneTransportAndTimeoutFailOver(t *testing.T) {
for _, transportErr := range []error{errors.New("network unavailable"), context.DeadlineExceeded} {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { return nil, transportErr }}
svc := newSystemOneTestService(upstream)
account := &Account{ID: 9, Name: "jev", Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
}
}
func newSystemOneStatusUpstream(status int, body string) *systemOneHTTPUpstream {
return &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: http.Header{"X-Request-Id": []string{"req-err"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
}}
}
func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent {
t.Helper()
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
return events
}
func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) {
for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity} {
t.Run(http.StatusText(status), func(t *testing.T) {
// Even a custom error-code rule must not let client input disable the account.
account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{
"base_url": "http://typesafe.test", "api_key": "ts-secret",
"custom_error_codes_enabled": true, "custom_error_codes": []any{float64(status)},
}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(status, `{"detail":"bad question"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
c := newSystemOneTestContext()
_, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
var upstreamErr *SystemOneUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, status, upstreamErr.StatusCode)
require.Zero(t, repo.errorCalls+repo.rateLimitedCalls+repo.overloadedCalls)
events := systemOneOpsEvents(t, c)
require.Len(t, events, 1)
require.Equal(t, "http_error", events[0].Kind)
require.Equal(t, status, events[0].UpstreamStatusCode)
require.Equal(t, "req-err", events[0].UpstreamRequestID)
})
}
}
func TestForwardSystemOneAccountLevelFailuresFailOver(t *testing.T) {
for _, tc := range []struct {
name string
status int
wantError int
}{
{name: "payment required", status: http.StatusPaymentRequired, wantError: 1},
{name: "forbidden", status: http.StatusForbidden, wantError: 1},
} {
t.Run(tc.name, func(t *testing.T) {
account := &Account{ID: 22, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(tc.status, `{"detail":"account problem"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
c := newSystemOneTestContext()
_, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.ShouldRetryNextAccount())
require.Equal(t, tc.status, failoverErr.StatusCode)
require.Equal(t, tc.wantError, repo.errorCalls)
require.Equal(t, "failover", systemOneOpsEvents(t, c)[0].Kind)
})
}
}
func TestForwardSystemOneHonorsCustomErrorCodes(t *testing.T) {
account := &Account{ID: 23, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{
"base_url": "http://typesafe.test", "api_key": "ts-secret",
"custom_error_codes_enabled": true, "custom_error_codes": []any{float64(http.StatusNotFound)},
}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusNotFound, `{"detail":"missing"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, 1, repo.errorCalls)
}
func TestForwardSystemOneUnhandledStatusDoesNotFailOver(t *testing.T) {
account := &Account{ID: 24, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusConflict, `{"detail":"conflict"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
var upstreamErr *SystemOneUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, http.StatusConflict, upstreamErr.StatusCode)
require.Zero(t, repo.errorCalls)
}
func TestForwardSystemOneNormalizesResponseContentType(t *testing.T) {
for _, tc := range []struct{ upstream, want string }{
{"application/json; charset=utf-8", "application/json; charset=utf-8"},
{"application/problem+json", "application/problem+json"},
{"text/html; charset=utf-8", "application/json"},
{"", "application/json"},
{"not a media type;;", "application/json"},
} {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
header := make(http.Header)
if tc.upstream != "" {
header.Set("Content-Type", tc.upstream)
}
return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`{"answers":{}}`))}, nil
}}
account := &Account{ID: 25, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, tc.want, result.ContentType, tc.upstream)
}
}
func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) {
oversized := `{"pad":"` + strings.Repeat("a", typesafe.MaxSystemOneResponseBytes) + `"}`
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil
}}
account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
c := newSystemOneTestContext()
_, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge)
events := systemOneOpsEvents(t, c)
require.Len(t, events, 1)
require.Equal(t, "response_error", events[0].Kind)
require.Equal(t, http.StatusOK, events[0].UpstreamStatusCode)
}
func TestForwardSystemOneBillsLenientUsageShapes(t *testing.T) {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"answers":{},"usage":{"input_tokens":"21","output_tokens":1.0}}`))}, nil
}}
account := &Account{ID: 27, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, 21, result.Usage.InputTokens)
require.Equal(t, 1, result.Usage.OutputTokens)
}
func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) {
account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}}
require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL())
require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL())
for raw, want := range map[string]string{
"https://api.typesafe.ai/v1": "https://api.typesafe.ai",
"https://api.typesafe.ai/V1/": "https://api.typesafe.ai",
"https://proxy.example/typesafe/": "https://proxy.example/typesafe",
"https://proxy.example/apiv1": "https://proxy.example/apiv1",
" https://proxy.example/x/v1/ ": "https://proxy.example/x",
"/v1": typesafe.DefaultBaseURL,
} {
withBase := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": raw}}
require.Equal(t, want, withBase.GetTypeSafeBaseURL(), raw)
}
anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}}
require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL())
}
func TestTypeSafeModelsListCandidates(t *testing.T) {
require.Equal(t, []string{typesafe.JevLatestModel}, defaultModelsListCandidateIDs(PlatformTypeSafe))
require.NotContains(t, compositeDefaultModelsListCandidateIDs(), typesafe.JevLatestModel)
}
@@ -17,7 +17,7 @@ const (
grokClientModeHeader = xai.CLIClientMode
)
// defaultGrokUpstreamUserAgent is the pinned Grok CLI / workspace UA.
// defaultGrokUpstreamUserAgent 使用固定版本的官方交互式 CLI UA。
// Grok upstream must not forward Claude Code / Codex / browser client UAs.
func defaultGrokUpstreamUserAgent() string {
return xai.CLIUserAgent(xai.ResolveCLIVersion())
@@ -28,7 +28,7 @@ func TestApplyDefaultGrokUpstreamHeadersUsesCLIUserAgent(t *testing.T) {
}
func TestApplyDefaultGrokUpstreamHeadersHonorsCLIVersionOverride(t *testing.T) {
t.Setenv(xai.CLIVersionEnv, "0.2.95")
t.Setenv(xai.CLIVersionEnv, "1.0.14")
req, err := http.NewRequest(http.MethodGet, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
@@ -36,9 +36,9 @@ func TestApplyDefaultGrokUpstreamHeadersHonorsCLIVersionOverride(t *testing.T) {
applyDefaultGrokUpstreamHeaders(req)
require.Equal(t, "0.2.95", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("0.2.95"), req.Header.Get("User-Agent"))
require.Equal(t, "grok-shell", req.Header.Get("x-grok-client-identifier"))
require.Equal(t, "1.0.14", req.Header.Get("x-grok-client-version"))
require.Equal(t, xai.CLIUserAgent("1.0.14"), req.Header.Get("User-Agent"))
require.Equal(t, "grok-pager", req.Header.Get("x-grok-client-identifier"))
}
func TestResolveGrokUpstreamUserAgentNeverPassthrough(t *testing.T) {
@@ -1613,8 +1613,8 @@ func applyGrokCLIHeaders(headers http.Header) {
headers.Set("X-Grok-Client-Version", version)
headers.Set("x-grok-client-version", version)
headers.Set("x-grok-client-identifier", xai.CLIClientIdentifier)
// Historical mode value expected by some unit tests / older CLI probes.
headers.Set("X-Grok-Client-Mode", "interactive")
// 对齐官方 CLI 交互模式,网关请求与额度探测共用身份。
headers.Set("X-Grok-Client-Mode", xai.CLIClientMode)
}
func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot) {

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