Compare commits

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

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

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

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

Fixes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:02:50 +08:00
helianthus1 00f6f4e4a8 fix(ccswitch): stop usage query from requesting /v1/v1/usage
#7395 made Codex (and Grok) imports store a base URL ending in /v1, but the
usage script still appends a hard-coded /v1/usage, so CC Switch requests
/v1/v1/usage, gets a 404 and shows "query failed" for every newly imported
Codex/Grok provider. Move the script into ccswitchImport.ts and strip an
existing trailing /v1 from {{baseUrl}} before appending /v1/usage, which
covers both the imported value and base URLs users edit either way later.
2026-09-23 04:24:52 -07:00
wucm667 e2c35cf394 fix(account): prevent whitelist model mapping conflicts 2026-09-23 17:47:47 +08:00
github-actions[bot] a3eb7ef302 chore: sync VERSION to 0.2.8 [skip ci] 2026-09-23 09:18:46 +00:00
touhouqing 2fc91663d2 fix(apicompat): honor disabled thinking in OpenAI bridges 2026-09-23 16:48:15 +08:00
wucm667 453b796df0 fix(gateway): enforce composite model ownership in legacy scheduling 2026-09-23 15:29:11 +08:00
wucm667 e878ab710f fix(gateway): constrain account model route ownership 2026-09-23 15:29:11 +08:00
wucm667 13fcfc0f3b fix(gateway): resolve composite routes for websocket aliases 2026-09-23 15:29:11 +08:00
wucm667 0416c9ede4 fix(gateway): avoid ambiguous late-cache input subtraction 2026-09-23 15:27:53 +08:00
HuntercodeT ecba49f711 fix(gateway): let OpenAI passthrough accounts supplement model discovery instead of hiding mapped models
A single passthrough account made GetAvailableModels return nil for the whole
group, so every other account's model_mapping was dropped from /v1/models and
the Codex catalog. Treat passthrough accounts like unmapped ones: skip their
(possibly stale) mapping and let supplementUnmappedOpenAIModels add the
default set, while ordinary accounts' mappings still reach the list.

