fix(billing): harden tiered retry group-switch billing (follow-up to #6518) - #6570
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
WalkthroughThe relay flow records and validates channels before pricing. Tiered billing updates ChangesRelay billing flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
service/billing_session.go (1)
232-256: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftDo not use a separate read-then-unconditional-write path for wallet balance.
reserveFundingreadsmodel.GetUserQuota(funding.userId, false), checksuserQuota-delta < 0, and then callsmodel.DecreaseUserQuota(funding.userId, delta, false).DecreaseUserQuotaupdatesquota = quota - ?without aquota >= ?guard, andReserveonly serializes calls on the sameBillingSession. Two concurrent wallet requests can both pass the read check and both decrement, leaving the wallet balance negative. Use an atomic conditional update that rejectsquota - ? < 0(including handling rows-affected), or a per-user lock around the read/decrement sequence, including the initial pre-consume wallet path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/billing_session.go` around lines 232 - 256, Replace the separate GetUserQuota check and unconditional DecreaseUserQuota call in BillingSession.reserveFunding with an atomic quota decrement guarded by sufficient balance, and handle zero rows affected as insufficient_user_quota while preserving database errors as update_data_error. Apply the same synchronization or conditional-update protection to the initial wallet pre-consume path used by tryWallet so concurrent requests cannot overdraw a user wallet.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@controller/relay.go`:
- Around line 314-324: The retry path computes the group ratio before the
selected retry group is available in the context, so `HandleGroupRatio` may use
the wrong group. Update the call to `helper.HandleGroupRatio` in the retry flow
to receive or otherwise use `selectGroup`, ensuring `info.UsingGroup` reflects
the retry-selected group before `PriceData.GroupRatioInfo` is assigned.
---
Outside diff comments:
In `@service/billing_session.go`:
- Around line 232-256: Replace the separate GetUserQuota check and unconditional
DecreaseUserQuota call in BillingSession.reserveFunding with an atomic quota
decrement guarded by sufficient balance, and handle zero rows affected as
insufficient_user_quota while preserving database errors as update_data_error.
Apply the same synchronization or conditional-update protection to the initial
wallet pre-consume path used by tryWallet so concurrent requests cannot overdraw
a user wallet.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 926a473f-4135-4d79-86e3-a8333c0d1610
📒 Files selected for processing (4)
controller/relay.goservice/billing_session.goservice/tiered_settle.goservice/tiered_settle_test.go
| channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) | ||
|
|
||
| info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) | ||
|
|
||
| if err != nil { | ||
| return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) | ||
| } | ||
| if channel == nil { | ||
| return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) | ||
| } | ||
|
|
||
| info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) | ||
|
|
||
| newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "func HandleGroupRatio" --type go -A 40
echo "---"
rg -n "func SetupContextForSelectedChannel" --type go -A 40
echo "---"
rg -n "UsingGroup\s*=" --type goRepository: QuantumNous/new-api
Length of output: 157
🏁 Script executed:
#!/bin/bash
set -u
echo "Go files:"
git ls-files '*.go' | wc -l
echo "--- locate candidate files by basename ---"
fd -a 'relay.go|tiered_settle.go|group|group_ratio|json.go' . | sed 's#^\./##' | head -200
echo "--- search symbols with case-insensitive text search ---"
rg -n -i 'HandleGroupRatio|SetupContextForSelectedChannel|UsingGroup|SelectGroup|selectGroup|refreshTieredBillingGroup' . | head -300
echo "--- git diff stat/summary if available ---"
git diff --stat || trueRepository: QuantumNous/new-api
Length of output: 32906
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- relay helper price.go ---"
cat -n relay/helper/price.go | sed -n '1,110p'
echo "--- controller/relay.go relevant section ---"
cat -n controller/relay.go | sed -n '260,350p'
echo "--- middleware auth context key setter ---"
cat -n middleware/auth.go | sed -n '455,480p'
echo "--- relay common relay_info.go BuildFromContext section ---"
cat -n relay/common/relay_info.go | sed -n '430,495p'
echo "--- middleware distributor SetupContextForSelectedChannel ---"
cat -n middleware/distributor.go | sed -n '440,490p'
echo "--- tiered settle refreshTieredBillingGroup ---"
cat -n service/tiered_settle.go | sed -n '85,105p'Repository: QuantumNous/new-api
Length of output: 15711
Thread the selected retry group into HandleGroupRatio.
HandleGroupRatio(c, info) sets relayInfo.UsingGroup from the auto_group context value, but controller/relay.go:322 calls it before middleware.SetupContextForSelectedChannel sets that value. In the retry path, pass selectGroup to the helper or set auto_group/info.UsingGroup with the retry-selected group before computing the group ratio.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@controller/relay.go` around lines 314 - 324, The retry path computes the
group ratio before the selected retry group is available in the context, so
`HandleGroupRatio` may use the wrong group. Update the call to
`helper.HandleGroupRatio` in the retry flow to receive or otherwise use
`selectGroup`, ensuring `info.UsingGroup` reflects the retry-selected group
before `PriceData.GroupRatioInfo` is assigned.
Follow-up to #6518 (issue #6480) addressing three review findings: - Document and lock in arrears semantics for the wallet Reserve top-up: when an auto-group retry lands on a more expensive group, the full reservation delta is deducted unconditionally (balance may go negative), mirroring settlement, so the logged pre-consumed quota always reconciles with the actual balance movement. Genuine DB errors still fail the attempt with update_data_error. Subscription funding keeps its insufficient-quota behavior: subscriptions enforce a hard used<=total cap and do not support arrears. - PriceData.FreeModel is cleared when a retry switches from a free group to a paid one, keeping it consistent with the billing session created at that point. - getChannel refreshes GroupRatioInfo only after channel selection succeeds, and the retry loop records the channel in use_channel before PrepareTieredBillingForSelectedGroup can fail.
dd924e5 to
e5aa3f5
Compare
Upstream 2026-07-31..2026-08-01: deepseek responses api (QuantumNous#6562), zstd request decompression (QuantumNous#6545), log stream status (QuantumNous#6558), tiered retry billing settlement (QuantumNous#6518, QuantumNous#6570), OAuth opener fix (QuantumNous#6425), multipart image edit fix (QuantumNous#6559), public header nav style (QuantumNous#6557). Conflict resolution: - controller/relay.go: keep per-channel rate limit check (custom) and upstream PrepareTieredBillingForSelectedGroup call - relay/common/relay_info.go: keep RequestDebugSnapshot (custom) and upstream TieredBillingSnapshot comment
) Follow-up to QuantumNous#6518 (issue QuantumNous#6480) addressing three review findings: - Document and lock in arrears semantics for the wallet Reserve top-up: when an auto-group retry lands on a more expensive group, the full reservation delta is deducted unconditionally (balance may go negative), mirroring settlement, so the logged pre-consumed quota always reconciles with the actual balance movement. Genuine DB errors still fail the attempt with update_data_error. Subscription funding keeps its insufficient-quota behavior: subscriptions enforce a hard used<=total cap and do not support arrears. - PriceData.FreeModel is cleared when a retry switches from a free group to a paid one, keeping it consistent with the billing session created at that point. - getChannel refreshes GroupRatioInfo only after channel selection succeeds, and the retry loop records the channel in use_channel before PrepareTieredBillingForSelectedGroup can fail.
合并上游 16 个提交,主要是 token Auto 分组 (QuantumNous#6590)、deepseek responses API (QuantumNous#6562)、Bedrock 客户端断开取消 (QuantumNous#6589)、分层重试计费加固 (QuantumNous#6518/QuantumNous#6570)、 zstd 请求解压 (QuantumNous#6545)、OIDC 自定义显示名 (QuantumNous#6012)、日志暴露 stream_status (QuantumNous#6558)。 33 处冲突的处理: - 11 个 legacy channel adaptor:上游删除 panic 之后的死代码以配合新增的 go vet CI,本仓早已把整段 panic 换成返回 ErrLegacyAdaptorNotImplemented, 已达成同一目的且不会让中继链路 panic,保留本仓实现。 - relay-aws.go 流式循环:两侧改动正交,合并保留 —— 上游的 ctx.Done() 取消 分支(客户端断开时不再空转上游),加本仓的 finalizeClaudeOnError(提前 返回时补发流终止符,否则 Claude 格式调用方会一直挂着)。 - 7 个 locale:两侧各自插入相邻 key,按字母序归并;上游 24 个新 key 与本仓 1278 个 fork key 全部保留,逐一核对无丢失。 - keys / oauth / profile 前端:上游 Auto 分组为主体,叠加本仓改动; account-bindings-tab 与 oauth/$provider 保留本仓实现,理由见下。 测试取舍: - 不引入 model/token_auto_groups_cache_test.go:它依赖上游的 truncateTables, 该助手全局清表,与本仓行级清理原则冲突(共享库里有开发数据)。 - 不携带上游 controller/token_test.go:其迁移兼容测试会替换 model.DB 并在 cleanup 里关闭,后续用 harness 连接的测试会拿到已关闭的句柄。按既有约定, token_auto_groups_test.go 需要的四个助手放进 zz_upstream_test_shims_test.go, 其中 openTokenControllerTestDB 增加了 model.DB/LOG_DB 的成对保存还原。 本仓原 token_test.go 的 controller 行为测试迁到 gen_ctrl_token_handlers_test.go,与上游文件名脱钩以免再冲突。 - 三处断言随上游行为更新:stream_status 现对日志所有者可见;deepseek 的 ConvertOpenAIResponsesRequest 已实现不再返回错误;计费路径判定收紧为 「标记之外还需带对应 usage payload」,并补了三个反向用例锁住该语义。 遗留:oauth 绑定回调仍用 window.opener 判定 bind/login,上游 QuantumNous#6425 已改为 sessionStorage 标记 + state 比对。该修复要求 popup 先以 about:blank 打开再打 标记,本仓四个内置 provider 走 window.open(url) 直开,直接套用会让绑定永远 判成 login,故本次未采纳,需单独改造。
Sync upstream/main (rc.11 → rc.23, 46 commits) into main. Key upstream changes: QuantumNous#6369 relaykit module extraction (dto/types/service/relayconvert moved to relaykit/ submodule with require+replace), QuantumNous#6518/QuantumNous#6570 tiered retry billing, QuantumNous#6558 stream status exposed to log owners, QuantumNous#6562 DeepSeek responses API, QuantumNous#6590 auto group, QuantumNous#6545 zstd decompression, per-channel HTTP transport controls. Conflicts resolved: - constant/api_type.go, constant/channel.go: keep LH channel numbering (OspreyAI=58/HappyHorse=59/Seedance=60/AdvancedCustom=61 via reserved placeholders), append upstream Sub2API=62/NewAPI=63 before Dummy. - relay/common/relay_info_test.go: union imports (LH common2/constant/gin + upstream convmeta/relaykit-types/assert), keep both test sets. LH-only packages (ospreyai/happyhorse/seedance/task variants) had stale host dto/types imports after relaykit extraction — migrated to relaykit/dto and relaykit/types (task-family TaskError kept on host dto via taskdto alias, matching upstream doubao pattern). Co-Authored-By: Claude <noreply@anthropic.com>
合并官方上游 Calcium-Ion/new-api main 分支的 10 个新 commit: - Feat/auto group (QuantumNous#6590) - fix(aws): cancel Bedrock requests on client disconnect (QuantumNous#6589) - fix(billing): harden tiered retry group-switch billing (QuantumNous#6570) - fix(billing): settle tiered retries with final group (QuantumNous#6518) - feat: deepseek responses api (QuantumNous#6562) - fix(oauth): stop treating a foreign window.opener as a bind flow (QuantumNous#6425) - fix(relay): preserve multipart image edits for New API channels (QuantumNous#6559) - feat(logs): expose stream status to log owners (QuantumNous#6558) - feat: support zstd request decompression (QuantumNous#6545) - style: use text-sm for public header nav links (QuantumNous#6557) 冲突解决: model/option.go 的 switch case 区域,双方各自新增了选项注册 (我方 GroupPassThrough + 官方 MaxTokenAutoGroups),取并集保留两者。 其余文件均自动合并成功。 验证: go build + go test 全过(relay/helper、middleware、controller)
Sync 71 upstream commits (rc.21 -> rc.23). Major upstream changes adopted: - stateless-token dashboard auth (sessions removed); fork site scoping re-grafted fail-closed via model.GetUserSiteId in setDashboardAuthContext - relaykit module extraction; fork billing guards, error override, and O(N^2) DoS fix re-applied inside relaykit paths - web/default -> web frontend move; web/classic theme deleted (fork's classic-only edits verified ported to the new tree before deletion) - auto-group (QuantumNous#6590), per-channel HTTP transport, tool pricing, zstd, New API channel, tiered-retry billing fixes (QuantumNous#6518/QuantumNous#6570) Fork features preserved and verified: multi-site scoping, tickets, captcha suite (per-request verification now, no session memo), email campaigns, request-body recording, epay three-path settlement, quota saturation invariants, model-redirect hiding, token multi-group priority. Notable resolutions: - 76 fork-only frontend files missed by rename detection were manually relocated from web/default/src to web/src (tickets/referral/site-admin/ sub-site-management/contact/affiliate + routes + locale extras) - altcha dependency restored in web/package.json - getModelListGroups adopts upstream empty-on-filtered-snapshot semantics - relayconvert goldens regenerated for fork's BillingUsage json:"-" privacy - upstream tests adapted to fork signatures (site scope args, SSRF-off TestMain in controller) Semantic changes accepted from upstream: external identity binding is now platform-global (was per-site); captcha verification is per-request. go build/vet/test green in root and relaykit modules.
* v1.0.0-rc.24: (117 commits) CI: enhance release synchronization workflow with optional file syncing fix: 修复兑换码额度精度损失 (QuantumNous#6685) feat(rate-limit): add user critical rate limit middleware for access token and aff transfer routes fix: test Claude/Gemini endpoints with native request format (QuantumNous#6698) feat(channels): refine fetched model categorization (QuantumNous#6632) Merge commit from fork refactor(relay): move replay metadata onto request bodies fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset (QuantumNous#6249) Feat/auto group (QuantumNous#6590) fix(aws): cancel Bedrock requests on client disconnect (QuantumNous#6589) fix(billing): harden tiered retry group-switch billing (QuantumNous#6570) fix(billing): settle tiered retries with final group (QuantumNous#6518) feat: deepseek responses api (QuantumNous#6562) style: use text-sm for public header nav links to match other nav components (QuantumNous#6557) fix(oauth): stop treating a foreign window.opener as a bind flow (QuantumNous#6425) fix(relay): preserve multipart image edits for New API channels (QuantumNous#6559) feat(logs): expose stream status to log owners (QuantumNous#6558) feat: support zstd request decompression (QuantumNous#6545) fix: preserve Qwen thinking_budget passthrough (QuantumNous#5836) feat(oidc): 支持自定义 OIDC 登录显示名称 (QuantumNous#6012) ... # Conflicts: # service/text_quota.go # web/src/features/models/components/drawers/model-mutate-drawer.tsx # web/src/features/pricing/components/model-details.tsx # web/src/features/pricing/lib/price.ts
) Follow-up to QuantumNous#6518 (issue QuantumNous#6480) addressing three review findings: - Document and lock in arrears semantics for the wallet Reserve top-up: when an auto-group retry lands on a more expensive group, the full reservation delta is deducted unconditionally (balance may go negative), mirroring settlement, so the logged pre-consumed quota always reconciles with the actual balance movement. Genuine DB errors still fail the attempt with update_data_error. Subscription funding keeps its insufficient-quota behavior: subscriptions enforce a hard used<=total cap and do not support arrears. - PriceData.FreeModel is cleared when a retry switches from a free group to a paid one, keeping it consistent with the billing session created at that point. - getChannel refreshes GroupRatioInfo only after channel selection succeeds, and the retry loop records the channel in use_channel before PrepareTieredBillingForSelectedGroup can fail.
) Follow-up to QuantumNous#6518 (issue QuantumNous#6480) addressing three review findings: - Document and lock in arrears semantics for the wallet Reserve top-up: when an auto-group retry lands on a more expensive group, the full reservation delta is deducted unconditionally (balance may go negative), mirroring settlement, so the logged pre-consumed quota always reconciles with the actual balance movement. Genuine DB errors still fail the attempt with update_data_error. Subscription funding keeps its insufficient-quota behavior: subscriptions enforce a hard used<=total cap and do not support arrears. - PriceData.FreeModel is cleared when a retry switches from a free group to a paid one, keeping it consistent with the billing session created at that point. - getChannel refreshes GroupRatioInfo only after channel selection succeeds, and the retry loop records the channel in use_channel before PrepareTieredBillingForSelectedGroup can fail.
Important
📝 变更描述 / Description
Follow-up to #6518 (fix for #6480), addressing three non-blocking review findings on the tiered-billing retry path. This PR was developed with AI assistance (reviewed before submission).
model.DecreaseUserQuota是无条件quota - ?递减,余额不足的部分记为用户欠费(余额可为负),与结算路径(SettleBilling正差额 →WalletFunding.Settle)语义完全一致,请求不因补扣不足而中断。这是对账要求:日志记录的预扣/结算额度必须与用户余额的实际变动一致。本 PR 为该分支补充了明确的语义注释,并新增回归测试锁定该行为(此前无任何测试覆盖,未来改动若引入余额检查会立即破坏对账一致性)。真正的数据库错误仍返回update_data_error并使本次尝试失败,绝不静默少预扣。订阅资金来源不变:PostConsumeUserSubscriptionDelta强制used <= total硬上限(model/subscription.go),订阅不支持欠费,因此保留其insufficient_user_quota(403)行为。PriceData.FreeModel未同步 — 初始分组 ratio 为 0(跳过预扣费)而重试落到付费分组时,PrepareTieredBillingForSelectedGroup会创建计费会话,但FreeModel仍为true。现在在付费分组分支将其清为false。付费转免费方向保持不变:会话已存在时 "跳过预扣费" 不再成立,且结算对 ratio 0 本就产出 0。controller/relay.go顺序问题 — (a)getChannel中HandleGroupRatio原先在CacheGetRandomSatisfiedChannel的错误检查之前执行,选路失败也会改写定价状态,现移到错误处理之后;(b) 重试循环中PrepareTieredBillingForSelectedGroup原先在addUsedChannel之前,Prepare 失败时选中的渠道不会出现在use_channel重试日志里,现先记录渠道再执行 Prepare。计费不变量保持:扣费金额永不为负(欠费指余额可为负,而非扣费额为负);
FinalPreConsumedQuota与会话预扣量始终反映完整的补扣后预扣目标,保证预扣与结算差额正确对账;补扣遇数据库错误即失败整个尝试。🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
新增测试覆盖:余额不足时补扣不中断请求(Prepare 返回 nil)、全额扣减使余额进入欠费(-30k)、
FinalPreConsumedQuota等于完整新预扣目标(100k)、后续结算按完整预扣正确对账(80k 实耗 → 退 20k → 余额 -10k);余额充足的补扣正确扣减并同步FinalPreConsumedQuota;免费转付费清除FreeModel;付费转免费保持FreeModel=false且不触发 Reserve。Summary by CodeRabbit