Refs #6647, #7517
2026-09-23 11:51:06 +08:00
Achord Chan 17ce21c038 test(scheduler): pass explicit multiplier pointer to snapshot 2026-09-23 11:38:23 +08:00
Achord Chan b5b5f1f94b fix(model-plaza): apply independent video billing multipliers 2026-09-23 11:28:41 +08:00
chihao.ou 1af3269adb fix(groups): clean up modal listeners and pending searches 2026-09-22 21:43:13 +08:00
Achord Chan f867e3e2ec test(antigravity): check schema properties type assertion 2026-09-22 18:34:36 +08:00
wucm667 ece1ecccca fix(gateway): normalize streamed Anthropic usage 2026-09-21 12:58:56 +08:00
wucm667 b13200d7a1 fix(gateway): forward received chat stream usage 2026-09-21 12:58:56 +08:00
aofee d1ba57977d feat: add native TypeSafe Jev System One support 2026-09-20 20:20:16 +08:00
Achord Chan ef51690b5a fix(antigravity): preserve string const constraints in tool schemas 2026-09-20 10:46:09 +08:00
408 changed files with 18805 additions and 1447 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.7
0.2.13
+4 -3
View File
@@ -288,14 +288,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
@@ -321,7 +323,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
+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)
+52
View File
@@ -89,6 +89,7 @@ type Config struct {
Pricing PricingConfig `mapstructure:"pricing"`
Gateway GatewayConfig `mapstructure:"gateway"`
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
APIKeyCreate APIKeyCreateConfig `mapstructure:"api_key_create"`
SubscriptionCache SubscriptionCacheConfig `mapstructure:"subscription_cache"`
SubscriptionMaintenance SubscriptionMaintenanceConfig `mapstructure:"subscription_maintenance"`
Dashboard DashboardCacheConfig `mapstructure:"dashboard_cache"`
@@ -921,6 +922,29 @@ type BillingConfig struct {
// UserPlatformQuotaSentinelTTLSeconds sentinel(无 limit 占位)entry 的 TTL,
// 显著短于 quota cache 默认 86400s 以控 Redis 内存;默认 3600=1h。
UserPlatformQuotaSentinelTTLSeconds int `mapstructure:"user_platform_quota_sentinel_ttl_seconds"`
// InflightReservation 余额模式在途请求预留(Redis),防止并发请求在预检时看到同一份余额而集体透支。
InflightReservation InflightReservationConfig `mapstructure:"inflight_reservation"`
}
// InflightReservationConfig 余额模式在途预留配置。
// 准入时按 输入估算 + 输出单价 × max_tokens 估算单请求费用,在 Redis 中原子地
// 校验 缓存余额 - 在途预留合计 >= 估算 后登记预留,请求结束(任意路径)释放。
// 估算失败或 Redis 不可用时 fail-open,退回旧的仅余额 > 阈值检查。
type InflightReservationConfig struct {
Enabled bool `mapstructure:"enabled"`
// TTLSeconds 单条预留的最长存活时间;进程崩溃等泄漏的预留到期自动失效。
TTLSeconds int `mapstructure:"ttl_seconds"`
// DefaultMaxTokens 请求未携带 max_tokens 时用于估算的输出 token 数。
DefaultMaxTokens int `mapstructure:"default_max_tokens"`
// MaxOutputTokens 估算输出 token 的上限(max_tokens 超出时截断)。
MaxOutputTokens int `mapstructure:"max_output_tokens"`
// MaxInputTokens 输入 token 估算(请求体字节数 / 4)的上限。
MaxInputTokens int `mapstructure:"max_input_tokens"`
// MaxReservationUSD 单请求预留金额上限;0 表示不设上限。
MaxReservationUSD float64 `mapstructure:"max_reservation_usd"`
// FailClosedOnUnpriced 无法为请求估算费用(模型/分组/渠道均无定价)时是否拒绝请求。
// 默认 false:放行且不预留(fail-open,节流告警日志)。
FailClosedOnUnpriced bool `mapstructure:"fail_closed_on_unpriced"`
}
type CircuitBreakerConfig struct {
@@ -1696,6 +1720,14 @@ type APIKeyAuthCacheConfig struct {
InvalidAbuse InvalidAuthAbuseConfig `mapstructure:"invalid_abuse"`
}
// APIKeyCreateConfig 用户创建 API Key 的防滥用限制(0 表示不限制)
type APIKeyCreateConfig struct {
// MaxActivePerUser 单个用户同时存在(未删除)的 API Key 上限
MaxActivePerUser int `mapstructure:"max_active_per_user"`
// MaxPerUserPerHour 单个用户每小时可创建的 API Key 次数(删除不返还次数)
MaxPerUserPerHour int `mapstructure:"max_per_user_per_hour"`
}
type InvalidAuthAbuseConfig struct {
Enabled bool `mapstructure:"enabled"`
Threshold int `mapstructure:"threshold"`
@@ -2093,6 +2125,13 @@ func setDefaults() {
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
viper.SetDefault("billing.inflight_reservation.enabled", true)
viper.SetDefault("billing.inflight_reservation.ttl_seconds", 900)
viper.SetDefault("billing.inflight_reservation.default_max_tokens", 8192)
viper.SetDefault("billing.inflight_reservation.max_output_tokens", 128000)
viper.SetDefault("billing.inflight_reservation.max_input_tokens", 200000)
viper.SetDefault("billing.inflight_reservation.max_reservation_usd", 0)
viper.SetDefault("billing.inflight_reservation.fail_closed_on_unpriced", false)
// Turnstile
viper.SetDefault("turnstile.required", false)
@@ -2332,6 +2371,8 @@ func setDefaults() {
viper.SetDefault("api_key_auth_cache.invalid_abuse.window_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.block_seconds", 60)
viper.SetDefault("api_key_auth_cache.invalid_abuse.capacity", 16384)
viper.SetDefault("api_key_create.max_active_per_user", 200)
viper.SetDefault("api_key_create.max_per_user_per_hour", 60)
// Subscription auth L1 cache
viper.SetDefault("subscription_cache.l1_size", 16384)
@@ -2711,6 +2752,12 @@ func (c *Config) Validate() error {
return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must be positive")
}
}
if c.APIKeyCreate.MaxActivePerUser < 0 {
return fmt.Errorf("api_key_create.max_active_per_user must be non-negative")
}
if c.APIKeyCreate.MaxPerUserPerHour < 0 {
return fmt.Errorf("api_key_create.max_per_user_per_hour must be non-negative")
}
if c.APIKeyAuth.InvalidAbuse.Enabled {
if c.APIKeyAuth.InvalidAbuse.Threshold < 10 {
return fmt.Errorf("api_key_auth_cache.invalid_abuse.threshold must be at least 10")
@@ -3062,6 +3109,11 @@ func (c *Config) Validate() error {
if c.Billing.MinimumBalanceReserve < 0 {
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
}
if c.Billing.InflightReservation.TTLSeconds < 0 || c.Billing.InflightReservation.DefaultMaxTokens < 0 ||
c.Billing.InflightReservation.MaxOutputTokens < 0 || c.Billing.InflightReservation.MaxInputTokens < 0 ||
c.Billing.InflightReservation.MaxReservationUSD < 0 {
return fmt.Errorf("billing.inflight_reservation values must be non-negative")
}
if c.Database.MaxOpenConns <= 0 {
return fmt.Errorf("database.max_open_conns must be positive")
}
+3
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"
@@ -190,6 +191,8 @@ var DefaultBedrockModelMapping = map[string]string{
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
// Claude Sonnet
// Sonnet 5.5 is available on bedrock-runtime through Global inference only.
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
+4 -3
View File
@@ -109,9 +109,10 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5",
}
for from, want := range cases {
got, ok := DefaultBedrockModelMapping[from]
@@ -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"
@@ -48,6 +49,7 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
// AccountHandler handles admin account management
type AccountHandler struct {
claudeResetCredits claudeResetReader
adminService service.AdminService
oauthService *service.OAuthService
openaiOAuthService *service.OpenAIOAuthService
@@ -2949,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)
@@ -0,0 +1,56 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"strconv"
)
type claudeResetReader interface {
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
Redeem(context.Context, int64, string) (*service.ClaudeResetOutcome, error)
}
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
h.claudeResetCredits = s
}
func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
if h.claudeResetCredits == nil {
response.Error(c, 503, "Claude reset service unavailable")
return
}
status, err := h.claudeResetCredits.Query(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, status)
}
// RedeemClaudeResetCredit consumes the upstream next reset credit. The request has
// no body: the server picks the grant; the Idempotency-Key header identifies one
// operator confirmation and replays its outcome.
func (h *AccountHandler) RedeemClaudeResetCredit(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid account ID")
return
}
if h.claudeResetCredits == nil {
response.Error(c, 503, "Claude reset service unavailable")
return
}
result, err := h.claudeResetCredits.Redeem(c.Request.Context(), id, c.GetHeader("Idempotency-Key"))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type claudeResetHandlerStub struct {
key string
calls int
}
func (s *claudeResetHandlerStub) Query(context.Context, int64) (*service.ClaudeResetCredits, error) {
return &service.ClaudeResetCredits{Credits: []service.ClaudeResetCredit{}}, nil
}
func (s *claudeResetHandlerStub) Redeem(_ context.Context, _ int64, key string) (*service.ClaudeResetOutcome, error) {
s.calls++
s.key = key
if key == "" {
return nil, service.ErrIdempotencyKeyRequired
}
return &service.ClaudeResetOutcome{Outcome: "reset", Cleared: []string{"five_hour"}}, nil
}
func TestClaudeResetHandlerRedeemContract(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &claudeResetHandlerStub{}
h := &AccountHandler{claudeResetCredits: stub}
r := gin.New()
r.POST("/accounts/:id/claude/reset-credits/redeem", h.RedeemClaudeResetCredit)
req := httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil)
req.Header.Set("Idempotency-Key", "same-operation")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "same-operation", stub.key)
require.Contains(t, w.Body.String(), `"outcome":"reset"`)
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil))
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "IDEMPOTENCY_KEY_REQUIRED")
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/abc/claude/reset-credits/redeem", nil))
require.Equal(t, http.StatusBadRequest, w.Code)
require.Equal(t, 2, stub.calls)
}
@@ -500,7 +500,11 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
limit = 12
}
trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
metric := c.DefaultQuery("metric", "tokens")
if metric != "tokens" && metric != "actual_cost" {
metric = "tokens"
}
trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit, metric)
if err != nil {
response.Error(c, 500, "Failed to get user usage trend")
return
@@ -45,6 +45,7 @@ func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend(
startTime, endTime time.Time,
granularity string,
limit int,
metric string,
) ([]usagestats.UserUsageTrendPoint, error) {
r.usersTrendCalls.Add(1)
return []usagestats.UserUsageTrendPoint{{
@@ -115,4 +116,20 @@ func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) {
require.Equal(t, http.StatusOK, rec2.Code)
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
require.Equal(t, int32(1), repo.usersTrendCalls.Load())
for _, tc := range []struct {
metric, cache string
calls int32
}{
{"actual_cost", "miss", 2},
{"actual_cost", "hit", 2},
{"tokens", "hit", 2},
} {
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8&metric="+tc.metric, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, tc.cache, rec.Header().Get("X-Snapshot-Cache"))
require.Equal(t, tc.calls, repo.usersTrendCalls.Load())
}
}
@@ -53,6 +53,7 @@ type dashboardEntityTrendCacheKey struct {
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
Limit int `json:"limit"`
Metric string `json:"metric,omitempty"`
}
func cacheStatusValue(hit bool) string {
@@ -213,15 +214,16 @@ func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startT
return trend, hit, err
}
func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) {
func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
Limit: limit,
Metric: metric,
})
entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric)
})
if err != nil {
return nil, hit, err
@@ -243,7 +243,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
}
if includeUsersTrend {
usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit)
usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit, "tokens")
if err != nil {
return nil, errors.New("failed to get user usage trend")
}
@@ -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,
@@ -265,6 +265,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled,
CyberPolicyUserAllowlist: settings.CyberPolicyUserAllowlist,
CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
@@ -361,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,
@@ -603,6 +603,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RiskControlEnabled != after.RiskControlEnabled {
changed = append(changed, "risk_control_enabled")
}
if before.CyberPolicyUserAllowlist != after.CyberPolicyUserAllowlist {
changed = append(changed, "cyber_policy_user_allowlist")
}
if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled {
changed = append(changed, "cyber_session_block_enabled")
}
@@ -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"`
@@ -364,8 +368,9 @@ type UpdateSettingsRequest struct {
RiskControlEnabled *bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"`
CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"`
CyberPolicyUserAllowlist *string `json:"cyber_policy_user_allowlist"`
CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"`
// OpenAI fast/flex policy (optional, only updated when provided)
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
@@ -1503,6 +1508,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
if req.CyberPolicyUserAllowlist != nil {
if _, err := service.ParseCyberPolicyUserAllowlist(*req.CyberPolicyUserAllowlist); err != nil {
response.BadRequest(c, err.Error())
return
}
}
// cyber 会话屏蔽 TTL 校验:提供时必须 > 0
if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 {
response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0")
@@ -2008,6 +2020,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RiskControlEnabled
}(),
CyberPolicyUserAllowlist: func() string {
if req.CyberPolicyUserAllowlist != nil {
return *req.CyberPolicyUserAllowlist
}
return previousSettings.CyberPolicyUserAllowlist
}(),
CyberSessionBlockEnabled: func() bool {
if req.CyberSessionBlockEnabled != nil {
return *req.CyberSessionBlockEnabled
@@ -2114,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,
@@ -2393,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,
@@ -2429,6 +2453,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
RiskControlEnabled: updatedSettings.RiskControlEnabled,
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
CyberPolicyUserAllowlist: updatedSettings.CyberPolicyUserAllowlist,
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
@@ -2468,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
}
@@ -2,10 +2,13 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -73,3 +76,65 @@ func TestConcurrencyErrorResponse(t *testing.T) {
})
}
}
func TestGoogleConcurrencyError(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantGStatus string
wantMessage string
}{
{
name: "client cancellation is 499",
err: context.Canceled,
wantStatus: statusClientClosedRequest,
wantGStatus: "CANCELLED",
wantMessage: "context canceled",
},
{
name: "full wait queue is 429",
err: &WaitQueueFullError{SlotType: "user"},
wantStatus: http.StatusTooManyRequests,
wantGStatus: "RESOURCE_EXHAUSTED",
wantMessage: "Too many pending requests, please retry later",
},
{
name: "slot wait timeout is 429",
err: &ConcurrencyError{SlotType: "user", IsTimeout: true},
wantStatus: http.StatusTooManyRequests,
wantGStatus: "RESOURCE_EXHAUSTED",
wantMessage: "Concurrency limit exceeded for user, please retry later",
},
{
name: "acquire backend error is 503",
err: errors.New("redis unavailable"),
wantStatus: http.StatusServiceUnavailable,
wantGStatus: "INTERNAL",
wantMessage: "Service temporarily unavailable, please retry later",
},
}
gin.SetMode(gin.TestMode)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
googleConcurrencyError(c, tt.err, "user")
require.Equal(t, tt.wantStatus, rec.Code)
var body 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(), &body))
require.Equal(t, tt.wantStatus, body.Error.Code)
require.Equal(t, tt.wantGStatus, body.Error.Status)
require.Equal(t, tt.wantMessage, body.Error.Message)
})
}
}
@@ -0,0 +1,7 @@
package dto
// RechargeBonusTier 充值赠送档位:余额充值支付金额 ≥ MinAmount 时,在到账基数上赠送 BonusPercent%。
type RechargeBonusTier struct {
MinAmount float64 `json:"min_amount"`
BonusPercent float64 `json:"bonus_percent"`
}
+12 -7
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"`
@@ -335,8 +339,9 @@ type SystemSettings struct {
RiskControlEnabled bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"`
CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"`
CyberPolicyUserAllowlist string `json:"cyber_policy_user_allowlist"`
CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_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},
+57 -6
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)
@@ -263,6 +267,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if err != nil {
reqLog.Info("gateway.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.handleStreamingAwareError(c, status, code, message, streamStarted)
return
}
defer inflightRelease()
// 设置请求所属分组 ID(用于渠道级功能判断,如 WebSearch 模拟)
parsedReq.GroupID = apiKey.GroupID
@@ -330,6 +347,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
if !cls.ModelNotFound {
@@ -660,6 +681,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
)
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
if !cls.ModelNotFound {
@@ -1141,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 {
@@ -1185,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)
}
@@ -1236,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
@@ -1262,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 的
@@ -1453,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 {
@@ -1971,10 +2011,15 @@ func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, statu
// Writer 已被写过时(ping 已 flush)走 streamStarted 分支,
// 让 handleStreamingAwareError 通过 SSE 发协议合规的终止事件,
// 否则下游收到的就是 silent EOF。
// 客户端已断开时不补写,响应未提交则标记 499(见 failoverClientGone)。
func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarted bool) bool {
if c == nil || c.Writer == nil {
return false
}
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
failoverClientGone(c)
return false
}
if service.IsResponseCommitted(c) {
return false
}
@@ -2136,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)))
@@ -2501,9 +2549,12 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
if task == nil {
return
}
task = wrapUsageRecordTaskContext(parent, task)
task, abandon := wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
if mode.Dropped() {
abandon()
}
return
}
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
@@ -2531,7 +2582,7 @@ func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context,
if task == nil {
return
}
task = wrapUsageRecordTaskContext(parent, task)
task, _ = wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
return
@@ -93,6 +93,8 @@ func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testi
require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector")
_, selected := c.Get(opsAccountIDKey)
require.False(t, selected)
require.Equal(t, statusClientClosedRequest, c.Writer.Status(), "an uncommitted cancelled request is marked 499")
require.Zero(t, recorder.Body.Len())
})
}
}
@@ -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)
@@ -101,8 +104,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
// Claude Code only restriction
if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly {
// Claude Code only restriction: /v1/chat/completions is never a Claude Code
// endpoint. With a fallback group the request continues and account selection
// (checkClaudeCodeRestriction) schedules it in the fallback group; without one
// it is rejected here.
if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil {
h.chatCompletionsErrorResponse(c, http.StatusForbidden, "permission_error",
"This group is restricted to Claude Code clients (/v1/messages only)")
return
@@ -144,6 +150,19 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if err != nil {
reqLog.Info("gateway.cc.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.chatCompletionsErrorResponse(c, status, code, message)
return
}
defer inflightRelease()
// Parse request for session hash
bodyRef := service.NewRequestBodyRef(body)
parsedReq, _ := service.ParseGatewayRequest(bodyRef, "chat_completions")
@@ -168,11 +187,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
}
for {
if c.Request.Context().Err() != nil {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.cc.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
cls = classifySelectionFailureError(err, cls)
@@ -0,0 +1,181 @@
//go:build unit
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "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"
)
// groupScopedSchedulerCache 按桶的分组只返回该分组的成员账号,并记录被查询过的分组。
type groupScopedSchedulerCache struct {
*fakeSchedulerCache
mu sync.Mutex
groupIDs []int64
}
func (c *groupScopedSchedulerCache) GetSnapshot(_ context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) {
c.mu.Lock()
c.groupIDs = append(c.groupIDs, bucket.GroupID)
c.mu.Unlock()
var members []*service.Account
for _, account := range c.accounts {
for _, ag := range account.AccountGroups {
if ag.GroupID == bucket.GroupID {
members = append(members, account)
break
}
}
}
return members, true, nil
}
func (c *groupScopedSchedulerCache) queriedGroupIDs() []int64 {
c.mu.Lock()
defer c.mu.Unlock()
return append([]int64(nil), c.groupIDs...)
}
type groupMapRepo struct {
*fakeGroupRepo
groups map[int64]*service.Group
}
func (r *groupMapRepo) GetByID(_ context.Context, id int64) (*service.Group, error) {
if group, ok := r.groups[id]; ok {
return group, nil
}
return nil, service.ErrGroupNotFound
}
func (r *groupMapRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
return r.GetByID(ctx, id)
}
func TestGatewayOpenAICompatibleHandlersClaudeCodeOnlyFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
primaryGroupID = int64(9300)
fallbackGroupID = int64(9301)
primaryAccountID = int64(9310)
fallbackAccountID = int64(9311)
)
// 账号不带 api_key:转发在取令牌时失败,请求不会触达上游,断言只看选号结果。
newAccount := func(id, groupID int64) *service.Account {
return &service.Account{
ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true, Concurrency: 1,
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
}
}
endpoints := []struct {
name string
path string
body string
call func(*GatewayHandler, *gin.Context)
}{
{
name: "responses", path: "/v1/responses",
body: `{"model":"claude-sonnet-4-5","input":"hello","stream":false}`,
call: (*GatewayHandler).Responses,
},
{
name: "chat completions", path: "/v1/chat/completions",
body: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}],"stream":false}`,
call: (*GatewayHandler).ChatCompletions,
},
}
for _, ep := range endpoints {
for _, tc := range []struct {
name string
hasFallback bool
}{
{name: "with fallback group", hasFallback: true},
{name: "without fallback group", hasFallback: false},
} {
t.Run(ep.name+"/"+tc.name, func(t *testing.T) {
primary := &service.Group{
ID: primaryGroupID, Hydrated: true, Platform: service.PlatformAnthropic,
Status: service.StatusActive, ClaudeCodeOnly: true,
}
if tc.hasFallback {
fallbackID := fallbackGroupID
primary.FallbackGroupID = &fallbackID
}
fallback := &service.Group{
ID: fallbackGroupID, Hydrated: true, Platform: service.PlatformAnthropic,
Status: service.StatusActive,
}
schedulerCache := &groupScopedSchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{
newAccount(primaryAccountID, primaryGroupID),
newAccount(fallbackAccountID, fallbackGroupID),
}}}
gatewayService := service.NewGatewayService(
nil, &groupMapRepo{fakeGroupRepo: &fakeGroupRepo{}, groups: map[int64]*service.Group{
primaryGroupID: primary,
fallbackGroupID: fallback,
}}, nil, nil, nil, nil, nil, nil, nil,
service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil),
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
cfg := &config.Config{RunMode: config.RunModeSimple}
billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billingCacheService.Stop)
h := &GatewayHandler{
gatewayService: gatewayService,
billingCacheService: billingCacheService,
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0),
maxAccountSwitches: 1,
cfg: cfg,
}
primaryGroupIDRef := primaryGroupID
apiKey := &service.APIKey{
ID: 9320, UserID: 9330, GroupID: &primaryGroupIDRef, Group: primary, Status: service.StatusActive,
User: &service.User{ID: 9330, Concurrency: 10, Balance: 100},
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
ctx := context.WithValue(context.Background(), ctxkey.Group, primary)
req := httptest.NewRequest(http.MethodPost, ep.path, bytes.NewBufferString(ep.body)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
c.Request = req
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
ep.call(h, c)
selected, reachedSelection := c.Get(opsAccountIDKey)
if !tc.hasFallback {
require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "This group is restricted to Claude Code clients")
require.False(t, reachedSelection, "a Claude Code only group without fallback must be rejected before account selection")
require.Empty(t, schedulerCache.queriedGroupIDs())
return
}
require.NotContains(t, recorder.Body.String(), "restricted to Claude Code clients")
require.True(t, reachedSelection, "a Claude Code only group with fallback must reach account selection")
require.Equal(t, fallbackAccountID, selected)
queried := schedulerCache.queriedGroupIDs()
require.Contains(t, queried, fallbackGroupID)
require.NotContains(t, queried, primaryGroupID)
})
}
}
}
@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -70,6 +71,43 @@ func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T)
require.Equal(t, 1, strings.Count(w.Body.String(), "event: error"))
}
// 客户端已断开且响应未提交:不补写 502,标记 499 供访问日志与 ops 归类。
func TestGatewayEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
cancel()
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.False(t, wrote)
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
require.Empty(t, w.Body.String())
}
// 客户端已断开但流已开始:状态码已固化为 200,不再向断开的连接追加错误帧。
func TestGatewayEnsureForwardErrorResponse_CanceledClientAfterStreamStartedAppendsNothing(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString(":\n\n")
cancel()
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, true)
require.False(t, wrote)
require.Equal(t, http.StatusOK, c.Writer.Status())
require.Equal(t, ":\n\n", w.Body.String())
require.Empty(t, service.GetOpsStreamErrors(c))
}
// case B 回归:Anthropic-backed /responses,Writer 已被写过时
// ensureForwardErrorResponse 仍要发 response.failed。
func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) {
@@ -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)
@@ -105,13 +108,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel)
// Claude Code only restriction:
// /v1/responses is never a Claude Code endpoint.
// When claude_code_only is enabled, this endpoint is rejected.
// The existing service-layer checkClaudeCodeRestriction handles degradation
// to fallback groups when the Forward path calls SelectAccountForModelWithExclusions.
// Here we just reject at handler level since /v1/responses clients can't be Claude Code.
if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly {
// Claude Code only restriction: /v1/responses is never a Claude Code
// endpoint. With a fallback group the request continues and account selection
// (checkClaudeCodeRestriction) schedules it in the fallback group; without one
// it is rejected here.
if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil {
h.responsesErrorResponse(c, http.StatusForbidden, "permission_error",
"This group is restricted to Claude Code clients (/v1/messages only)")
return
@@ -153,6 +154,19 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if err != nil {
reqLog.Info("gateway.responses.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.responsesErrorResponse(c, status, code, message)
return
}
defer inflightRelease()
// Parse request for session hash
bodyRef := service.NewRequestBodyRef(body)
parsedReq, _ := service.ParseGatewayRequest(bodyRef, "responses")
@@ -170,11 +184,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
fs := NewFailoverState(h.maxAccountSwitches, false)
for {
if requestCtx.Err() != nil {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.responses.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
cls = classifySelectionFailureError(err, cls)
@@ -0,0 +1,132 @@
package handler
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// inflightReservationEstimator 估算单请求在途预留金额(USD);false 表示无法定价。
type inflightReservationEstimator interface {
EstimateInflightReservation(ctx context.Context, apiKey *service.APIKey, req service.InflightEstimateRequest) (float64, bool)
}
// requestMaxOutputTokens 从请求体中提取输出 token 上限(兼容 Anthropic / OpenAI Chat / Responses / Gemini)。
func requestMaxOutputTokens(body []byte) int {
for _, path := range []string{"max_tokens", "max_completion_tokens", "max_output_tokens", "generationConfig.maxOutputTokens", "generation_config.max_output_tokens"} {
if v := gjson.GetBytes(body, path); v.Exists() && v.Type == gjson.Number && v.Int() > 0 {
return int(v.Int())
}
}
return 0
}
// tokenInflightEstimate 文本类请求的估算输入。
func tokenInflightEstimate(model string, body []byte) service.InflightEstimateRequest {
return service.InflightEstimateRequest{
Model: model,
BodyBytes: len(body),
MaxTokens: requestMaxOutputTokens(body),
Kind: service.InflightEstimateToken,
}
}
func inflightNoop() {}
// reserveInflightBalance 在 CheckBillingEligibility 之后为余额模式请求登记在途预留。
//
// 成功时把预留句柄挂到 c.Request 的 context 上:之后通过 submit*UsageRecordTask 提交的
// 计费任务会接管一个引用,直到余额缓存实际扣减后才释放。返回的 done 必须 defer 调用
// (停止续期并归还 handler 引用;没有提交计费任务时即刻释放)。
// 开关关闭、订阅模式、Redis 故障时返回 no-op(fail-open);无法定价时默认 fail-open,
// 配置 fail_closed_on_unpriced=true 时返回 ErrInsufficientBalance。
func reserveInflightBalance(
c *gin.Context,
billing *service.BillingCacheService,
estimator inflightReservationEstimator,
apiKey *service.APIKey,
subscription *service.UserSubscription,
req service.InflightEstimateRequest,
) (func(), error) {
if c == nil || c.Request == nil {
return inflightNoop, nil
}
ctx, done, err := reserveInflightBalanceCtx(c.Request.Context(), billing, estimator, apiKey, subscription, req)
if err != nil {
return inflightNoop, err
}
c.Request = c.Request.WithContext(ctx)
return done, nil
}
// reserveInflightBalanceCtx 同 reserveInflightBalance,但返回携带预留句柄的新 context
// (供 WebSocket 等自管 context 的路径)。
func reserveInflightBalanceCtx(
ctx context.Context,
billing *service.BillingCacheService,
estimator inflightReservationEstimator,
apiKey *service.APIKey,
subscription *service.UserSubscription,
req service.InflightEstimateRequest,
) (context.Context, func(), error) {
if billing == nil || estimator == nil || apiKey == nil || apiKey.User == nil || !billing.InflightReservationEnabled() {
return ctx, inflightNoop, nil
}
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() && subscription != nil {
return ctx, inflightNoop, nil
}
estimate, priced := estimator.EstimateInflightReservation(ctx, apiKey, req)
if !priced && billing.InflightReservationFailClosedOnUnpriced() {
return ctx, inflightNoop, service.ErrInsufficientBalance
}
if estimate <= 0 {
return ctx, inflightNoop, nil
}
res, err := billing.ReserveInflight(ctx, apiKey.User, apiKey.Group, subscription, estimate)
if err != nil {
return ctx, inflightNoop, err
}
if res == nil {
return ctx, inflightNoop, nil
}
return service.WithInflightReservation(ctx, res), res.HandlerDone, nil
}
// grokMediaInflightEstimate 媒体生成请求的估算输入;状态/内容查询返回空模型(不预留:
// 查询会为已生成的媒体计费,不能因余额预留而拦截用户取回已付费结果)。
func grokMediaInflightEstimate(endpoint service.GrokMediaEndpoint, model string, info service.GrokMediaRequestInfo, body []byte) service.InflightEstimateRequest {
if !endpoint.IsGenerationRequest() {
return service.InflightEstimateRequest{}
}
switch endpoint {
case service.GrokMediaEndpointImagesGenerations, service.GrokMediaEndpointImagesEdits:
return service.InflightEstimateRequest{Model: model, BodyBytes: len(body), Kind: service.InflightEstimateImage, Units: info.N}
default:
return service.InflightEstimateRequest{
Model: model,
BodyBytes: len(body),
Kind: service.InflightEstimateVideo,
Units: 1,
VideoResolution: info.Resolution,
VideoDurationSeconds: info.DurationSeconds,
}
}
}
// grokVoiceSTTBytesPerSecond STT 时长粗估(~128kbps 压缩音频)。
const grokVoiceSTTBytesPerSecond = 16000
// grokVoiceInflightEstimate 语音 HTTP 接口估算:TTS 按输入字符数(百万字符),STT 按音频字节粗估时长(小时)。
// 其他接口(custom-voices)无音频计量,返回的估算为 0(不预留)。
func grokVoiceInflightEstimate(endpoint string, body []byte) service.InflightEstimateRequest {
req := service.InflightEstimateRequest{Model: endpoint, Kind: service.InflightEstimateAudio, AudioMode: endpoint}
switch endpoint {
case "tts":
req.AudioUnits = float64(len([]rune(extractGrokTTSInputText(body)))) / 1e6
case "stt":
req.AudioUnits = float64(len(body)) / grokVoiceSTTBytesPerSecond / 3600
}
return req
}
@@ -0,0 +1,204 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestRequestMaxOutputTokens(t *testing.T) {
require.Equal(t, 1024, requestMaxOutputTokens([]byte(`{"max_tokens":1024}`)))
require.Equal(t, 2048, requestMaxOutputTokens([]byte(`{"max_completion_tokens":2048}`)))
require.Equal(t, 4096, requestMaxOutputTokens([]byte(`{"max_output_tokens":4096}`)))
require.Equal(t, 512, requestMaxOutputTokens([]byte(`{"generationConfig":{"maxOutputTokens":512}}`)))
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{"max_tokens":"x"}`)))
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{}`)))
}
type countingEstimator struct {
calls int
cost float64
priced bool
}
func (e *countingEstimator) EstimateInflightReservation(context.Context, *service.APIKey, service.InflightEstimateRequest) (float64, bool) {
e.calls++
return e.cost, e.priced
}
func newInflightTestGinContext() *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
return c
}
func TestReserveInflightBalance_SkipsWhenDisabledOrSubscription(t *testing.T) {
cfg := &config.Config{}
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billing.Stop)
est := &countingEstimator{cost: 1, priced: true}
apiKey := &service.APIKey{User: &service.User{ID: 1}}
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("m", []byte(`{}`)))
require.NoError(t, err)
done()
require.Equal(t, 0, est.calls, "disabled switch must not even estimate")
cfg.Billing.InflightReservation.Enabled = true
apiKey.Group = &service.Group{SubscriptionType: service.SubscriptionTypeSubscription}
done, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, &service.UserSubscription{}, tokenInflightEstimate("m", []byte(`{}`)))
require.NoError(t, err)
done()
require.Equal(t, 0, est.calls, "subscription mode must be unaffected")
}
func TestReserveInflightBalance_UnpricedFailOpenByDefaultFailClosedOptIn(t *testing.T) {
cache := newHandlerInflightCache(10)
cfg := &config.Config{}
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billing.Stop)
apiKey := &service.APIKey{User: &service.User{ID: 1}}
est := &countingEstimator{priced: false}
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
require.NoError(t, err)
done()
cfg.Billing.InflightReservation.FailClosedOnUnpriced = true
_, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
require.ErrorIs(t, err, service.ErrInsufficientBalance)
}
// handlerInflightCache 内存版余额缓存 + 在途预留(语义同 Redis Lua)。
type handlerInflightCache struct {
service.BillingCache
mu sync.Mutex
balance float64
res map[string]float64
}
func newHandlerInflightCache(balance float64) *handlerInflightCache {
return &handlerInflightCache{balance: balance, res: map[string]float64{}}
}
func (m *handlerInflightCache) GetUserBalance(context.Context, int64) (float64, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.balance, nil
}
func (m *handlerInflightCache) GetUserPlatformQuotaCache(context.Context, int64, string) (*service.UserPlatformQuotaCacheEntry, bool, error) {
return nil, false, nil
}
func (m *handlerInflightCache) ReserveInflightBalance(_ context.Context, _ int64, id string, amount, balance float64, _ time.Duration) (bool, float64, error) {
m.mu.Lock()
defer m.mu.Unlock()
sum := 0.0
for _, v := range m.res {
sum += v
}
if len(m.res) > 0 && balance-sum < amount {
return false, sum, nil
}
m.res[id] = amount
return true, sum, nil
}
func (m *handlerInflightCache) ReleaseInflightBalance(_ context.Context, _ int64, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.res, id)
return nil
}
func (m *handlerInflightCache) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.res)
}
func TestWrapUsageRecordTaskContext_HandsReservationToBillingTask(t *testing.T) {
cache := newHandlerInflightCache(1)
cfg := &config.Config{}
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billing.Stop)
apiKey := &service.APIKey{User: &service.User{ID: 5}}
c := newInflightTestGinContext()
done, err := reserveInflightBalance(c, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
require.NoError(t, err)
require.Equal(t, 1, cache.count())
ran := false
task, abandon := wrapUsageRecordTaskContext(c.Request.Context(), func(context.Context) { ran = true })
done() // handler returns; billing still pending
require.Equal(t, 1, cache.count(), "reservation held until the billing task finishes")
task(context.Background())
require.True(t, ran)
require.Equal(t, 0, cache.count())
abandon() // idempotent with the task's own done
// Dropped task: the submitter abandons it and the reservation is released.
c2 := newInflightTestGinContext()
done2, err := reserveInflightBalance(c2, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
require.NoError(t, err)
_, abandon2 := wrapUsageRecordTaskContext(c2.Request.Context(), func(context.Context) {})
done2()
require.Equal(t, 1, cache.count())
abandon2()
require.Equal(t, 0, cache.count())
}
// 新接入的端点(独立 web_search):在途预留超过余额时拒绝,且不残留预留。
func TestWebSearch_RejectsWhenInflightExceedsBalance(t *testing.T) {
cache := newHandlerInflightCache(1.5)
cfg := &config.Config{}
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
billingCache := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billingCache.Stop)
billing := service.NewBillingService(cfg, nil)
gw := service.NewGatewayService(
nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, billing, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, service.NewModelPricingResolver(nil, billing), nil, nil, nil,
)
h := &GatewayHandler{gatewayService: gw, billingCacheService: billingCache}
groupID := int64(3)
perK := 1000.0 // $1 per search
apiKey := &service.APIKey{
ID: 9, User: &service.User{ID: 42, Balance: 1.5}, GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, RateMultiplier: 1, SearchPricePer1k: &perK},
}
// Another in-flight request of this user already holds $1.
held, err := billingCache.ReserveInflight(context.Background(), apiKey.User, apiKey.Group, nil, 1.0)
require.NoError(t, err)
defer held.HandlerDone()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/web_search", bytes.NewBufferString(`{"query":"sub2api"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
h.WebSearch(c)
require.NotEqual(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "balance")
require.Equal(t, 1, cache.count(), "rejected request must not leave a reservation behind")
}
@@ -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 {
@@ -287,6 +287,18 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
accounts: accounts[1:],
want: []string{sparkModel, alias},
},
{
// A passthrough account with a stale mapping behaves like an unmapped
// one: it adds the defaults but never its own mapping keys, and it no
// longer hides the aliases declared on ordinary accounts.
name: "passthrough account contributes defaults without hiding mapped aliases",
accounts: append([]service.Account{{
ID: 5, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}},
Extra: map[string]any{"openai_passthrough": true},
}}, accounts[1:]...),
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped accounts from another platform do not add defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
@@ -920,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
@@ -1487,9 +1503,9 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
restricted bool
want []string
}{
{"selected and ordered", []string{"gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6-luna", "gpt-6-sol"}},
{"selected and ordered", []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}},
{"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}},
{"account restricts new models", []string{"gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}},
{"account restricts new models", []string{"gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}},
} {
t.Run(tc.name, func(t *testing.T) {
groupID := int64(25)
@@ -1510,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)
})
}
}
@@ -88,6 +88,18 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"), Kind: service.InflightEstimatePerRequest, SearchCalls: 1})
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
return
}
defer inflightDone()
subject, _ := middleware2.GetAuthSubjectFromContext(c)
reqLog := requestLogger(c, "handler.gateway.web_search")
// Audit user search query before upstream Grok web_search traffic.
@@ -0,0 +1,130 @@
//go:build unit
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "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"
)
// clientCancelUpstream 模拟上游尚未响应时客户端断开:Do 期间取消请求 context,
// 并按 net/http 的形态返回包裹 context.Canceled 的 *url.Error。
type clientCancelUpstream struct {
service.HTTPUpstream
cancel context.CancelFunc
calls atomic.Int32
}
func (u *clientCancelUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.calls.Add(1)
u.cancel()
return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: context.Canceled}
}
type geminiClientCancelFixture struct {
handler *GatewayHandler
group *service.Group
apiKey *service.APIKey
upstream *clientCancelUpstream
ctx context.Context
}
func newGeminiClientCancelFixture(t *testing.T) *geminiClientCancelFixture {
t.Helper()
groupID := int64(9200)
accountID := int64(9201)
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive}
account := &service.Account{
ID: accountID,
Platform: service.PlatformGemini,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "test-key"},
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 1,
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
}
h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
t.Cleanup(cleanup)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
upstream := &clientCancelUpstream{cancel: cancel}
h.geminiCompatService = service.NewGeminiMessagesCompatService(nil, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})
apiKey := &service.APIKey{
ID: 9202, UserID: 9203, GroupID: &groupID, Group: group, Status: service.StatusActive,
User: &service.User{ID: 9203, Concurrency: 10, Balance: 100},
}
return &geminiClientCancelFixture{handler: h, group: group, apiKey: apiKey, upstream: upstream, ctx: ctx}
}
// serve 经 ops 错误日志中间件执行请求,返回响应与 ops 队列中的条目数。
func (f *geminiClientCancelFixture) serve(t *testing.T, route, path, body string, call func(*gin.Context)) (*httptest.ResponseRecorder, int64) {
t.Helper()
setupOpsErrorLogTestQueue(t, 4)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST(route, func(c *gin.Context) {
c.Set(string(middleware.ContextKeyAPIKey), f.apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: f.apiKey.UserID, Concurrency: 10})
call(c)
})
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req = req.WithContext(context.WithValue(f.ctx, ctxkey.Group, f.group))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec, OpsErrorLogQueueLength()
}
// 原生入口:上游响应前客户端断开,响应未提交,标记 499;纯客户端取消不落 ops 错误日志。
func TestGeminiV1BetaModels_ClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1beta/models/*modelAction",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
f.handler.GeminiV1BetaModels,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}
// Chat Completions 兼容入口(Gemini 分组):同一场景标记 499,不再补写 502。
func TestGatewayChatCompletions_GeminiClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1/chat/completions",
"/v1/chat/completions",
`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`,
f.handler.ChatCompletions,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}
@@ -370,7 +370,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
if err != nil {
reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err))
googleError(c, http.StatusTooManyRequests, err.Error())
googleConcurrencyError(c, err, "user")
return
}
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
@@ -390,6 +390,19 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
if err != nil {
reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err))
status, _, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleError(c, status, message)
return
}
defer inflightRelease()
// 3) select account (sticky session based on request body)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash := extractGeminiCLISessionHash(c, body)
@@ -506,6 +519,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
for {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
if !cls.ModelNotFound {
@@ -598,7 +615,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
)
if err != nil {
reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
googleError(c, http.StatusTooManyRequests, err.Error())
googleConcurrencyError(c, err, "account")
return
}
if accountWaitCounted {
@@ -674,7 +691,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
return
}
}
// ForwardNative already wrote the response
// 转发层已写出错误响应;客户端断开时转发层不写,响应未提交则标记 499。
failoverClientGone(c)
reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return
}
@@ -836,6 +854,13 @@ func googleError(c *gin.Context, status int, message string) {
})
}
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
status, _, _, message := concurrencyErrorResponse(err, slotType)
googleError(c, status, message)
}
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
if res == nil {
googleError(c, http.StatusBadGateway, "Empty upstream response")
+24
View File
@@ -49,6 +49,18 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
if strings.TrimSpace(model) == "" {
model = "grok-voice-latest"
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: model, Kind: service.InflightEstimateAudio, AudioMode: "realtime", AudioUnits: 1})
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightDone()
// Keep the HTTP response uncommitted while selecting and probing an account.
// Realtime is not an HTTP streaming response; using reqStream=true here would
// let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
@@ -212,6 +224,18 @@ func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
return
}
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, grokVoiceInflightEstimate(endpoint, body))
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightDone()
contentType := c.GetHeader("Content-Type")
if strings.TrimSpace(contentType) == "" {
contentType = "application/json"
+12
View File
@@ -173,6 +173,18 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, grokMediaInflightEstimate(endpoint, routingModel, requestInfo, body))
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightDone()
sessionSeed := body
if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" {
sessionSeed = []byte(requestID)
@@ -92,6 +92,8 @@ type modelPlazaGroup struct {
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
Models []modelPlazaModel `json:"models"`
@@ -211,6 +213,8 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
LongContextPricingEnabled: g.LongContextPricingEnabled,
Models: models,
}
@@ -89,6 +89,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
VideoRateIndependent: true, VideoRateMultiplier: 0.7,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
@@ -115,11 +116,14 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
"image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
"video_rate_independent", "video_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
require.Equal(t, true, decoded["video_rate_independent"])
require.Equal(t, 0.7, decoded["video_rate_multiplier"])
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
@@ -109,6 +109,18 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: requestedModel, BodyBytes: len(body), MaxTokens: requestMaxOutputTokens(body), Kind: service.InflightEstimateToken, SearchCalls: 1})
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightDone()
searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
profitVetoCount := 0
@@ -197,6 +209,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError
if !errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
if failoverClientGone(c) {
reqLog.Info("openai_alpha_search.forward_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Error(err))
return
}
if c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
@@ -146,6 +146,19 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if err != nil {
reqLog.Info("openai_chat_completions.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.handleStreamingAwareError(c, status, code, message, streamStarted)
return
}
defer inflightRelease()
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
@@ -0,0 +1,18 @@
package handler
import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
func (h *OpenAIGatewayHandler) cyberPolicyLogOnly(c *gin.Context, apiKey *service.APIKey) bool {
return h != nil && c != nil && c.Request != nil && h.gatewayService.CyberPolicyLogOnly(c.Request.Context(), apiKey)
}
// Both HTTP and WebSocket admission skip existing blocks for trusted users.
func (h *OpenAIGatewayHandler) findBlockedCyberSessionForAPIKey(c *gin.Context, apiKey *service.APIKey, body []byte) string {
if apiKey == nil || h.cyberPolicyLogOnly(c, apiKey) {
return ""
}
return findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body)
}
@@ -0,0 +1,79 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCyberAllowlistedUserBypassesExistingBlocksAndContinuesWebSocket(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := coderws.Accept(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.CloseNow() }()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
for _, response := range []string{
`{"type":"response.failed","response":{"id":"resp_cyber","model":"gpt-5.1","error":{"code":"cyber_policy","message":"blocked by upstream"}}}`,
`{"type":"response.completed","response":{"id":"resp_ok","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`,
} {
if _, _, err := conn.Read(ctx); err != nil {
return
}
if err := conn.Write(ctx, coderws.MessageText, []byte(response)); err != nil {
return
}
}
// Let the downstream close so the completed frame can drain normally.
_, _, _ = conn.Read(ctx)
}))
defer upstream.Close()
harness := newOpenAIWSPassthroughHandlerHarness(t, upstream.URL, map[string]string{
service.SettingKeyCyberPolicyUserAllowlist: "1751",
})
payload := []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"trusted-session","input":"test"}`)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(string(payload)))
explicitKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, c, payload)
store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
require.True(t, ok)
require.NoError(t, store.SetCyberSessionBlocked(context.Background(), "", []string{explicitKey}, time.Minute))
for _, format := range []cyberSessionBlockFormat{cyberBlockFormatResponses, cyberBlockFormatChat, cyberBlockFormatAnthropic} {
require.False(t, harness.handler.rejectIfCyberSessionBlocked(c, harness.apiKey, payload, "gpt-5.1", format))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload))
_, event, err := harness.clientConn.Read(ctx)
require.NoError(t, err)
require.Equal(t, "cyber_policy", gjson.GetBytes(event, "response.error.code").String())
require.Eventually(t, func() bool {
logs := harness.moderationRepo.logSnapshot()
return len(logs) == 1 && logs[0].Mode == service.ContentModerationModeCyberLogOnly
}, 3*time.Second, 10*time.Millisecond)
matched, err := store.FindCyberSessionBlocked(ctx, service.CyberSessionTranscriptBlockKeys(harness.apiKey.ID, payload))
require.NoError(t, err)
require.Empty(t, matched, "the cyber response must not write new transcript blocks")
require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload))
_, event, err = harness.clientConn.Read(ctx)
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
_ = harness.clientConn.CloseNow()
select {
case <-harness.handlerDone:
case <-ctx.Done():
t.Fatal("handler did not exit")
}
}
@@ -108,6 +108,18 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: reqModel, BodyBytes: len(body), MaxTokens: 1, Kind: service.InflightEstimateToken})
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightDone()
profitVetoCount := 0
failedAccountIDs := make(map[int64]struct{})
var lastFailoverErr *service.UpstreamFailoverError
@@ -34,6 +34,7 @@ import (
// OpenAIGatewayHandler handles OpenAI API gateway requests
type OpenAIGatewayHandler struct {
compositeResolver *service.CompositeRouteResolver
gatewayService *service.OpenAIGatewayService
billingCacheService *service.BillingCacheService
apiKeyService *service.APIKeyService
@@ -257,13 +258,21 @@ func usageRecordContext(parent context.Context, base context.Context) context.Co
return base
}
func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) service.UsageRecordTask {
// wrapUsageRecordTaskContext 包装计费任务:复制请求级 context 值,并接管请求的在途余额预留引用。
// 返回的 abandon 在任务未被执行(被丢弃)时必须调用以归还预留引用;任务执行结束时(含 panic)
// 自动归还,此时余额缓存已在计费路径中同步扣减。
func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) (service.UsageRecordTask, func()) {
if task == nil {
return nil
return nil, func() {}
}
done := func() {}
if parent != nil {
done = service.InflightReservationFromContext(parent).Acquire()
}
return func(ctx context.Context) {
defer done()
task(usageRecordContext(parent, ctx))
}
}, done
}
func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string {
@@ -606,6 +615,19 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if err != nil {
reqLog.Info("openai.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.handleStreamingAwareError(c, status, code, message, streamStarted)
return
}
defer inflightRelease()
// Generate session hash (header first; fallback to prompt_cache_key)
sessionHash := h.gatewayService.GenerateSessionHash(c, sessionHashBody)
if h.rejectIfCyberSessionBlocked(c, apiKey, sessionHashBody, reqModel, cyberBlockFormatResponses) {
@@ -1242,6 +1264,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.anthropicStreamingAwareError(c, status, code, message, streamStarted)
return
}
defer inflightDone()
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(c, sessionHash, promptCacheKey, reqModel, body)
@@ -2391,7 +2425,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
// 必须在合成路由解析和上游模型映射之前执行。
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
// 全部候选值逐一校验,任一未命中即拒绝。
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
@@ -2400,7 +2434,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
return
}
ensureCompositeTargetPlatform(c, apiKey, reqModel)
// Keep the client model in the frame for admission, response identity and usage.
// Apply the resolved upstream model through MapRequestModel on every turn.
wsRouteModel := reqModel
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
decision, resolveErr := h.compositeResolver.Resolve(c.Request.Context(), apiKey.Group.ID, reqModel, service.CompositeRouteEndpointResponses)
if resolveErr != nil {
reqLog.Error("openai.websocket_composite_route_failed", zap.Error(resolveErr))
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route")
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
wsRouteModel = decision.UpstreamModel
}
}
ctx = c.Request.Context()
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
platform, ok := service.ResolvedTargetPlatformFromContext(ctx)
@@ -2440,7 +2488,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// The first response.create frame is available here, so explicit IDs are
// checked directly and body-derived sessions use the coarse scope gate.
if cyberBlockKey := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, firstMessage); cyberBlockKey != "" {
if cyberBlockKey := h.findBlockedCyberSessionForAPIKey(c, apiKey, firstMessage); cyberBlockKey != "" {
writeCyberSessionBlockedWSError(c.Request.Context(), wsConn)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy")
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey)
@@ -2464,8 +2512,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
// 解析渠道级模型映射
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, wsRouteModel)
wsForwardModel := openAIChannelForwardModel(channelMappingWS, wsRouteModel)
var currentUserRelease func()
var currentAccountRelease func()
@@ -2526,6 +2574,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
// 余额模式在途预留(会话级):按首帧估算一次,会话期间续期;每轮计费任务接管引用,
// 会话结束且所有轮次扣减落地后释放。
inflightCtx, inflightDone, inflightErr := reserveInflightBalanceCtx(ctx, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, firstMessage))
if inflightErr != nil {
reqLog.Info("openai.websocket_inflight_reservation_rejected", zap.Error(inflightErr))
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
return
}
defer inflightDone()
ctx = inflightCtx
// A WebSocket may outlive a key's remaining spending window. Recheck
// after acquiring turn slots, including the first account-selection wait.
// Restrict this extra check to the opt-in mode so standard-mode RPM checks
@@ -2817,7 +2876,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
// BeforeTurn 中保留同一检查作为防御式兜底。
if cyberBlockedThisConn {
if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
if turn == 1 {
@@ -2856,7 +2915,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
model = reqModel
}
setOpsRequestContext(c, model, true)
mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model)
routeModel := model
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
// The account, target platform and route context are connection-scoped.
// A different public model needs a new connection and fresh resolution.
if model != reqModel {
return "", newOpenAIWSUnsupportedModelSwitchError(model)
}
routeModel = wsRouteModel
}
mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, routeModel)
mappedModelUnchanged := false
if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn {
mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel)
@@ -2869,7 +2937,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
},
BeforeTurn: func(turn int) error {
// turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。
if cyberBlockedThisConn {
if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
// 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核
@@ -2949,7 +3017,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState(
cyberBlockedThisConn,
cyberBlockPendingAfterFailover,
cyberMarked,
cyberMarked && !h.cyberPolicyLogOnly(c, apiKey),
turnErr,
)
if turnErr != nil {
@@ -3266,9 +3334,12 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
if task == nil {
return
}
task = wrapUsageRecordTaskContext(parent, task)
task, abandon := wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
if mode.Dropped() {
abandon()
}
return
}
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
@@ -3305,7 +3376,7 @@ func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Con
if task == nil {
return
}
task = wrapUsageRecordTaskContext(parent, task)
task, _ = wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
return
@@ -4045,7 +4116,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled {
return false
}
key := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body)
key := h.findBlockedCyberSessionForAPIKey(c, apiKey, body)
if key == "" {
return false
}
@@ -4242,7 +4313,8 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
ClientIP: clientIPStr,
CreatedAt: time.Now(),
}
if gwSvc != nil && apiKey != nil {
cyberLogOnly := h.cyberPolicyLogOnly(c, apiKey)
if gwSvc != nil && apiKey != nil && !cyberLogOnly {
plan := buildCyberSessionBlockWritePlan(apiKey.ID, c, cyberBlockBody)
if len(plan.keys) > 0 {
blockCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
@@ -4255,6 +4327,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
defer cancel()
if cmSvc != nil {
cmSvc.RecordCyberPolicyEvent(ctx, service.CyberPolicyRecordInput{
LogOnly: cyberLogOnly,
RequestID: requestID,
UserID: userID,
UserEmail: userEmail,
@@ -1931,7 +1931,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
type openAIResponsesWSUsageLogCase struct {
simpleModeRejectAtRead int64
compositeResolver *service.CompositeRouteResolver
accountPlatform string
closeReason string
closeStatus coderws.StatusCode
firstPayload string
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
// 回一个 response.completed,客户端按普通事件读取。
@@ -2878,6 +2881,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
upstreamErrCh := make(chan error, 1)
var channelSvc *service.ChannelService
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.accountPlatform == service.PlatformGrok {
payload, err := io.ReadAll(r.Body)
if err != nil {
upstreamErrCh <- err
return
}
upstreamPayloadCh <- payload
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok_test\",\"model\":%q,\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n", gjson.GetBytes(payload, "model").String())
return
}
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
CompressionMode: coderws.CompressionContextTakeover,
})
@@ -2945,6 +2959,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
},
}
if tc.accountPlatform != "" {
account.Platform = tc.accountPlatform
}
if strings.TrimSpace(tc.ingressMode) != "" {
account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode
}
@@ -3000,7 +3017,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
service.NewBillingService(cfg, nil),
nil,
billingCacheSvc,
nil,
&compositeWSHTTPUpstream{},
&service.DeferredService{},
nil,
nil,
@@ -3021,6 +3038,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
}
h := &OpenAIGatewayHandler{
cfg: cfg,
compositeResolver: tc.compositeResolver,
gatewayService: gatewaySvc,
billingCacheService: billingCacheSvc,
apiKeyService: &service.APIKeyService{},
@@ -3069,6 +3087,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
cancelWrite()
require.NoError(t, err)
if tc.closeReason == "" {
tc.closeReason = "not available for this group"
}
if tc.closeStatus == 0 {
tc.closeStatus = coderws.StatusPolicyViolation
}
if tc.firstFrameCloseExpected {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, _, readErr := clientConn.Read(readCtx)
@@ -3076,12 +3100,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
require.Error(t, readErr, "first frame should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
status := tc.closeStatus
if status == 0 {
status = coderws.StatusPolicyViolation
}
require.Equal(t, status, closeErr.Code)
reason := tc.closeReason
if reason == "" {
reason = "not available for this group"
}
require.Contains(t, closeErr.Reason, reason)
require.Empty(t, upstreamPayloadCh, "rejected first frame must not reach upstream")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
@@ -3115,12 +3144,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
require.Error(t, readErr, "second turn should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
status := tc.closeStatus
if status == 0 {
status = coderws.StatusPolicyViolation
}
require.Equal(t, status, closeErr.Code)
reason := tc.closeReason
if reason == "" {
reason = "not available for this group"
}
require.Contains(t, closeErr.Reason, reason)
require.Len(t, upstreamPayloadCh, turnCount-1, "rejected turn must not reach upstream")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
@@ -3149,6 +3183,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
}
}
if tc.accountPlatform == service.PlatformGrok {
upstreamErrCh <- nil
}
select {
case upstreamErr := <-upstreamErrCh:
require.NoError(t, upstreamErr)
@@ -0,0 +1,159 @@
package handler
import (
"context"
"errors"
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
type compositeWSRouteRepo struct {
service.CompositeModelRouteRepository
routes []service.CompositeModelRoute
err error
}
func (r *compositeWSRouteRepo) ListByGroup(context.Context, int64, bool) ([]service.CompositeModelRoute, error) {
return r.routes, r.err
}
type compositeWSHTTPUpstream struct{ service.HTTPUpstream }
func (*compositeWSHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return http.DefaultClient.Do(req)
}
func compositeWSResolver(platform, endpoint, upstream string) *service.CompositeRouteResolver {
return service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{
GroupID: 4201, PublicModel: "public-alias", MatchType: service.CompositeRouteMatchExact,
TargetPlatform: platform, Endpoint: endpoint, UpstreamModel: upstream, Enabled: true,
}}})
}
func compositeWSGroup(models ...string) *service.Group {
g := wsAllowlistGroup(len(models) > 0, models...)
g.Platform = service.PlatformComposite
return g
}
func TestOpenAIResponsesWebSocket_CompositeAlias(t *testing.T) {
for _, platform := range []string{service.PlatformOpenAI, service.PlatformGrok} {
for _, endpoint := range []string{service.CompositeRouteEndpointResponses, service.CompositeRouteEndpointAny} {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
t.Run(platform+"/"+endpoint+"/"+mode, func(t *testing.T) {
upstream := "gpt-5.4"
if platform == service.PlatformGrok {
upstream = "grok-4.3"
}
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias","input":"hi"}`,
secondPayload: `{"type":"response.create","input":"again"}`,
group: compositeWSGroup("public-alias"), accountPlatform: platform, ingressMode: mode,
compositeResolver: compositeWSResolver(platform, endpoint, upstream),
})
require.Len(t, got.upstreamPayloads, 2)
for i, payload := range got.upstreamPayloads {
require.Equal(t, upstream, gjson.GetBytes(payload, "model").String())
require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[i], "response.model").String())
require.Equal(t, "public-alias", got.logs[i].RequestedModel)
require.NotNil(t, got.logs[i].UpstreamModel)
require.Equal(t, upstream, *got.logs[i].UpstreamModel)
}
})
}
}
}
}
func TestOpenAIResponsesWebSocket_CompositeRouteRejections(t *testing.T) {
for _, tc := range []struct {
name, platform, endpoint, model, reason string
repoErr error
status coderws.StatusCode
}{
{name: "disallowed platform", platform: service.PlatformAnthropic, endpoint: "responses", model: "public-alias", reason: "only supports OpenAI-compatible"},
{name: "wrong endpoint", platform: service.PlatformOpenAI, endpoint: "messages", model: "public-alias", reason: "only supports OpenAI-compatible"},
{name: "unknown alias", platform: service.PlatformOpenAI, endpoint: "responses", model: "unknown-alias", reason: "only supports OpenAI-compatible"},
{name: "resolver error", model: "gpt-5.4", repoErr: errors.New("database unavailable"), reason: "Failed to resolve composite model route", status: coderws.StatusInternalError},
} {
t.Run(tc.name, func(t *testing.T) {
resolver := compositeWSResolver(tc.platform, tc.endpoint, "gpt-5.4")
if tc.repoErr != nil {
resolver = service.NewCompositeRouteResolver(&compositeWSRouteRepo{err: tc.repoErr})
}
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"` + tc.model + `"}`, group: compositeWSGroup(), compositeResolver: resolver,
firstFrameCloseExpected: true, closeReason: tc.reason, closeStatus: tc.status,
})
})
}
}
func TestOpenAIResponsesWebSocket_CompositeAdmissionUsesPublicModel(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias"}`, group: compositeWSGroup("gpt-5.4"),
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), firstFrameCloseExpected: true,
})
}
func TestOpenAIResponsesWebSocket_CompositeModelSwitchRequiresReconnect(t *testing.T) {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
for _, model := range []string{"gpt-5.4", "grok-4.3", "unknown-alias"} {
t.Run(mode+"/"+model, func(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias"}`,
secondPayload: `{"type":"response.create","model":"` + model + `"}`, group: compositeWSGroup(), ingressMode: mode,
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"),
secondTurnCloseExpected: true, closeReason: "model switch requires reconnect",
})
})
}
}
}
func TestOpenAIResponsesWebSocket_CompositeChannelBilling(t *testing.T) {
for _, source := range []string{service.BillingModelSourceRequested, service.BillingModelSourceChannelMapped, service.BillingModelSourceUpstream} {
t.Run(source, func(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`,
secondPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`,
group: compositeWSGroup("gpt-5.6-sol"),
compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{PublicModel: "gpt-5.6-sol", MatchType: service.CompositeRouteMatchExact, TargetPlatform: service.PlatformOpenAI, Endpoint: service.CompositeRouteEndpointResponses, UpstreamModel: "route-target"}}}),
channelMapping: map[string]string{"route-target": "gpt-5.4"}, billingModelSource: source,
accountModelMapping: map[string]any{"gpt-5.4": "gpt-5.4"},
})
for i, log := range got.logs {
require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamPayloads[i], "model").String())
require.Equal(t, "gpt-5.6-sol", log.RequestedModel)
require.Equal(t, "gpt-5.6-sol", log.Model)
if source == service.BillingModelSourceRequested {
require.InDelta(t, 40e-6, log.TotalCost, 1e-12)
} else {
require.InDelta(t, 20e-6, log.TotalCost, 1e-12)
}
}
})
}
}
func TestOpenAIResponsesWebSocket_CompositeDetectorFallback(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4"}`, group: compositeWSGroup(),
compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{}),
})
require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamFirstPayload, "model").String())
}
func TestOpenAIResponsesWebSocket_CompositeSessionModelSwitchRequiresReconnect(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias"}`,
midPayload: `{"type":"session.update","session":{"model":"grok-4.3"}}`,
secondPayload: `{"type":"response.create"}`, group: compositeWSGroup(),
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"),
secondTurnCloseExpected: true, closeReason: "model switch requires reconnect",
})
}
+12
View File
@@ -143,6 +143,18 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
return
}
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: routingModel, BodyBytes: len(body), Kind: service.InflightEstimateImage, Units: parsed.N})
if inflightErr != nil {
status, code, message, retryAfter := billingErrorDetails(inflightErr)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.handleStreamingAwareError(c, status, code, message, streamStarted)
return
}
defer inflightDone()
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body)
requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context()))
@@ -19,6 +19,7 @@ import (
)
type openAIWSPassthroughHandlerHarness struct {
handler *OpenAIGatewayHandler
clientConn *coderws.Conn
handlerDone <-chan struct{}
moderationRepo *contentModerationHandlerTestRepo
@@ -26,7 +27,7 @@ type openAIWSPassthroughHandlerHarness struct {
apiKey *service.APIKey
}
func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness {
func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string, settings ...map[string]string) *openAIWSPassthroughHandlerHarness {
t.Helper()
gatewayCache := testutil.NewRedisGatewayCache(t)
@@ -35,6 +36,11 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope
service.SettingKeyCyberSessionBlockEnabled: "true",
service.SettingKeyCyberSessionBlockTTLSeconds: "60",
}}
for _, overrides := range settings {
for key, value := range overrides {
settingRepo.values[key] = value
}
}
moderationRepo := &contentModerationHandlerTestRepo{}
moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil)
settingSvc := service.NewSettingService(settingRepo, nil)
@@ -90,6 +96,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope
apiKey := &service.APIKey{
ID: 1851,
UserID: 1751,
Name: "ws-cyber-key",
Key: "sk-handler-cyber-test",
GroupID: &groupID,
@@ -116,6 +123,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope
t.Cleanup(func() { _ = clientConn.CloseNow() })
return &openAIWSPassthroughHandlerHarness{
handler: h,
clientConn: clientConn,
handlerDone: handlerDone,
moderationRepo: moderationRepo,
@@ -1146,6 +1146,9 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
if shouldSkipOpsErrorLog(c.Request.Context(), ops, parsed.Message, string(body), c.Request.URL.Path) {
return
}
if shouldSkipOpsClientClosed(c, ops, status) {
return
}
apiKey := getOpsAPIKey(c)
@@ -2491,6 +2494,21 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message
return false
}
// shouldSkipOpsClientClosed 按 IgnoreContextCanceled 过滤纯客户端取消的 499。
// 499 表示客户端在响应提交前断开(见 failoverClientGone),通常不带
// "context canceled" 文案,shouldSkipOpsErrorLog 的文本过滤命中不了。
// 本次请求未观察到上游错误时是纯客户端取消;已有上游错误的 499 表示上游失败后
// 客户端没等到换号结果就离开,仍按上游失败落库。
func shouldSkipOpsClientClosed(c *gin.Context, ops *service.OpsService, status int) bool {
if status != statusClientClosedRequest || ops == nil {
return false
}
if !ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled {
return false
}
return !hasOpsUpstreamErrorContext(c)
}
// shouldSkipOpsErrorLogForCyber:cyber_policy 命中的请求由 recordCyberPolicyIfMarked
// 统一落一条 status=403 的错误请求,故中间件跳过自身落库,避免双写。
func shouldSkipOpsErrorLogForCyber(c *gin.Context) bool {
@@ -2168,3 +2168,83 @@ func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) {
require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType)
}
}
type opsAdvancedSettingsRepoStub struct {
service.SettingRepository
advanced string
}
func (r *opsAdvancedSettingsRepoStub) GetValue(context.Context, string) (string, error) {
return "", service.ErrSettingNotFound
}
func (r *opsAdvancedSettingsRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{service.SettingKeyOpsAdvancedSettings: r.advanced}, nil
}
func (r *opsAdvancedSettingsRepoStub) Set(context.Context, string, string) error {
return nil
}
// serveClientClosedRequest 以已取消的请求 context 走 failoverClientGone,复现网关标记 499 的收尾路径。
func serveClientClosedRequest(t *testing.T, ops *service.OpsService, prepare func(c *gin.Context)) {
t.Helper()
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/messages", func(c *gin.Context) {
if prepare != nil {
prepare(c)
}
failoverClientGone(c)
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", nil).WithContext(ctx))
require.Equal(t, statusClientClosedRequest, recorder.Code)
require.Zero(t, recorder.Body.Len())
}
func TestOpsErrorLoggerMiddleware_SkipsPureClientClosed(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
require.True(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
serveClientClosedRequest(t, ops, nil)
require.Zero(t, OpsErrorLogQueueLength(), "纯客户端取消的 499 按 IgnoreContextCanceled 跳过")
}
func TestOpsErrorLoggerMiddleware_RecordsClientClosedAfterUpstreamFailure(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
serveClientClosedRequest(t, ops, func(c *gin.Context) {
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
AccountID: 7, UpstreamStatusCode: 524, Kind: "failover", Message: "upstream timeout",
}})
})
require.Equal(t, int64(1), OpsErrorLogQueueLength(), "上游失败后客户端离开仍按上游失败落库")
job := <-opsErrorLogQueue
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
require.Equal(t, "upstream", job.entry.ErrorPhase)
require.NotNil(t, job.entry.UpstreamStatusCode)
require.Equal(t, 524, *job.entry.UpstreamStatusCode)
}
func TestOpsErrorLoggerMiddleware_RecordsClientClosedWhenIgnoreContextCanceledDisabled(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
settings := &opsAdvancedSettingsRepoStub{advanced: `{"ignore_context_canceled":false}`}
ops := service.NewOpsService(nil, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil)
require.False(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
serveClientClosedRequest(t, ops, nil)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
}
@@ -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
}
+4
View File
@@ -50,10 +50,12 @@ func ProvideAdminHandlers(
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
claudeResetCredits *service.ClaudeResetCreditService,
) *AdminHandlers {
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage)
accountHandler.SetClaudeResetCreditService(claudeResetCredits)
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
@@ -132,10 +134,12 @@ func ProvideOpenAIGatewayHandler(
grokQuotaService *service.GrokQuotaService,
cfg *config.Config,
coordinator *securityaudit.Coordinator,
compositeResolver *service.CompositeRouteResolver,
) *OpenAIGatewayHandler {
gatewayService.SetPluginManager(pluginManager)
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
h.compositeResolver = compositeResolver
h.securityAuditCoordinator = coordinator
h.grokMediaEligibilityProber = grokQuotaService
return h
@@ -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")
}
}
@@ -532,8 +532,8 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
}
parts = append(parts, part)
case "image":
if block.Source != nil && block.Source.Type == "base64" {
case "image", "document":
if block.Source != nil && block.Source.Type == "base64" && strings.TrimSpace(block.Source.Data) != "" {
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: block.Source.MediaType,
@@ -646,6 +646,20 @@ func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T)
})
}
func TestBuildParts_DocumentBecomesInlineData(t *testing.T) {
content := `[
{"type":"text","text":"read this"},
{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
]`
parts, stripped, err := buildParts(json.RawMessage(content), map[string]string{}, true)
require.NoError(t, err)
require.False(t, stripped)
require.Len(t, parts, 2)
require.NotNil(t, parts[1].InlineData)
require.Equal(t, "application/pdf", parts[1].InlineData.MimeType)
require.Equal(t, "JVBERi0=", parts[1].InlineData.Data)
}
// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for
// reasoning models without any tools: upstream rejects requests that omit it.
func TestToolConfigAlwaysPresent(t *testing.T) {
@@ -258,6 +258,18 @@ func cleanJSONSchemaRecursive(value any) any {
if constVal, exists := schemaMap["const"]; exists {
if _, hasEnum := schemaMap["enum"]; !hasEnum {
schemaMap["enum"] = []any{constVal}
} else if constant, ok := constVal.(string); ok {
if existing, ok := schemaMap["enum"].([]any); ok {
// Both constraints apply; preserve their intersection before dropping const.
values := []any{}
for _, value := range existing {
if text, ok := value.(string); ok && text == constant {
values = append(values, constant)
break
}
}
schemaMap["enum"] = values
}
}
if _, hasType := schemaMap["type"]; !hasType {
switch constVal.(type) {
@@ -0,0 +1,45 @@
package antigravity
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestBuildToolsPreservesStringConst(t *testing.T) {
for _, tc := range []struct {
name string
schema string
want string
}{
{"typed", `{"type":"string","const":"browser"}`, `{"type":"string","enum":["browser"]}`},
{"inferred", `{"const":"browser"}`, `{"type":"string","enum":["browser"]}`},
{"empty", `{"type":"string","const":""}`, `{"type":"string","enum":[""]}`},
{"existing enum", `{"type":"string","const":"browser","enum":["browser","shell"]}`, `{"type":"string","enum":["browser"]}`},
{"conflicting enum", `{"type":"string","const":"browser","enum":["shell"]}`, `{"type":"string","enum":[]}`},
{"ordinary enum", `{"type":"string","enum":["browser","shell"]}`, `{"type":"string","enum":["browser","shell"]}`},
{"array items", `{"type":"array","items":{"type":"string","const":"browser"}}`, `{"type":"array","items":{"type":"string","enum":["browser"]}}`},
{"nested property named const", `{"type":"object","properties":{"const":{"const":"browser"}}}`, `{"type":"object","properties":{"const":{"type":"string","enum":["browser"]}}}`},
{"schema metadata", `{"type":"string","const":"browser","$schema":"https://json-schema.org/draft/2020-12/schema","description":"Tool kind"}`, `{"type":"string","enum":["browser"],"description":"Tool kind"}`},
} {
t.Run(tc.name, func(t *testing.T) {
var property map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.schema), &property))
tools := buildTools([]ClaudeTool{{
Name: "dispatch",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"action": property},
},
}})
require.Len(t, tools, 1)
require.Len(t, tools[0].FunctionDeclarations, 1)
properties, ok := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any)
require.True(t, ok, "tool parameters must contain a properties object")
got, err := json.Marshal(properties["action"])
require.NoError(t, err)
require.JSONEq(t, tc.want, string(got))
})
}
}
@@ -40,6 +40,7 @@ type StreamingProcessor struct {
outputTokens int
cacheReadTokens int
imageOutputTokens int
hasContent bool
}
// NewStreamingProcessor 创建流式响应处理器
@@ -175,6 +176,11 @@ func (p *StreamingProcessor) MessageStartSent() bool {
return p.messageStartSent
}
// HasContent reports whether any substantive text, thinking, or tool calls were emitted.
func (p *StreamingProcessor) HasContent() bool {
return p.hasContent
}
// emitMessageStart 发送 message_start 事件
func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte {
if p.messageStartSent {
@@ -296,6 +302,7 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte {
}
if text != "" {
p.hasContent = true
_, _ = result.Write(p.emitDelta("thinking_delta", map[string]any{
"thinking": text,
}))
@@ -350,6 +357,7 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
}))
}
p.hasContent = true
_, _ = result.Write(p.emitDelta("text_delta", map[string]any{
"text": text,
}))
@@ -362,6 +370,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
var result bytes.Buffer
p.usedTool = true
p.hasContent = true
toolID := fc.ID
if toolID == "" {
@@ -1136,9 +1136,24 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
// Default effort applies (medium) even when thinking is disabled.
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "none", resp.Reasoning.Effort)
assert.Empty(t, resp.Reasoning.Summary)
}
func TestAnthropicToResponses_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.6-sol",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Thinking: &AnthropicThinking{Type: "disabled"},
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
}
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "none", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_NoThinking(t *testing.T) {
@@ -1867,6 +1882,64 @@ func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) {
require.Error(t, err)
}
func TestSonnet55ResponsesThinkingAndSampling(t *testing.T) {
for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "none"} {
req := &ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}}
out, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err, effort)
require.Zero(t, out.Thinking.BudgetTokens)
if effort == "none" {
require.Equal(t, "between_tools", out.Thinking.Type)
require.Equal(t, "low", out.OutputConfig.Effort)
} else {
require.Equal(t, "adaptive", out.Thinking.Type)
if effort == "" {
effort = "high"
}
require.Equal(t, effort, out.OutputConfig.Effort)
}
}
for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} {
_, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)})
require.ErrorContains(t, err, "forced tool_choice")
}
temperature := 0.7
_, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature})
require.ErrorContains(t, err, "temperature")
topP := 0.5
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP})
require.ErrorContains(t, err, "top_p")
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "minimal"}})
require.ErrorContains(t, err, "reasoning effort")
temperature, topP = 1, 0.99
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature, TopP: &topP})
require.NoError(t, err)
}
func TestSonnet55SignedThinkingResponsesRoundTrip(t *testing.T) {
block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "signed-sonnet-block"}
response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-sonnet-5-5", Content: []AnthropicContentBlock{block, {Type: "text", Text: "progress"}, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}})
require.Len(t, response.Output, 3)
require.NotEmpty(t, response.Output[0].EncryptedContent)
require.Equal(t, "message", response.Output[1].Type)
raw, err := json.Marshal(response.Output)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(raw, &items))
items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[2].CallID, Output: "ok"})
raw, err = json.Marshal(items)
require.NoError(t, err)
converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: raw})
require.NoError(t, err)
require.Len(t, converted.Messages, 2)
var blocks []AnthropicContentBlock
require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks))
require.Equal(t, block, blocks[0])
require.Equal(t, "text", blocks[1].Type)
require.Equal(t, "tool_use", blocks[2].Type)
}
func TestGPT6ChatSamplingAndCacheFields(t *testing.T) {
temperature := 0.7
for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} {
@@ -1906,3 +1979,16 @@ func TestMessageStartSSE_StopReasonIsJSONNull(t *testing.T) {
require.Contains(t, sse, `"stop_reason":null`)
require.NotContains(t, sse, `"stop_reason":""`)
}
func TestGPT61SolCacheOptionsAndBreakpointsSurviveChatBridge(t *testing.T) {
sampling := 0.7
for _, effort := range []string{"low", "medium", "high", "xhigh", "max"} {
out, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: "gpt-6.1-sol", ReasoningEffort: effort, Temperature: &sampling, TopP: &sampling, PromptCacheOptions: json.RawMessage(`{"ttl":"30m","mode":"explicit"}`), Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"prefix","prompt_cache_breakpoint":{"mode":"explicit"}}]`)}}})
require.NoError(t, err)
require.Nil(t, out.Temperature)
require.Nil(t, out.TopP)
require.Equal(t, effort, out.Reasoning.Effort)
require.JSONEq(t, `{"ttl":"30m","mode":"explicit"}`, string(out.PromptCacheOptions))
require.Contains(t, string(out.Input), "prompt_cache_breakpoint")
}
}
@@ -13,6 +13,9 @@ import (
// Chat Completions intermediary round-trip (e.g. thinking, cache_control,
// structured system prompts).
func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil {
return nil, err
}
input, err := convertAnthropicToResponsesInput(req.System, req.Messages)
if err != nil {
return nil, err
@@ -57,17 +60,16 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
out.Tools = convertAnthropicToolsToResponses(req.Tools)
}
// Determine reasoning effort: only output_config.effort controls the
// level; thinking.type is ignored. Default follows Codex CLI / airgate's
// Anthropic bridge shape, which uses medium when unset.
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
// An explicit thinking disable takes precedence over output_config.effort.
effort := anthropicReasoningEffort(req)
if openai.IsGPT61SolModelSpelling(req.Model) && req.OutputConfig != nil && req.OutputConfig.Effort == "max" && effort != "none" {
effort = "max"
}
out.Reasoning = &ResponsesReasoning{
Effort: mapAnthropicEffortToResponses(effort),
Summary: "auto",
Effort: effort,
}
if effort != "none" {
out.Reasoning.Summary = "auto"
}
// Convert tool_choice
@@ -423,17 +425,22 @@ func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
return strings.Join(parts, "\n\n")
}
// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
// OpenAI Responses API effort levels.
//
// Both APIs default to "high". The mapping is 1:1 for shared levels;
// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh"
// (GPT-5.2+ exclusive) as both represent the highest reasoning tier.
//
// low → low
// medium → medium
// high → high
// max → xhigh
// anthropicReasoningEffort resolves the Anthropic request preference for both
// OpenAI bridges. Explicitly disabled thinking overrides output_config.effort;
// otherwise the bridge keeps its medium default.
func anthropicReasoningEffort(req *AnthropicRequest) string {
if req.Thinking != nil && req.Thinking.Type == "disabled" {
return "none"
}
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
return mapAnthropicEffortToResponses(effort)
}
// mapAnthropicEffortToResponses maps shared effort levels directly and maps
// Anthropic's max to OpenAI's xhigh.
func mapAnthropicEffortToResponses(effort string) string {
if effort == "max" {
return "xhigh"
@@ -469,10 +476,38 @@ func boolPtr(v bool) *bool {
// isReasoningModel reports whether model is a reasoning model that does not
// support sampling parameters (temperature, top_p) via the Responses API.
// All gpt-5.x models are reasoning-only; the Responses API returns
// "Unsupported parameter: temperature" if these fields are present.
// GPT-5 and every later generation are reasoning-only; the Responses API
// returns "Unsupported parameter: temperature" if these fields are present.
//
// Keyed on the generation number instead of a "gpt-5" prefix: pinning the
// prefix meant each new family (gpt-6-astra and whatever follows) silently
// fell through to the sampling branch and failed upstream on every compat
// request until someone edited this line.
func isReasoningModel(model string) bool {
return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model)
major, ok := openAIModelGeneration(model)
return (ok && major >= 5) || openai.IsGPT6SolOrLunaModelSpelling(model)
}
// openAIModelGeneration extracts N from a "gpt-N[.M][-suffix]" model id.
// ok is false for non-GPT ids and for GPT families that carry no numeric
// generation (gpt-image-1, gpt-audio, ...).
func openAIModelGeneration(model string) (int, bool) {
rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-")
if !ok {
return 0, false
}
major, digits := 0, 0
for _, r := range rest {
if r < '0' || r > '9' {
break
}
major = major*10 + int(r-'0')
digits++
}
if digits == 0 {
return 0, false
}
return major, true
}
// normalizeToolParameters ensures the tool parameter schema is valid for
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -52,7 +53,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
for _, block := range resp.Content {
switch block.Type {
case "thinking", "redacted_thinking":
if claude.IsOpus55(resp.Model) && (block.Signature != "" || block.Data != "") {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && (block.Signature != "" || block.Data != "") {
item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)}
if block.Thinking != "" {
item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}}
@@ -71,7 +72,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
})
}
case "text":
if claude.IsOpus55(resp.Model) && block.Text != "" {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && block.Text != "" {
outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}})
continue
}
@@ -198,6 +199,12 @@ type AnthropicEventToResponsesState struct {
CurrentThinking AnthropicContentBlock
PreserveThinkingSignatures bool
// PendingToolInput holds tool arguments that arrived complete on
// content_block_start instead of as input_json_delta. It is only consumed at
// content_block_stop, and only when no delta ever arrived, so a canonical
// Anthropic stream keeps its exact event sequence.
PendingToolInput string
// Outputs accumulates every closed output item so that response.completed
// can carry the full output list. The OpenAI SDK's get_final_response()
// parses the terminal event's response directly; without this, clients see
@@ -278,7 +285,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) {
func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Message != nil {
state.ResponseID = evt.Message.ID
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model)
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model)
if state.Model == "" {
state.Model = evt.Message.Model
}
@@ -382,6 +389,12 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi
state.CurrentItemType = "function_call"
state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID)
state.CurrentName = evt.ContentBlock.Name
// The canonical Anthropic stream leaves input empty here and streams the
// arguments as input_json_delta, but Anthropic-compatible relays may put
// the complete arguments on this event and never send a delta. Keep them
// as a seed rather than emitting now: a delta, if one follows, is
// authoritative and must not be concatenated onto this JSON.
state.PendingToolInput = seedToolArguments(evt.ContentBlock.Input)
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
@@ -432,6 +445,9 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi
if evt.Delta.PartialJSON == "" {
return nil
}
// A real delta supersedes whatever content_block_start carried; keeping
// both would splice two complete JSON documents together.
state.PendingToolInput = ""
state.CurrentArgs += evt.Delta.PartialJSON
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
@@ -467,21 +483,35 @@ func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *Anthropic
return events
case "function_call":
var events []ResponsesStreamEvent
// No delta ever arrived, so the arguments the upstream put on
// content_block_start are all there is. Emit them as one delta here so
// the done event below still repeats exactly what the deltas streamed.
if state.CurrentArgs == "" && state.PendingToolInput != "" {
state.CurrentArgs = state.PendingToolInput
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Delta: state.PendingToolInput,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
}))
}
state.PendingToolInput = ""
// Emit function_call_arguments.done + output item done.
// arguments must repeat exactly what the deltas already streamed for this
// item: clients reconcile the done event against the accumulated
// function_call_arguments.delta payloads and reject the call as
// inconsistent_tool_call when the two disagree. Omitting the field left it
// empty while the deltas carried the whole JSON.
events := []ResponsesStreamEvent{
makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
Arguments: state.CurrentArgs,
}),
}
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
Arguments: state.CurrentArgs,
}))
events = append(events, closeCurrentResponsesItem(state)...)
return events
@@ -562,6 +592,19 @@ func anthropicResponsesStreamTerminalState(stopReason string) (string, *Response
return "completed", nil
}
// seedToolArguments normalizes a tool_use content block's inline input into a
// seed for the streaming converter. Empty, absent and no-argument payloads
// return "" so the existing "{}" fallback still applies and no empty delta is
// synthesized.
func seedToolArguments(input json.RawMessage) string {
trimmed := strings.TrimSpace(string(input))
switch trimmed {
case "", "{}", "null":
return ""
}
return trimmed
}
func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if state.CurrentItemType == "" {
return nil
@@ -605,6 +648,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response
state.CurrentName = ""
state.CurrentContent = nil
state.CurrentArgs = ""
state.PendingToolInput = ""
state.CurrentSummary = ""
state.CurrentThinking = AnthropicContentBlock{}
state.TextAccum = ""
@@ -0,0 +1,168 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// collectToolCallStreamEvents drives a tool_use block through the Anthropic →
// Responses stream converter and returns every emitted event in order.
func collectToolCallStreamEvents(t *testing.T, blockInput json.RawMessage, partials []string) []ResponsesStreamEvent {
t.Helper()
state := NewAnthropicEventToResponsesState()
var all []ResponsesStreamEvent
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_tool_input",
Model: "gemini-3.7-flash",
Usage: AnthropicUsage{InputTokens: 12},
},
}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_start",
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_01abc",
Name: "eval",
Input: blockInput,
},
}, state)...)
for _, partial := range partials {
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
}, state)...)
}
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "content_block_stop"}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_delta",
Usage: &AnthropicUsage{OutputTokens: 9},
}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)...)
return all
}
func concatArgumentDeltas(events []ResponsesStreamEvent) string {
out := ""
for _, e := range events {
if e.Type == "response.function_call_arguments.delta" {
out += e.Delta
}
}
return out
}
func findFunctionCallOutput(events []ResponsesStreamEvent) *ResponsesOutput {
for i := range events {
if events[i].Type != "response.output_item.done" || events[i].Item == nil {
continue
}
if events[i].Item.Type == "function_call" {
return events[i].Item
}
}
return nil
}
// Upstreams that are not the canonical Anthropic API (here: an Anthropic-compatible
// relay fronting Gemini) put the complete tool arguments on content_block_start and
// never emit an input_json_delta. The converter must not drop them: this repository
// already reads ContentBlock.Input on the non-streaming path
// (anthropicResponseToResponsesOutputs) and on the ChatCompletions bridge, so the
// streaming path has to agree. Dropping it makes every client-side tool call arrive
// with `{}` and the agent loop cannot proceed.
func TestAnthropicEventToResponses_ToolInputOnContentBlockStart(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(args), nil)
item := findFunctionCallOutput(events)
require.NotNil(t, item, "a function_call output item must be emitted")
assert.Equal(t, args, item.Arguments,
"arguments carried on content_block_start must survive to output_item.done")
assert.Equal(t, "eval", item.Name)
// Clients that accumulate from the event stream (rather than reading the
// terminal item) must see the arguments too.
assert.Equal(t, args, concatArgumentDeltas(events),
"argument deltas must reconstruct the full arguments JSON")
done := findEvent(events, "response.function_call_arguments.done")
require.NotNil(t, done, "function_call_arguments.done must be emitted")
assert.Equal(t, args, done.Arguments,
"function_call_arguments.done must carry the complete arguments")
completed := findEvent(events, "response.completed")
require.NotNil(t, completed)
require.NotNil(t, completed.Response)
require.Len(t, completed.Response.Output, 1)
assert.Equal(t, args, completed.Response.Output[0].Arguments,
"response.completed must carry the same arguments")
}
// The canonical Anthropic shape (empty input on content_block_start, arguments
// streamed as input_json_delta) must keep its existing event sequence exactly.
func TestAnthropicEventToResponses_ToolInputFromDeltasUnchanged(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(`{}`),
[]string{`{"language":"py",`, `"code":"print(1)"}`})
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, args, item.Arguments)
// Exactly the two upstream deltas, not duplicated by the seed.
var deltas []string
for _, e := range events {
if e.Type == "response.function_call_arguments.delta" {
deltas = append(deltas, e.Delta)
}
}
assert.Equal(t, []string{`{"language":"py",`, `"code":"print(1)"}`}, deltas,
"canonical delta streaming must not gain or lose events")
}
// An upstream that sends both a populated content_block_start and real deltas
// must not have the two concatenated into malformed JSON.
func TestAnthropicEventToResponses_ToolInputSeedNotDuplicatedByDeltas(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(args),
[]string{`{"language":"py",`, `"code":"print(1)"}`})
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, args, item.Arguments, "deltas win over the content_block_start seed")
assert.True(t, json.Valid([]byte(item.Arguments)), "arguments must stay valid JSON")
assert.Equal(t, args, concatArgumentDeltas(events), "seed must not be replayed alongside deltas")
}
// An empty or absent input on content_block_start with no deltas keeps the
// existing "{}" fallback rather than emitting a spurious delta.
func TestAnthropicEventToResponses_ToolInputEmptyStaysEmptyObject(t *testing.T) {
for name, input := range map[string]json.RawMessage{
"absent": nil,
"empty": json.RawMessage(`{}`),
} {
t.Run(name, func(t *testing.T) {
events := collectToolCallStreamEvents(t, input, nil)
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, "{}", item.Arguments)
assert.Empty(t, concatArgumentDeltas(events),
"no argument delta should be synthesized when there are no arguments")
})
}
}
@@ -0,0 +1,131 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// driveToolUseThroughRestorer runs a tool_use block through the full outbound
// chain the gateway actually uses: the Anthropic→Responses stream converter
// feeding ResponsesClientToolStreamRestorer. Returns the events a client sees.
func driveToolUseThroughRestorer(
blockInput json.RawMessage,
partials []string,
mapping ResponsesClientToolMapping,
) []ResponsesStreamEvent {
state := NewAnthropicEventToResponsesState()
restorer := NewResponsesClientToolStreamRestorer(mapping)
var clientEvents []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
for _, converted := range AnthropicEventToResponsesEvents(evt, state) {
clientEvents = append(clientEvents, restorer.Restore(converted)...)
}
}
feed(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_chain",
Model: "gemini-3.7-flash",
Usage: AnthropicUsage{InputTokens: 11},
},
})
feed(&AnthropicStreamEvent{
Type: "content_block_start",
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_chain",
Name: "eval",
Input: blockInput,
},
})
for _, partial := range partials {
feed(&AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
})
}
feed(&AnthropicStreamEvent{Type: "content_block_stop"})
feed(&AnthropicStreamEvent{Type: "message_delta", Usage: &AnthropicUsage{OutputTokens: 6}})
feed(&AnthropicStreamEvent{Type: "message_stop"})
return clientEvents
}
func firstEventOfType(events []ResponsesStreamEvent, typ string) *ResponsesStreamEvent {
for i := range events {
if events[i].Type == typ {
return &events[i]
}
}
return nil
}
// A client-side custom tool whose arguments arrived only on content_block_start
// must still reach the client as a populated custom_tool_call. The restorer
// suppresses function_call_arguments.delta for adapted tools and rebuilds the
// input from the accumulated arguments, so a converter that drops the inline
// input yields an empty tool call even though nothing errors.
func TestToolInputOnContentBlockStart_SurvivesClientToolRestore(t *testing.T) {
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
events := driveToolUseThroughRestorer(
json.RawMessage(`{"input":"print(1)"}`), nil, mapping)
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
require.NotNil(t, inputDone, "custom_tool_call_input.done must be emitted")
assert.Equal(t, "print(1)", inputDone.Input,
"inline tool input must survive conversion and client-tool restoration")
var itemDone *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
events[i].Item.Type == "custom_tool_call" {
itemDone = &events[i]
}
}
require.NotNil(t, itemDone, "a custom_tool_call item must be closed")
assert.Equal(t, "print(1)", itemDone.Item.Input)
}
// The same block delivered as a plain (non-adapted) function tool must carry
// its arguments through untouched.
func TestToolInputOnContentBlockStart_PlainFunctionToolChain(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := driveToolUseThroughRestorer(
json.RawMessage(args), nil, ResponsesClientToolMapping{})
done := firstEventOfType(events, "response.function_call_arguments.done")
require.NotNil(t, done)
assert.Equal(t, args, done.Arguments)
var itemDone *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
events[i].Item.Type == "function_call" {
itemDone = &events[i]
}
}
require.NotNil(t, itemDone)
assert.Equal(t, args, itemDone.Item.Arguments)
}
// Canonical delta streaming through the same chain must keep working, so the
// seed path cannot be mistaken for the only source of arguments.
func TestToolInputFromDeltas_SurvivesClientToolRestore(t *testing.T) {
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
events := driveToolUseThroughRestorer(
json.RawMessage(`{}`),
[]string{`{"input":"pri`, `nt(1)"}`},
mapping)
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
require.NotNil(t, inputDone)
assert.Equal(t, "print(1)", inputDone.Input)
}
@@ -99,13 +99,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR
}
}
// Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type
// itself is ignored (the Responses bridge behaves identically).
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
out.ReasoningEffort = mapAnthropicEffortToResponses(effort)
// Match the Responses bridge, including an explicit thinking disable.
out.ReasoningEffort = anthropicReasoningEffort(req)
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
@@ -150,6 +150,20 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) {
require.Empty(t, out.Messages[0].ReasoningContent)
}
func TestAnthropicToChatCompletionsRequest_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.6-luna",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Thinking: &AnthropicThinking{Type: "disabled"},
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
}
out, err := AnthropicToChatCompletionsRequest(req)
require.NoError(t, err)
require.Equal(t, "none", out.ReasoningEffort)
}
func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) {
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
@@ -92,6 +92,56 @@ func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
assert.Equal(t, "user", items[1].Role)
}
func TestChatCompletionsToResponses_MessageTypesWithReasoningAndTools(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "step-5-preview",
Messages: []ChatMessage{
{Role: "system", Content: json.RawMessage(`"You are helpful."`)},
{Role: "user", Content: json.RawMessage(`"Check the directory"`)},
{
Role: "assistant",
Content: json.RawMessage(`"I will check."`),
ReasoningContent: "Need to inspect the directory.",
ToolCalls: []ChatToolCall{{
ID: "call_1", Type: "function",
Function: ChatFunctionCall{Name: "bash", Arguments: `{"cmd":"pwd"}`},
}},
},
{Role: "tool", ToolCallID: "call_1", Content: json.RawMessage(`"/tmp"`)},
{Role: "assistant", ReasoningContent: "The tool returned /tmp."},
{Role: "user", Content: json.RawMessage(`"Continue"`)},
},
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 7)
for i, want := range []struct{ typ, role string }{
{"message", "system"},
{"message", "user"},
{"message", "assistant"},
{"function_call", ""},
{"function_call_output", ""},
{"message", "assistant"},
{"message", "user"},
} {
assert.Equal(t, want.typ, items[i].Type, "input item %d type", i)
assert.Equal(t, want.role, items[i].Role, "input item %d role", i)
}
var assistantContent, reasoningOnlyContent []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[2].Content, &assistantContent))
require.NoError(t, json.Unmarshal(items[5].Content, &reasoningOnlyContent))
require.Len(t, assistantContent, 1)
require.Len(t, reasoningOnlyContent, 1)
assert.Equal(t, "<thinking>Need to inspect the directory.</thinking>\nI will check.", assistantContent[0].Text)
assert.Equal(t, "<thinking>The tool returned /tmp.</thinking>", reasoningOnlyContent[0].Text)
assert.Equal(t, "call_1", items[3].CallID)
assert.Equal(t, "call_1", items[4].CallID)
}
func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
@@ -18,6 +18,9 @@ type chatMessageContent struct {
// true. store is always false and reasoning.encrypted_content is always
// included so that the response translator has full context.
func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) {
if err := openai.ValidateGPT61SolReasoningEffort(req.Model, req.ReasoningEffort); err != nil {
return nil, err
}
input, err := convertChatMessagesToResponsesInput(req.Messages)
if err != nil {
return nil, err
@@ -143,7 +146,7 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "system", Content: content}}, nil
return []ResponsesInputItem{{Type: "message", Role: "system", Content: content}}, nil
}
// chatUserToResponses converts a user message, handling both plain strings and
@@ -157,7 +160,7 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
return []ResponsesInputItem{{Type: "message", Role: "user", Content: content}}, nil
}
// chatAssistantToResponses converts an assistant message. If there is both
@@ -192,7 +195,7 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
}
// Emit one function_call item per tool_call.
@@ -0,0 +1,78 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// GPT-5 and every later generation are reasoning-only: forwarding temperature or
// top_p makes the Responses API reject the request with "Unsupported parameter".
// The check is keyed on the generation number so a new family (gpt-6-astra and
// whatever follows) does not silently fall into the sampling branch.
func TestIsReasoningModelCoversLaterGenerations(t *testing.T) {
for _, tc := range []struct {
model string
want bool
}{
{"gpt-6-astra", true},
{"gpt-6", true},
{"gpt-7-whatever", true},
{"gpt-6-sol", true},
{"gpt-5.5", true},
{"gpt-5.2", true},
{"gpt-5", true},
{"GPT-6-Astra", true},
{" gpt-6-astra ", true},
{"gpt-4o", false},
{"gpt-4.1", false},
{"gpt-image-1", false},
{"gpt-audio", false},
{"claude-opus-4-6", false},
{"gemini-3.1-pro", false},
{"", false},
} {
if got := isReasoningModel(tc.model); got != tc.want {
t.Errorf("isReasoningModel(%q) = %v, want %v", tc.model, got, tc.want)
}
}
}
func TestOpenAIModelGeneration(t *testing.T) {
for _, tc := range []struct {
model string
wantMajor int
wantOK bool
}{
{"gpt-6-astra", 6, true},
{"gpt-5.5", 5, true},
{"gpt-4o", 4, true},
{"gpt-10-future", 10, true},
{"gpt-image-1", 0, false},
{"claude-opus-4-6", 0, false},
{"", 0, false},
} {
major, ok := openAIModelGeneration(tc.model)
if major != tc.wantMajor || ok != tc.wantOK {
t.Errorf("openAIModelGeneration(%q) = (%d, %v), want (%d, %v)",
tc.model, major, ok, tc.wantMajor, tc.wantOK)
}
}
}
func TestAnthropicToResponses_TemperatureStrippedForGPT6Astra(t *testing.T) {
temp := 0.7
req := &AnthropicRequest{
Model: "gpt-6-astra",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Temperature: &temp,
TopP: &temp,
}
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.Nil(t, resp.Temperature, "gpt-6-astra is reasoning-only: temperature must be stripped")
require.Nil(t, resp.TopP, "gpt-6-astra is reasoning-only: top_p must be stripped")
}
@@ -109,7 +109,23 @@ func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T)
require.NotContains(t, string(messages[0].Content), "drop me")
}
// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
// data URI 形式的 input_file 要变成 Anthropic document,供后续 Gemini inlineData 使用。
func TestResponsesToAnthropic_InputFileDataURIBecomesDocument(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[
{"type":"input_text","text":"read this"},
{"type":"input_file","filename":"token.pdf","file_data":"data:application/pdf;base64,JVBERi0="}
]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.Contains(t, string(messages[0].Content), `"type":"document"`)
require.Contains(t, string(messages[0].Content), `"media_type":"application/pdf"`)
require.Contains(t, string(messages[0].Content), `"data":"JVBERi0="`)
}
// 只有 file_id、没有 data URI 的 input_file 仍然无法转换,整条消息丢掉。
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
@@ -14,7 +14,9 @@ import (
// enables Anthropic platform groups to accept OpenAI Responses API requests
// by converting them to the native /v1/messages format before forwarding upstream.
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model))
isOpus55 := claude.IsOpus55(req.Model)
isSonnet55 := claude.IsSonnet55(req.Model)
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55)
if err != nil {
return nil, err
}
@@ -54,9 +56,11 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
out.ToolChoice = tc
}
// Opus 5.5 always uses adaptive thinking. Resolve the upstream model before
// conversion, because client aliases need not identify a Claude model.
if claude.IsOpus55(req.Model) {
// The 5.5 models reject manual thinking and forced tool use. Sonnet 5.5
// additionally supports between_tools to disable up-front thinking.
// Resolve the upstream model before conversion: client aliases need not
// identify a Claude model.
if isOpus55 || isSonnet55 {
var choice struct {
Type string `json:"type"`
}
@@ -66,16 +70,36 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
}
}
if choice.Type == "any" || choice.Type == "tool" {
return nil, fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none")
return nil, fmt.Errorf("%s does not support forced tool_choice; use auto or none", req.Model)
}
effort := "medium"
if isSonnet55 {
effort = "high"
if req.Temperature != nil && *req.Temperature != 1 {
return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature")
}
if req.TopP != nil && (*req.TopP < 0.99 || *req.TopP > 1) {
return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p")
}
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
effort = req.Reasoning.Effort
}
if isSonnet55 && effort == "none" {
// OpenAI's no-reasoning request maps to Sonnet 5.5's lowest
// thinking mode. between_tools still preserves signed progress
// blocks produced during tool use.
out.Thinking = &AnthropicThinking{Type: "between_tools"}
if out.OutputConfig == nil {
out.OutputConfig = &AnthropicOutputConfig{}
}
out.OutputConfig.Effort = "low"
return out, nil
}
switch effort {
case "low", "medium", "high", "xhigh", "max":
default:
return nil, fmt.Errorf("claude-opus-5-5 does not support reasoning effort %q; use low, medium, high, xhigh or max", effort)
return nil, fmt.Errorf("%s does not support reasoning effort %q; use low, medium, high, xhigh or max", req.Model, effort)
}
out.Thinking = &AnthropicThinking{Type: "adaptive"}
out.OutputConfig = &AnthropicOutputConfig{Effort: effort}
@@ -530,6 +554,14 @@ func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessag
Source: src,
})
}
case "input_file":
src := dataURIToAnthropicFileSource(p.FileData)
if src != nil {
blocks = append(blocks, AnthropicContentBlock{
Type: "document",
Source: src,
})
}
}
}
@@ -617,6 +649,12 @@ func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource {
}
}
// dataURIToAnthropicFileSource parses a data URI into a document source.
// file_id-only parts are not convertible here and stay dropped.
func dataURIToAnthropicFileSource(fileData string) *AnthropicImageSource {
return dataURIToAnthropicImageSource(fileData)
}
// mergeConsecutiveMessages merges consecutive messages with the same role
// because Anthropic requires alternating user/assistant turns.
func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
@@ -609,6 +609,24 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
return
}
// The terminal event can carry a non-empty output array whose message has
// no usable text. Trusting it as-is silently drops the text that already
// streamed: the client gets an empty reply while usage still bills the
// terminal output_tokens. Refill it from the accumulated deltas; non-empty
// terminal text stays authoritative.
if a.text.Len() > 0 && !responsesOutputHasText(resp.Output) {
if !fillResponsesOutputText(resp.Output, a.text.String()) {
resp.Output = append(resp.Output, ResponsesOutput{
Type: "message",
Role: "assistant",
Content: []ResponsesContentPart{{
Type: "output_text",
Text: a.text.String(),
}},
})
}
}
for outputIndex := range resp.Output {
item := &resp.Output[outputIndex]
if item.Type != "function_call" || item.Arguments != "" {
@@ -627,3 +645,46 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
}
}
}
// responsesOutputHasText reports whether the terminal output already carries
// usable message text. Whitespace-only text does not count.
func responsesOutputHasText(output []ResponsesOutput) bool {
for i := range output {
if output[i].Type != "message" {
continue
}
for _, part := range output[i].Content {
if part.Type == "output_text" && strings.TrimSpace(part.Text) != "" {
return true
}
}
}
return false
}
// fillResponsesOutputText writes text into the first empty output_text part of
// the first message item, adding an output_text part when that message has
// none. It returns false when the output holds no message item, leaving the
// caller to append one.
func fillResponsesOutputText(output []ResponsesOutput, text string) bool {
for i := range output {
if output[i].Type != "message" {
continue
}
for j := range output[i].Content {
if output[i].Content[j].Type != "output_text" {
continue
}
if strings.TrimSpace(output[i].Content[j].Text) == "" {
output[i].Content[j].Text = text
return true
}
}
output[i].Content = append(output[i].Content, ResponsesContentPart{
Type: "output_text",
Text: text,
})
return true
}
return false
}

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