feat: support for tiered billing expressions in the billing system - #4409
Conversation
…tionality - Added support for tiered billing expressions in the billing system. - Introduced new types and functions for handling billing expressions, including caching and execution. - Updated existing billing logic to accommodate tiered billing scenarios. - Enhanced request handling to support incoming billing expression requests. - Added tests for tiered billing functionality to ensure correctness.
- Introduced new fields for billing mode and expression in the Pricing model. - Implemented dynamic pricing breakdown component to display tiered billing details. - Updated various components to support and render tiered billing information. - Enhanced pricing calculation logic to accommodate dynamic pricing scenarios. - Added tests for new billing expression functionalities and UI components.
- Adjusted billing calculations in tests and core logic to incorporate a new QuotaPerUnit field. - Modified estimated quota calculations to reflect changes in tiered billing logic. - Updated related tests to ensure accuracy with the new quota calculations. - Enhanced dynamic pricing components to align with updated billing expressions.
…ricing calculations
…billing logic - Introduced a new rule for the Billing Expression System, emphasizing the importance of reading `pkg/billingexpr/expr.md` for dynamic billing. - Updated the billing expression logic to support new variables and improved handling of image and audio tokens. - Enhanced the tiered billing functionality with versioning support for expressions and refined quota calculations. - Added tests to validate the new billing expression features and ensure correctness in pricing calculations.
…ity and functionality
Resolve 4 conflicts:
- relay/compatible_handler.go: accept main's refactor (postConsumeQuota -> service.PostTextConsumeQuota)
- service/quota.go: accept main's PostClaudeConsumeQuota deletion, keep nightly's tiered billing in PostWssConsumeQuota and PostAudioConsumeQuota
- web/src/i18n/locales/{en,zh-CN}.json: merge both sets of translation keys
Post-merge integration:
- Add tiered billing (TryTieredSettle, InjectTieredBillingInfo) to PostTextConsumeQuota
- Update tool pricing calls to use nightly's generic GetToolPriceForModel/GetToolPrice API
Pre-fill BillingRequestInput from dto.Request before ModelPriceHelper, so tiered_expr billing resolves param() from the structured request instead of reading HTTP body (which is empty in channel-test context). - attachTestBillingRequestInput: marshal dto.Request → RequestInput - ResolveIncomingBillingExprRequestInput: early-return when pre-filled - settleTestQuota / buildTestLogOther: align test settlement & logging with production TryTieredSettle / InjectTieredBillingInfo paths
# Conflicts: # web/src/helpers/render.jsx # web/src/hooks/usage-logs/useUsageLogsData.jsx # web/src/i18n/locales/en.json
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughRemoves a project rule doc and adds a tiered-expression billing system: new billingexpr package (compile/run/settle), service & relay plumbing for pre-consume/settle/reserve, model/tool pricing changes, extensive tests, frontend editor/rendering for dynamic pricing, DTO/i18n updates, and a nightly multi-arch Docker workflow. Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(9,129,255,0.5)
actor User as Frontend/User
end
rect rgba(0,200,83,0.5)
participant Editor as TieredPricingEditor
participant API as Backend API
end
rect rgba(255,193,7,0.5)
participant PreConsume as PreConsume/ModelPrice
participant BillingExpr as billingexpr Engine
participant Service as Service Layer
end
User->>Editor: Configure tiers & request rules
Editor->>API: Save billing_expr / billing_mode
API->>PreConsume: Retrieve billing_expr for model
PreConsume->>BillingExpr: Compile/evaluate with estimated TokenParams
BillingExpr-->>PreConsume: Return estimated cost & matched tier
PreConsume->>Service: Persist BillingSnapshot in RelayInfo and reserve pre-consume
Service->>BillingExpr: RunExprWithRequest(actual TokenParams, snapshot)
BillingExpr-->>Service: Return cost, TraceResult
Service->>Service: Convert cost→quota, apply groupRatio, round
Service->>API: Inject billing_mode, matched_tier, expr_b64 into logs
sequenceDiagram
participant Frontend as Editor UI
participant Estimator as Token Estimator
participant ExprEngine as billingexpr.RunExpr
participant Resolver as tier() callback
Frontend->>Estimator: Provide token estimates & request rule
Estimator->>ExprEngine: Build TokenParams and run expression
ExprEngine->>Resolver: tier() callback records selection
Resolver-->>ExprEngine: Return matched tier & cost
ExprEngine-->>Estimator: Return cost
Estimator-->>Frontend: Show estimated quota and matched tier
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 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
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
service/quota.go (1)
202-237:⚠️ Potential issue | 🟠 MajorAdd
SettleBillingcall to finalize tiered quota settlement.
PostWssConsumeQuotaapplies tiered quota but lacks theSettleBillingcall present inPostAudioConsumeQuota(line 189). Without this, the tiered quota is logged but wallet/subscription balance settlement does not occur, leaving the request unsettled. Add the settlement call after updating usage counters, matching the pattern used inPostAudioConsumeQuota.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/quota.go` around lines 202 - 237, PostWssConsumeQuota applies tiered quota but never calls SettleBilling like PostAudioConsumeQuota does, so tiered quota is not finalized; after you update usage counters in PostWssConsumeQuota (where model.UpdateUserUsedQuotaAndRequestCount and model.UpdateChannelUsedQuota are called), invoke SettleBilling(ctx, relayInfo, tieredResult) (matching the pattern in PostAudioConsumeQuota) so the tiered billing/quota gets properly settled and logged; ensure you only call it in the non-zero totalTokens path where tieredResult/tieredOk is relevant and keep InjectTieredBillingInfo(other, relayInfo, tieredResult) afterwards as before.web/src/helpers/render.jsx (1)
1640-1700:⚠️ Potential issue | 🔴 CriticalDo not assign to destructured
constbindings.These helpers destructure
completionRatio,audioRatio, andaudioCompletionRatiowithconst, then later reassign them in conditional branches. This causes "Assignment to constant variable" runtime errors when the fallback branches execute.Affected functions:
renderModelPrice(line 1698-1699)renderAudioModelPrice(lines 2453-2454, 2456)renderClaudeModelPrice(lines 2742-2743)Proposed pattern
- completion_ratio: completionRatio, + completion_ratio: rawCompletionRatio, @@ - if (completionRatio === undefined) { - completionRatio = 0; - } + const completionRatio = rawCompletionRatio ?? 0;Apply the same pattern for
audioRatioandaudioCompletionRatiobefore any operations likeparseFloat().🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/render.jsx` around lines 1640 - 1700, The destructured bindings completionRatio (in renderModelPrice), audioRatio and audioCompletionRatio (in renderAudioModelPrice and renderClaudeModelPrice) are declared as const but later reassigned, causing "Assignment to constant variable" errors; change those destructured declarations to use let (or assign them into new let variables immediately after destructuring) and then perform any parseFloat()/fallback assignments on the mutable variables so the conditional fallbacks (e.g., setting completionRatio = 0) and subsequent parseFloat() calls succeed without throwing.
♻️ Duplicate comments (1)
CLAUDE.md (1)
124-128:⚠️ Potential issue | 🟡 MinorMirror the rule-numbering fix here too.
Same ordering issue as
AGENTS.md: “Rule 7” appears before “Rule 6”. Please keep both convention files synchronized.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@CLAUDE.md` around lines 124 - 128, The headings for the rules are out of order in CLAUDE.md: the "Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`" block appears before "Rule 6: Upstream Relay Request DTOs — Preserve Explicit Zero Values"; swap or renumber these two sections so Rule 6 precedes Rule 7 and mirror the same change you applied to AGENTS.md. Locate the exact headings "Rule 6: Upstream Relay Request DTOs — Preserve Explicit Zero Values" and "Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`" and reorder their blocks (or update their numeric prefixes) to keep convention files synchronized.
🟠 Major comments (21)
.gitignore-33-33 (1)
33-33:⚠️ Potential issue | 🟠 MajorRemove
token_estimator_test.gofrom .gitignore or clarify why it must be excluded.Other Go test files in this repository are committed:
controller/token_test.gopkg/billingexpr/billingexpr_test.gorelay/helper/billing_expr_request_test.goservice/task_billing_test.goAdding
token_estimator_test.goto.gitignoreis inconsistent with this convention. While the implementation file (service/token_estimator.go) exists, ignoring its test file will prevent it from being tracked if created, breaking standard Go testing practices and CI/CD workflows.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.gitignore at line 33, Remove the ignored entry for token_estimator_test.go from .gitignore (or replace it with a clear comment explaining why the test must be excluded) so tests for service/token_estimator.go can be tracked; specifically, delete the "token_estimator_test.go" line or add a justification comment referencing service/token_estimator.go and the test name token_estimator_test.go to keep repository test conventions consistent with other files like controller/token_test.go.relay/channel/gemini/relay-gemini.go-1042-1049 (1)
1042-1049:⚠️ Potential issue | 🟠 MajorMap candidate
TEXTtokens into completion details too.This now records image/audio candidate tokens, but leaves
CompletionTokenDetails.TextTokensat zero when Gemini returnsTEXTincandidatesTokensDetails. Tiered billing expressions that price text output separately can undercount Gemini completions.🐛 Proposed fix
for _, detail := range metadata.CandidatesTokensDetails { switch detail.Modality { + case "TEXT": + usage.CompletionTokenDetails.TextTokens += detail.TokenCount case "IMAGE": usage.CompletionTokenDetails.ImageTokens += detail.TokenCount case "AUDIO": usage.CompletionTokenDetails.AudioTokens += detail.TokenCount } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@relay/channel/gemini/relay-gemini.go` around lines 1042 - 1049, The loop over metadata.CandidatesTokensDetails in relay-gemini.go updates ImageTokens and AudioTokens but never maps TEXT candidate tokens to usage.CompletionTokenDetails.TextTokens; add a case for detail.Modality == "TEXT" and increment usage.CompletionTokenDetails.TextTokens by detail.TokenCount (use the same pattern as the IMAGE/AUDIO cases) so Gemini TEXT candidate tokens are counted for completion billing..github/workflows/docker-image-nightly.yml-3-13 (1)
3-13:⚠️ Potential issue | 🟠 MajorPrevent overlapping runs from publishing a stale or mixed
nightlymanifest.The manifest is composed from mutable
nightly-amd64/nightly-arm64tags. Concurrent runs can mix architectures from different commits, or an older run can finish last and roll back:nightly. Use run-specific arch tags as manifest inputs and add workflow concurrency.🐛 Proposed fix
on: push: branches: - nightly workflow_dispatch: inputs: name: description: "reason" required: false + +concurrency: + group: docker-nightly-${{ github.ref }} + cancel-in-progress: true jobs: @@ - name: Create & push manifest (Docker Hub - nightly) run: | docker buildx imagetools create \ -t calciumion/new-api:nightly \ - calciumion/new-api:nightly-amd64 \ - calciumion/new-api:nightly-arm64 + calciumion/new-api:${VERSION}-amd64 \ + calciumion/new-api:${VERSION}-arm64Also applies to: 101-106
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/docker-image-nightly.yml around lines 3 - 13, Replace mutable architecture tags used when composing the multi-arch manifest (the nightly-amd64/nightly-arm64 inputs and the final :nightly tag) with run-specific tags (for example by including the GitHub run id or short commit SHA in the arch tags) so the manifest assembler always pulls the two arches from the same run; also add a workflow-level concurrency block (concurrency key with a stable group name and cancel-in-progress: true) to prevent overlapping runs from publishing mixed or stale :nightly manifests. Update the same pattern wherever nightly-amd64/nightly-arm64 are used (the other occurrence referenced in the review) so all manifest consumers use run-scoped tags and the workflow uses concurrency..github/workflows/docker-image-nightly.yml-37-43 (1)
37-43:⚠️ Potential issue | 🟠 MajorCompute
VERSIONonce per workflow run.The workflow computes version in
build_single_arch(lines 37-43) and again increate_manifests(lines 88-93). If the run crosses midnight, matrix jobs could push images under one version while the manifest job creates references under another. Consolidate into a single prep job that runs first and outputs the version for both jobs to consume.🐛 Proposed fix
jobs: + prepare_version: + name: Determine nightly version + runs-on: ubuntu-latest + outputs: + value: ${{ steps.version.outputs.value }} + steps: + - name: Determine nightly version + id: version + shell: bash + run: | + VERSION="nightly-$(date -u +'%Y%m%d')-${GITHUB_SHA::7}" + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + build_single_arch: name: Build & push (${{ matrix.arch }}) [native] + needs: [prepare_version] @@ - - name: Determine nightly version - id: version - run: | - VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" - echo "$VERSION" > VERSION - echo "value=$VERSION" >> $GITHUB_OUTPUT - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "Publishing version: $VERSION for ${{ matrix.arch }}" + - name: Export nightly version + run: | + VERSION="${{ needs.prepare_version.outputs.value }}" + echo "$VERSION" > VERSION + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Publishing version: $VERSION for ${{ matrix.arch }}" @@ - calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} + calciumion/new-api:${{ needs.prepare_version.outputs.value }}-${{ matrix.arch }} @@ create_manifests: name: Create multi-arch manifests (Docker Hub) - needs: [build_single_arch] + needs: [prepare_version, build_single_arch] runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare_version.outputs.value }} @@ - - name: Check out (shallow) - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Determine nightly version - id: version - run: | - VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" - echo "value=$VERSION" >> $GITHUB_OUTPUT - echo "VERSION=$VERSION" >> $GITHUB_ENV🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/docker-image-nightly.yml around lines 37 - 43, Consolidate the VERSION computation into a single preparatory job (e.g., prep_version) that runs first and exports the computed VERSION via outputs/GITHUB_OUTPUT and GITHUB_ENV so downstream jobs consume the exact same value; remove the duplicate VERSION logic from build_single_arch and create_manifests, make those jobs declare needs: prep_version (or reference the prep job output), and ensure the prep job writes the same "value=$VERSION" to $GITHUB_OUTPUT and "VERSION=$VERSION" to $GITHUB_ENV so both build_single_arch and create_manifests use the identical VERSION variable.pkg/billingexpr/round.go-8-10 (1)
8-10:⚠️ Potential issue | 🟠 MajorAdd validation to reject invalid quota values before int conversion.
int(math.Round(f))silently converts NaN, Inf, and out-of-range values tomin int64(–9223372036854775808), corrupting billing settlement. The smoke test only checksresult < 0, leaving non-finite and out-of-range cases undetected. Validate in QuotaRound or ensure all callers guard against these cases.Safer conversion shape
-func QuotaRound(f float64) int { - return int(math.Round(f)) +func QuotaRound(f float64) (int, error) { + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("invalid quota value: %v", f) + } + rounded := math.Round(f) + maxInt := float64(int(^uint(0) >> 1)) + if rounded < 0 || rounded > maxInt { + return 0, fmt.Errorf("quota value out of range: %v", f) + } + return int(rounded), nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pkg/billingexpr/round.go` around lines 8 - 10, QuotaRound currently does an unchecked int(math.Round(f)) which silently converts NaN/Inf and out-of-range floats into bogus ints; update QuotaRound to validate inputs and return an error instead of producing invalid values: change the signature QuotaRound(float64) to QuotaRound(float64) (int, error), check math.IsNaN(f) and math.IsInf(f, 0), compute platform int max/min as float64(int(^uint(0)>>1)) and -that-1, verify math.Round(f) lies within those bounds, and return a descriptive error for any invalid case; only perform the int conversion when validations pass.service/log_info_generate.go-271-276 (1)
271-276:⚠️ Potential issue | 🟠 MajorGuard the exported helper against nil inputs.
Line 272 dereferences
relayInfo, and Line 276 writes intoother; either being nil will panic. This file’s other append helpers already return early for nil inputs.Proposed fix
func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) { + if relayInfo == nil || other == nil { + return + } snap := relayInfo.TieredBillingSnapshot if snap == nil { return }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/log_info_generate.go` around lines 271 - 276, InjectTieredBillingInfo currently dereferences relayInfo and writes into other without guarding for nil; add early nil checks at the top of the function (e.g., if other == nil || relayInfo == nil || result == nil { return }) before accessing relayInfo.TieredBillingSnapshot or assigning other["billing_mode"] to prevent panics, then keep the existing snap == nil check and subsequent logic.service/quota.go-161-165 (1)
161-165:⚠️ Potential issue | 🟠 MajorInclude realtime audio/text token variables in WSS tiered settlement.
This WSS path only passes aggregate
P/C, so expressions that depend on audio/text-specific variables can’t be evaluated correctly even thoughRealtimeUsagecontains those details. Mirror thePostAudioConsumeQuotavariable-aware normalization with a realtime-specific token-param builder.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/quota.go` around lines 161 - 165, The WSS realtime settlement is only passing aggregate P/C to TryTieredSettle so expressions depending on audio/text-specific vars fail; update the call in service/quota.go to build a realtime-aware billingexpr.TokenParams (similar to PostAudioConsumeQuota normalization) using RealtimeUsage fields (e.g., AudioInputTokens, AudioOutputTokens, TextInputTokens, TextOutputTokens or whatever RealtimeUsage exposes) and pass that token param into TryTieredSettle (retain relayInfo and names tieredResult/tieredOk/tieredQuota/tieredRes) so tiered expressions can access the audio/text-specific variables during evaluation.controller/channel-test.go-529-539 (1)
529-539:⚠️ Potential issue | 🟠 MajorApply the group ratio in fallback test settlement.
The non-tiered fallback omits
priceData.GroupRatioInfo.GroupRatio, so channel-test consume logs won’t match production quota for groups with non-1x pricing.Proposed fix
quota := 0 + groupRatio := priceData.GroupRatioInfo.GroupRatio if !priceData.UsePrice { quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) - quota = int(math.Round(float64(quota) * priceData.ModelRatio)) - if priceData.ModelRatio != 0 && quota <= 0 { + effectiveRatio := priceData.ModelRatio * groupRatio + quota = int(math.Round(float64(quota) * effectiveRatio)) + if effectiveRatio != 0 && quota <= 0 { quota = 1 } return quota, nil } - return int(priceData.ModelPrice * common.QuotaPerUnit), nil + return int(priceData.ModelPrice * common.QuotaPerUnit * groupRatio), nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/channel-test.go` around lines 529 - 539, The fallback branch for non-tiered pricing currently skips priceData.GroupRatioInfo.GroupRatio, so update the !priceData.UsePrice path and the final fallback return to apply GroupRatioInfo.GroupRatio: when computing quota in the branch that sets quota = usage.PromptTokens + ... and then multiplies by priceData.ModelRatio, also multiply by priceData.GroupRatioInfo.GroupRatio (preserve rounding and the existing minimum-of-1 check), and change the final return to multiply model price by common.QuotaPerUnit and priceData.GroupRatioInfo.GroupRatio (i.e., return int(priceData.ModelPrice * common.QuotaPerUnit * priceData.GroupRatioInfo.GroupRatio)), ensuring rounding/typing stays consistent with existing computations.service/text_quota.go-307-318 (1)
307-318:⚠️ Potential issue | 🟠 MajorTiered override silently drops auxiliary tool quotas from billed amount.
summary.Quotareturned bycalculateTextQuotaSummaryincludes quotas forweb_search,claude_web_search,file_search,audio_input, andimage_generation_call(lines 245-249 / 263-267). When tiered settlement succeeds,summary.Quota = tieredQuotareplaces the entire total with only the expression-derived quota, discarding these auxiliary costs.Note:
web_searchandfile_searchhave no corresponding variables in tiered expressions (onlyp,c,cr,cc,cc1h,img,imgO,ai,aoare supported). They cannot be recovered via the expression. Meanwhile, code at lines 320-334 still appends"Web Search 调用 ..."and"File Search 调用 ..."toextraContent, and lines 380-402 writeweb_search_priceandfile_search_pricetoother. The user is told they were charged for these tools, but the quota deducted does not include them.For
audio_inputandimage_generation_call, the situation is different: corresponding tiered variables (ai,ao,img,imgO) exist, so these can be expressed. However, if they are not referenced in the expression, they are still discarded fromsummary.Quotaat line 316, creating the same mismatch.Either guard against using
tiered_exprwith auxiliary tools that lack expression variables, or ensure all auxiliary costs computed incalculateTextQuotaSummaryare preserved and added back totieredQuotabefore overridingsummary.Quota.service/billing_session.go-165-170 (1)
165-170:⚠️ Potential issue | 🟠 MajorTrack subscription extra reserve before the token-reserve rollback path.
If
reserveFunding(delta)succeeds for a subscription, thenreserveToken(delta)fails androllbackFundingReserve(delta)also fails, the extra subscription usage is left in the DB butextraReservedis still0. A laterRefundcannot roll it back.Proposed direction
if err := s.reserveFunding(delta); err != nil { return err } + trackedSubscriptionReserve := false + if _, ok := s.funding.(*SubscriptionFunding); ok { + s.extraReserved += delta + trackedSubscriptionReserve = true + } if err := s.reserveToken(delta); err != nil { - s.rollbackFundingReserve(delta) + if s.rollbackFundingReserve(delta) && trackedSubscriptionReserve { + s.extraReserved -= delta + } return err } s.preConsumedQuota += delta s.tokenConsumed += delta - s.extraReserved += delta + if !trackedSubscriptionReserve { + s.extraReserved += delta + }-func (s *BillingSession) rollbackFundingReserve(delta int) { +func (s *BillingSession) rollbackFundingReserve(delta int) bool { switch funding := s.funding.(type) { case *WalletFunding: if err := model.IncreaseUserQuota(funding.userId, delta, false); err != nil { common.SysLog("error rolling back wallet funding reserve: " + err.Error()) + return false } else { funding.consumed -= delta } case *SubscriptionFunding: if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, -int64(delta)); err != nil { common.SysLog("error rolling back subscription funding reserve: " + err.Error()) + return false } } + return true }Also applies to: 256-268
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/billing_session.go` around lines 165 - 170, reserveFunding(delta) can succeed but if reserveToken(delta) fails and rollbackFundingReserve(delta) also fails, the subscription's extra usage remains in the DB while in-memory extraReserved stays 0 so later Refund can't revert it; after a successful reserveFunding(delta) update/record the subscription extraReserved (or call the existing tracker that persists extra reserve) immediately before calling reserveToken(delta), and on reserveToken error ensure you either clear that persisted extraReserved when rollbackFundingReserve succeeds or leave it if rollback fails and surface the failure; update logic in the block around reserveFunding, reserveToken, and rollbackFundingReserve to persist extraReserved consistently (references: reserveFunding, reserveToken, rollbackFundingReserve, extraReserved, Refund).relay/helper/billing_expr_request.go-14-18 (1)
14-18:⚠️ Potential issue | 🟠 MajorMerge missing headers instead of only handling an empty header map.
A preloaded
BillingRequestInputwith partial headers skipsinfo.RequestHeadersentirely, so header-dependent billing expressions can evaluate against incomplete data.Proposed fix
if info != nil && info.BillingRequestInput != nil { input := cloneRequestInput(*info.BillingRequestInput) - if len(input.Headers) == 0 { - input.Headers = cloneStringMap(info.RequestHeaders) + headers := cloneStringMap(info.RequestHeaders) + for key, value := range input.Headers { + headers[key] = value } + input.Headers = headers return input, nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@relay/helper/billing_expr_request.go` around lines 14 - 18, The code currently replaces headers only when input.Headers is empty, causing preloaded BillingRequestInput with partial headers to ignore info.RequestHeaders; update the logic in the BillingRequestInput handling (around cloneRequestInput and input.Headers) to merge info.RequestHeaders into input.Headers instead of skipping them: ensure input.Headers is non-nil, then iterate info.RequestHeaders and copy keys that are missing or (if desired) overwrite based on the intended precedence so that header-dependent billing expressions see the full combined header set.relay/helper/price.go-254-259 (1)
254-259:⚠️ Potential issue | 🟠 MajorDo not mark tiered billing as free just because the estimate is zero.
For expressions that charge output tokens,
estimatedCompletionTokenscan be0whenmax_tokensis absent, but the actual completion may still be billable. MarkingFreeModelhere can bypass later settlement.Proposed fix
freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { - if groupRatioInfo.GroupRatio == 0 || quotaBeforeGroup == 0 { + if groupRatioInfo.GroupRatio == 0 { preConsumedQuota = 0 freeModel = true } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@relay/helper/price.go` around lines 254 - 259, The current logic sets freeModel = true and zeroes preConsumedQuota when groupRatioInfo.GroupRatio == 0 || quotaBeforeGroup == 0, which incorrectly marks tiered billing as free when estimatedCompletionTokens is 0; instead, only mark FreeModel (freeModel) when the quota-setting condition truly indicates a free-tier exemption (use operation_setting.GetQuotaSetting().EnableFreeModelPreConsume) and a definitive indicator that the request is non-billable, not simply when groupRatioInfo.GroupRatio or quotaBeforeGroup are zero; update the condition around freeModel and preConsumedQuota in the block using freeModel, groupRatioInfo.GroupRatio, quotaBeforeGroup, and EnableFreeModelPreConsume so that zero estimates do not trigger freeModel — e.g., require an explicit non-billable flag or check that output-token billing cannot apply before setting freeModel to true and setting preConsumedQuota = 0.service/tiered_settle.go-24-38 (1)
24-38:⚠️ Potential issue | 🟠 MajorSplit cache-creation token normalization by
ccvscc1h.Line 36 subtracts all cache-creation tokens when either variable is used, but line 64 only charges
ccTotal - cc1hthroughCC. Expressions that reference only one cache-creation variable can underbill the other, andCCcan go negative.Proposed fix
cr := float64(usage.PromptTokensDetails.CachedTokens) ccTotal := float64(usage.PromptTokensDetails.CachedCreationTokens) cc1h := float64(usage.ClaudeCacheCreation1hTokens) + cc5m := ccTotal - cc1h + if cc5m < 0 { + cc5m = 0 + } img := float64(usage.PromptTokensDetails.ImageTokens) ai := float64(usage.PromptTokensDetails.AudioTokens) imgO := float64(usage.CompletionTokenDetails.ImageTokens) ao := float64(usage.CompletionTokenDetails.AudioTokens) if !isClaudeUsageSemantic { if usedVars["cr"] { p -= cr } - if usedVars["cc"] || usedVars["cc1h"] { - p -= ccTotal + if usedVars["cc"] { + p -= cc5m + } + if usedVars["cc1h"] { + p -= cc1h }P: p, C: c, CR: cr, - CC: ccTotal - cc1h, + CC: cc5m, CC1h: cc1h,Also applies to: 60-65
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/tiered_settle.go` around lines 24 - 38, The subtraction currently lumps all cache-creation tokens (ccTotal) when either usedVars["cc"] or usedVars["cc1h"] is set, causing under/over-billing and negative CC; change logic to subtract only the relevant portion: if usedVars["cc1h"] subtract cc1h, if usedVars["cc"] subtract (ccTotal - cc1h) (or subtract ccTotal when both are true), and mirror the same split in the CC charging computation (the block around lines 60-65) so each of cc and cc1h is handled separately and consistently (use variables ccTotal and cc1h and usedVars["cc"]/usedVars["cc1h"] to find the right places).web/src/i18n/locales/en.json-3501-3739 (1)
3501-3739:⚠️ Potential issue | 🟠 MajorRemove duplicate translation keys from this block.
The appended keys (lines 3501–3739) duplicate existing entries earlier in the file. JSON keeps only the last value, silently overwriting earlier translations. Examples:
缓存读取,缓存创建,补全价格,音频输出, and 140+ others. Either remove these keys if they are unchanged, or update the original entries in-place if changes are needed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/i18n/locales/en.json` around lines 3501 - 3739, This JSON block duplicates many existing translation keys (e.g., "缓存读取", "缓存创建", "补全价格", "音频输出", "输入价格:{{symbol}}{{price}} / 1M tokens") which silently overwrite earlier entries; remove the duplicated keys from this appended section or, if these are intended updates, merge their values into the original entries instead of duplicating them — search for the listed unique keys ("缓存读取", "缓存创建", "补全价格", "音频输出", "输入价格:{{symbol}}{{price}} / 1M tokens", etc.) to find the originals and either delete the duplicates here or apply the new strings in-place in the original definitions to keep one canonical key per translation.setting/billing_setting/tiered_billing.go-35-44 (1)
35-44:⚠️ Potential issue | 🟠 MajorSynchronize map access in billing setting hot-path functions.
GetBillingMode()andGetBillingExpr()read unsynchronized maps thatLoadFromDB()mutates in-place via JSON deserialization. If config reload occurs during request processing, this is a Go map race and will panic the process.The mutex in
LoadFromDB()only protects the config manager's registry, not the individual setting maps. Either protect reads in these hot-path accessors with a shared lock, use immutable snapshots (copy-on-write), or synchronize the entire reload cycle with request handling.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@setting/billing_setting/tiered_billing.go` around lines 35 - 44, GetBillingMode and GetBillingExpr currently read maps (billingSetting.BillingMode, billingSetting.BillingExpr) unsafely while LoadFromDB mutates them; add synchronization by using a shared RW lock for reads and an exclusive lock for reloads or switch to copy-on-write snapshot replacement. Concretely: add an RWMutex field to the config/billingSetting holder, wrap GetBillingMode and GetBillingExpr in RLock()/RUnlock(), and make LoadFromDB either Lock()/Unlock() while mutating or construct new maps and atomically replace billingSetting to avoid in-place map mutation; ensure you reference/GetBillingMode, GetBillingExpr, LoadFromDB, billingSetting, BillingMode and BillingExpr when making the changes.web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx-964-979 (1)
964-979:⚠️ Potential issue | 🟠 MajorDo not evaluate billing expressions with
new Function.Billing expressions are persisted configuration and can execute arbitrary browser JavaScript here, including access to
window,localStorage, and network APIs. Use a safe expression evaluator shared with the backend semantics, or move estimation to a backend validation/evaluation endpoint.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 964 - 979, The evalExprLocally function currently uses new Function to evaluate persisted billing expressions (matched by evalExprLocally and the local variable tierFn), which is unsafe; replace this local arbitrary JS execution with a safe expression evaluator or delegate evaluation to a backend endpoint that enforces the same semantics as the backend. Specifically, remove the new Function(...) usage inside evalExprLocally, wire in a vetted parser/evaluator library (or call an API) that only supports the allowed operators/identifiers (p, c, tier, max, min, abs, ceil, floor and EXTRA_ESTIMATOR_FIELDS state keys) and returns {cost, matchedTier, error} with the same shape as before, and ensure EXTRA_ESTIMATOR_FIELDS values are passed as sanitized numeric inputs rather than injected into executable code.web/src/helpers/render.jsx-2251-2273 (1)
2251-2273:⚠️ Potential issue | 🟠 MajorParse full
tier()arguments instead of stopping at the first).
([^)]+)truncates valid tier bodies that contain parentheses or helper calls, e.g.tier("x", max(p, c) * 2)ortier("x", (p) * 2). The renderer can then show zero/misleading prices for valid backend expressions.Use a small balanced-parentheses scanner here, or reuse the same top-level splitting approach used by the request-rule expression helpers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/render.jsx` around lines 2251 - 2273, The regexp in parseTiersFromExpr stops at the first ')' so tier bodies containing nested parentheses or helper calls (e.g., max(p,c)) get truncated; change the parsing to locate the full balanced- parenthesis argument for parseTierBody instead of relying on ([^)]+): find the start of the tier(...) args after the matched label and then scan forward tracking open/close parens to the matching ')' (or reuse the project’s top-level expression splitter used by request-rule helpers), pass that full arg substring into parseTierBody, and keep building conditions and tier.label the same; update tierRe to only capture the label and position (or use a simpler prefix match) so you can perform the balanced-scan for the second argument before calling parseTierBody.web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx-164-168 (1)
164-168:⚠️ Potential issue | 🟠 MajorDo not show single-tier conditions unless they are serialized.
For
tiers.length === 1,generateExprFromVisualConfig()always returnstier(...)and ignoresconditions, but the UI still lets users add conditions for the only tier. That silently turns a conditional price into an unconditional one.Proposed UI-side fix
- {!isLast || isOnly ? ( + {!isLast ? (Also applies to: 582-615
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 164 - 168, generateExprFromVisualConfig currently special-cases tiers.length === 1 and always emits tier(...) via buildTierBodyExpr, which drops any conditions the UI may have added; update generateExprFromVisualConfig (and its analogous logic around lines 582-615) to detect when the single tier object (tiers[0]) contains a non-empty conditions field and serialize those conditions into the output instead of unconditionally returning tier(...). Concretely, inspect tiers[0].conditions (or the equivalent property used by the UI), and if present serialize the conditional form (use the same condition-serialization helper you use elsewhere or extend buildTierBodyExpr to accept and include conditions) so a single-tier conditional price is preserved rather than turned into an unconditional tier.web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx-888-893 (1)
888-893:⚠️ Potential issue | 🟠 MajorRemove the advertised aliases or add backend support for them.
The UI says
prompt_tokens,completion_tokens,cache_read_tokens, etc. are supported, but the backend compile/runtime environments only expose short variables likep,c,cr,cc, andcc1h. Expressions using these aliases will fail to compile when saved.Minimal UI-side fix
- <div> - {t('也支持更好懂的别名')}: <code>prompt_tokens</code>,{' '} - <code>completion_tokens</code>, <code>cache_read_tokens</code>,{' '} - <code>cache_create_tokens</code>,{' '} - <code>cache_create_1h_tokens</code> - </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 888 - 893, The UI lists long alias names that the backend doesn't support; in TieredPricingEditor.jsx update the displayed aliases to match the actual backend variables (p, c, cr, cc, cc1h) or remove the long names (prompt_tokens, completion_tokens, cache_read_tokens, cache_create_tokens, cache_create_1h_tokens) so saved expressions compile; locate the JSX block that renders the help text (the div next to t('也支持更好懂的别名')) and replace the advertised names with the real short symbols or remove that sentence, and ensure any translation key or tooltip referencing the long aliases is updated to avoid user confusion.web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js-1042-1055 (1)
1042-1055:⚠️ Potential issue | 🟠 MajorReject empty tiered billing expressions before saving.
combineBillingExpr()returns''whenbillingExpris empty, but this still savesbilling_mode: "tiered_expr"without a corresponding executablebilling_expr. That can leave the model in tiered mode with no pricing formula.Proposed fix
for (const model of models) { if (model.billingMode === 'tiered_expr') { - tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr'; const finalBillingExpr = combineBillingExpr( model.billingExpr, model.requestRuleExpr, ); - if (finalBillingExpr) { - tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr; + if (!finalBillingExpr) { + throw new Error( + t('模型 {{name}} 缺少计费表达式,无法保存阶梯计费配置', { + name: model.name, + }), + ); } + tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr'; + tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr; } if (model.billingMode === 'tiered_expr') { continue; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js` around lines 1042 - 1055, The loop saves models with billingMode 'tiered_expr' even when combineBillingExpr(...) returns an empty string; update the loop in useModelPricingEditorState.js to reject/skip saving tiered mode when finalBillingExpr is falsy: call combineBillingExpr(model.billingExpr, model.requestRuleExpr), and only set tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr' and tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr when finalBillingExpr is non-empty; ensure the code continues/skips the model if finalBillingExpr is empty so no model remains in 'tiered_expr' without a billing_expr.web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx-1018-1027 (1)
1018-1027:⚠️ Potential issue | 🟠 MajorAdd missing
MATCH_GTEimport.Line 1024 uses
MATCH_GTE, but this constant is not imported fromrequestRuleExpr.js. Switching an existing condition to "时间条件" will throw aReferenceError.Proposed fix
MATCH_CONTAINS, MATCH_RANGE, + MATCH_GTE, SOURCE_HEADER,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 1018 - 1027, The code uses MATCH_GTE in the onChange handler for the Select (when value === SOURCE_TIME) but forgot to import it; add MATCH_GTE to the named imports from requestRuleExpr.js at the top of TieredPricingEditor.jsx (alongside existing imports like normalizeCondition and SOURCE_TIME) so the reference in the Select's onChange (which calls normalizeCondition({ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_GTE })) resolves and prevents the ReferenceError.
🟡 Minor comments (10)
go.mod-79-79 (1)
79-79:⚠️ Potential issue | 🟡 MinorRemove
// indirectmarker—expr is directly imported.The project directly imports
github.com/expr-lang/exprinpkg/billingexpr/run.goandpkg/billingexpr/compile.go, so this dependency must be marked as direct. Remove the// indirectannotation; otherwisego mod tidywill churn this line and the dependency graph will be misleading.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@go.mod` at line 79, Remove the "// indirect" annotation on the github.com/expr-lang/expr module declaration in go.mod because this package is directly imported by pkg/billingexpr/run.go and pkg/billingexpr/compile.go; edit the go.mod entry for github.com/expr-lang/expr v1.17.8 to drop "// indirect" and then run go mod tidy (or re-run your dependency update) to ensure the module graph is consistent.web/src/pages/Setting/Ratio/ToolPriceSettings.jsx-101-114 (1)
101-114:⚠️ Potential issue | 🟡 Minor
syncToVisualcrashes when the user entersnull.
typeof null === 'object'andArray.isArray(null) === false, so a textarea value ofnullpasses the validation, thenobjectToRows(null)throwsTypeError: Cannot convert undefined or null to objectatObject.entries(null). A raw string of"null"parsed viaJSON.parseproduces the same crash.🛡️ Proposed fix
const syncToVisual = (text) => { setJsonText(text); try { const parsed = JSON.parse(text); - if (typeof parsed !== 'object' || Array.isArray(parsed)) { + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { setJsonError(t('JSON 必须是对象')); return; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/ToolPriceSettings.jsx` around lines 101 - 114, syncToVisual currently treats null as a valid object because typeof null === 'object', causing objectToRows(parsed) to throw; update the validation in syncToVisual to explicitly reject null (e.g., require parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)), and keep the existing setJsonError and return behavior when the check fails so objectToRows and setRows are only called with a real non-null object.service/tool_billing.go-71-82 (1)
71-82:⚠️ Potential issue | 🟡 Minor
PricePer1Kforimage_generationis a fabricated value.GPT-Image-1 is billed per single call (quality × size), not per 1K calls. Reporting
PricePer1K: price * 1000alongsideCallCount: 1will display e.g. "$167 per 1K" in logs/UI for a single high-quality 1024×1024 image (actual price ≈ $0.167). Consider settingPricePer1Kto the actual per-call price and leaving the consumer to interpret, or renaming the JSON field to avoid the unit implication.🛠️ Minimal fix
if usage.ImageGenerationCall { price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize) quota := int(math.Round(price * common.QuotaPerUnit * groupRatio)) items = append(items, ToolCallItem{ Name: "image_generation", CallCount: 1, - PricePer1K: price * 1000, + PricePer1K: price, // per-call pricing; keep unit consistent with upstream doc TotalPrice: price, Quota: quota, })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/tool_billing.go` around lines 71 - 82, The current ToolCallItem for "image_generation" incorrectly sets PricePer1K to price * 1000 which fabricates a per-1K rate for GPT-Image-1; change the assignment in the block that builds the ToolCallItem (the usage.ImageGenerationCall branch) to set PricePer1K to the actual per-call price returned by operation_setting.GetGPTImage1PriceOnceCall (or rename the field if you prefer semantic clarity), i.e., use PricePer1K: price (or update the consumer to interpret this field as per-call) while leaving CallCount: 1 and TotalPrice: price unchanged so logs/UI show the correct per-call value for image_generation.setting/operation_setting/tools.go-77-115 (1)
77-115:⚠️ Potential issue | 🟡 MinorDefault tool prices are sticky — admins cannot remove a built-in entry via the UI.
RebuildToolPriceIndexalways layersdefaultToolPricesanddefaultToolPriceOverridesunderneathtoolPriceSetting.Prices. If an admin deletes e.g.web_searchinToolPriceSettings.jsxand saves, the payload no longer contains the key, but after reload the index restores the default10.0. The only way to suppress a built-in tool is to set its price to0(which then skips billing via thepricePer1K <= 0guard inservice/tool_billing.go).This is a reasonable design (defaults as a safety net), but it should be intentional and documented. Two options:
- Document the behavior in the UI banner so admins know "delete row" ≠ "disable tool" (set to 0 to disable).
- Or change the merge strategy so that once a config has been persisted, only the persisted map is authoritative (defaults only apply on first boot).
♻️ Example doc-only tweak in the UI banner
- <div>{t('配置各工具的调用价格($/1K次调用)。按次计费模型不额外收取工具费用。')}</div> + <div>{t('配置各工具的调用价格($/1K次调用)。按次计费模型不额外收取工具费用。删除条目会恢复内置默认价格;如需禁用某工具计费,请将其价格设置为 0。')}</div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@setting/operation_setting/tools.go` around lines 77 - 115, RebuildToolPriceIndex currently always re-adds defaultToolPrices and defaultToolPriceOverrides under toolPriceSetting.Prices, making deleted built-ins reappear; change the merge so toolPriceSetting.Prices is authoritative: start merged by copying toolPriceSetting.Prices, then only add entries from defaultToolPriceOverrides and defaultToolPrices when the key is NOT present in merged (i.e., use "if _, ok := merged[k]; !ok { merged[k]=v }"). Update the merge logic in RebuildToolPriceIndex (referencing merged, defaultToolPriceOverrides, defaultToolPrices, and toolPriceSetting.Prices) so admin deletions are respected.controller/channel_test_internal_test.go-3-13 (1)
3-13:⚠️ Potential issue | 🟡 MinorAssert the encoded expression payload, not just presence.
require.NotEmptywould still pass ifexpr_b64contains the wrong expression. Compare it with the expected base64 value so the test protects log rendering/replay correctness.Proposed test tightening
import ( + "encoding/base64" "net/http/httptest" "testing" @@ require.Equal(t, "tiered_expr", other["billing_mode"]) require.Equal(t, "base", other["matched_tier"]) - require.NotEmpty(t, other["expr_b64"]) + exprB64, ok := other["expr_b64"].(string) + require.True(t, ok) + require.Equal(t, base64.StdEncoding.EncodeToString([]byte(`tier("base", p * 2)`)), exprB64) }Also applies to: 68-70
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/channel_test_internal_test.go` around lines 3 - 13, The test currently only checks expr_b64 is non-empty; instead assert its exact value to prevent wrong expressions slipping through. Decode or compute the expected base64 of the expression using the same encoder (e.g., billingexpr.Encode or the expected literal) and replace require.NotEmpty(resp.Data.ExprB64) with require.Equal(expectedB64, resp.Data.ExprB64) (and do the same for the other assertions at the places reported around lines 68-70). Reference the resp.Data.ExprB64 / expr_b64 field in your test helper and ensure you compare against the deterministic expected base64 string.AGENTS.md-124-128 (1)
124-128:⚠️ Potential issue | 🟡 MinorKeep the rule numbering sequential.
This inserts “Rule 7” before the existing “Rule 6”, making the conventions harder to scan. Move the billing-expression section after the DTO rule, or renumber the following heading.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@AGENTS.md` around lines 124 - 128, The headings are out of sequence: "Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`" appears before "Rule 6: Upstream Relay Request DTOs — Preserve Explicit Zero Values"; fix by either renumbering the billing-expression heading to follow the DTO rule (change "Rule 7" to "Rule 6" and increment subsequent rules accordingly) or move the entire billing-expression block so it appears after the DTO block; update only the heading text "Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`" (or the DTO heading) to restore sequential numbering and ensure subsequent rule numbers remain consistent.web/src/hooks/usage-logs/useUsageLogsData.jsx-500-507 (1)
500-507:⚠️ Potential issue | 🟡 MinorPass the billing display mode into the tiered renderer.
The tiered “计费过程” path does not include
displayMode, while the non-tiered paths do. This can make tiered usage-log details ignore the user’s price/ratio display preference.Proposed fix
value: renderTieredModelPrice({ ...other, prompt_tokens: logs[i].prompt_tokens, completion_tokens: logs[i].completion_tokens, + displayMode: billingDisplayMode, }),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/hooks/usage-logs/useUsageLogsData.jsx` around lines 500 - 507, The tiered branch that builds expandDataLocal calls renderTieredModelPrice without passing the user's price display preference, causing tiered entries to ignore displayMode; update the call in useUsageLogsData.jsx (the branch using other?.billing_mode === 'tiered_expr') to include the displayMode from the surrounding scope (e.g., add displayMode: displayMode or the local variable name used) alongside prompt_tokens and completion_tokens so renderTieredModelPrice receives the same displayMode as the non-tiered paths.model/pricing.go-325-330 (1)
325-330:⚠️ Potential issue | 🟡 MinorOnly expose
tiered_exprwhen an expression is present.This currently sets
billing_mode: "tiered_expr"even whenGetBillingExprmisses or returns an empty string, which leaves the frontend treating the model as dynamic without renderable billing details.Proposed fix
if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" { - pricing.BillingMode = billingMode - if expr, ok := billing_setting.GetBillingExpr(model); ok { + if expr, ok := billing_setting.GetBillingExpr(model); ok && strings.TrimSpace(expr) != "" { + pricing.BillingMode = billingMode pricing.BillingExpr = expr } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@model/pricing.go` around lines 325 - 330, The code currently sets pricing.BillingMode = "tiered_expr" even when billing_setting.GetBillingExpr(model) is missing or empty; change the logic in the block that calls billing_setting.GetBillingMode(model) so you only assign pricing.BillingExpr and pricing.BillingMode when GetBillingExpr returns ok && expr != "" (non-empty). In other words, call GetBillingExpr(model), check both ok and that expr is not empty, then set pricing.BillingExpr = expr and pricing.BillingMode = billingMode; otherwise leave pricing.BillingMode unset so the frontend won't treat the model as dynamic. Use the existing symbols billing_setting.GetBillingMode, billing_setting.GetBillingExpr, pricing.BillingExpr, and pricing.BillingMode to locate and update the code.web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx-141-148 (1)
141-148:⚠️ Potential issue | 🟡 MinorAdd missing i18n entries for new billing-mode labels to all locale files.
Three new keys are missing from all locale files:
表达式计费,表达式/阶梯计费, and the helper description string. This causes non-zh users to see fallback Chinese text and may trigger i18n lint warnings.Use the project's i18n CLI tools to extract and sync:
bun run i18n:extract bun run i18n:sync bun run i18n:lint🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx` around lines 141 - 148, The getExprModeLabel in ModelPricingEditor.jsx returns Chinese keys that are missing from locale files; add i18n entries for the three missing strings ('表达式计费', '表达式/阶梯计费', and the helper description string referenced in the same component) to all locale resources (or mark them with proper translation keys), then run the project's i18n tooling to propagate and validate translations: run bun run i18n:extract, bun run i18n:sync, and bun run i18n:lint to update locale files and fix any lint warnings.web/src/helpers/utils.jsx-918-919 (1)
918-919:⚠️ Potential issue | 🟡 Minor
minute(...)is not recognized as a time condition.
pkg/billingexpr/expr.md(Built-in Functions table) documentsminute(tz)alongsidehour,weekday,month,day. This regex omits it, so an expression usingminute(...)won't get the含时间条件tag.Proposed fix
- const hasTimeCondition = /\b(?:hour|weekday|month|day)\(/.test(exprBody); + const hasTimeCondition = /\b(?:hour|minute|weekday|month|day)\(/.test(exprBody);As per coding guidelines: "When working on tiered/dynamic billing ... All code changes to the billing expression system must follow the patterns described in [
pkg/billingexpr/expr.md]."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/utils.jsx` around lines 918 - 919, The time-condition detection regex in the hasTimeCondition variable misses the built-in minute(tz) function so expressions with minute(...) won't be flagged; update the regex used to test exprBody in hasTimeCondition (currently matching hour|weekday|month|day) to also include minute so minute(...) is recognized as a time condition, ensuring consistency with the built-in functions documented in pkg/billingexpr/expr.md.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 06eec1de-9de1-4e52-8bd2-4d9f0772d1c0
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (60)
.cursor/rules/project.mdc.github/workflows/docker-image-nightly.yml.gitignoreAGENTS.mdCLAUDE.mdcontroller/channel-test.gocontroller/channel_test_internal_test.godto/gemini.godto/openai_response.gogo.modmodel/option.gomodel/pricing.gopkg/billingexpr/billingexpr_test.gopkg/billingexpr/compile.gopkg/billingexpr/expr.mdpkg/billingexpr/round.gopkg/billingexpr/run.gopkg/billingexpr/settle.gopkg/billingexpr/types.gorelay/audio_handler.gorelay/channel/gemini/relay-gemini.gorelay/chat_completions_via_responses.gorelay/common/billing.gorelay/common/relay_info.gorelay/embedding_handler.gorelay/helper/billing_expr_request.gorelay/helper/billing_expr_request_test.gorelay/helper/price.gorelay/helper/price_test.goservice/billing_session.goservice/log_info_generate.goservice/quota.goservice/text_quota.goservice/tiered_settle.goservice/tiered_settle_test.goservice/tool_billing.gosetting/billing_setting/tiered_billing.gosetting/model_setting/claude_test.gosetting/operation_setting/tools.goweb/src/components/settings/RatioSetting.jsxweb/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsxweb/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsxweb/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsxweb/src/components/table/model-pricing/modal/components/ModelEndpoints.jsxweb/src/components/table/model-pricing/modal/components/ModelPricingTable.jsxweb/src/components/table/model-pricing/view/card/PricingCardView.jsxweb/src/components/table/usage-logs/UsageLogsColumnDefs.jsxweb/src/constants/billing.constants.jsweb/src/constants/index.jsweb/src/helpers/render.jsxweb/src/helpers/utils.jsxweb/src/hooks/usage-logs/useUsageLogsData.jsxweb/src/i18n/locales/en.jsonweb/src/i18n/locales/zh-CN.jsonweb/src/index.cssweb/src/pages/Setting/Ratio/ToolPriceSettings.jsxweb/src/pages/Setting/Ratio/components/ModelPricingEditor.jsxweb/src/pages/Setting/Ratio/components/TieredPricingEditor.jsxweb/src/pages/Setting/Ratio/components/requestRuleExpr.jsweb/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
💤 Files with no reviewable changes (1)
- .cursor/rules/project.mdc
| "缓存读取": "缓存读取", | ||
| "缓存创建": "缓存创建", |
There was a problem hiding this comment.
Remove duplicate translation keys before this can pass lint.
Several added keys already exist in the same translation object; JSON keeps only the last value, and Biome is already failing on duplicate keys. Keep a single shared key for identical labels, or make contextual keys unique if the values need to diverge.
Examples in this change include 缓存读取, 缓存创建, 是, 否, 输入, 输出, 小时, 默认, and 分组倍率.
Also applies to: 3034-3035, 3041-3041, 3064-3064, 3071-3071, 3093-3097
🧰 Tools
🪛 Biome (2.4.11)
[error] 2905-2905: The key 缓存读取 was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 2906-2906: The key 缓存创建 was already declared.
(lint/suspicious/noDuplicateObjectKeys)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/i18n/locales/zh-CN.json` around lines 2905 - 2906, There are
duplicate JSON translation keys (e.g., "缓存读取", "缓存创建", "是", "否", "输入", "输出",
"小时", "默认", "分组倍率") which break the linter; locate the repeated keys in the
zh-CN translation object (including the occurrences around the referenced
ranges) and remove or consolidate duplicates so each key appears only once — if
the same label is reused keep a single shared key, otherwise rename to a unique
contextual key (e.g., add a suffix like _label or _context) where the values
must differ, then re-run the linter to confirm the duplicates are resolved.
…r path Remove the intermediate branch that recomputed quota from EstimatedQuotaBeforeGroup when tieredResult is nil. This discarded the FinalPreConsumedQuota fallback that TryTieredSettle already selected. Now the error path simply adds tool surcharges to the passed-in tieredQuota, preserving the existing fallback semantics. Also removes unrelated mise.toml and adds a test covering the error fallback with a pre-consumed quota that differs from the estimate.
fix(billing): preserve text tool surcharges in tiered settlement
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@service/text_quota.go`:
- Around line 332-342: The code currently only calls InjectTieredBillingInfo
when tieredResult != nil, which drops tiered billing metadata for fallback
settlements where TryTieredSettle returns ok=true but tieredRes == nil; change
the gating to use the boolean result from TryTieredSettle (tieredOk) instead of
tieredResult, so InjectTieredBillingInfo is invoked whenever tieredOk is true
(passing relayInfo and the possibly-nil tieredRes), and ensure
composeTieredTextQuota/summary handling still uses tieredQuota and tieredRes as
before (so keep setting summary.Quota when tieredOk and adjust any null checks
accordingly).
- Around line 139-155: composeTieredTextQuota currently uses decimal.Round(0)
which causes banker's rounding; replace that with the billing package helper to
ensure consistent half-away-from-zero rounding. In the branch that returns the
scaled tieredResult value (inside composeTieredTextQuota when
relayInfo.TieredBillingSnapshot != nil), compute the scaled quota as before but
pass the final decimal/float value through billingexpr.QuotaRound (instead of
calling Round(0)) and then convert to int; likewise for the fallback that adds
summary.ToolCallSurchargeQuota, use billingexpr.QuotaRound on the summed value
before converting to int so both paths use billingexpr.QuotaRound consistently.
🪄 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
Run ID: 428e78fb-d697-4a12-a874-63fe32c38a33
📒 Files selected for processing (2)
service/text_quota.goservice/text_quota_test.go
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
service/quota.go (2)
209-214:⚠️ Potential issue | 🟡 MinorLog content still reports legacy ratios when tiered settlement is applied.
When
tieredOkis true,quotais overridden butlogContentis still built frommodelRatio/completionRatio/audioRatio/modelPrice, which for tiered models come fromPriceDatawith zero values (seerelay/helper/price.gomodelPriceHelperTiered). The recorded log will read模型倍率 0.00,补全倍率 0.00…, which is misleading for anyone inspecting consumption logs. The same applies toPostAudioConsumeQuotalines 326–331.Consider emitting a tiered-specific
logContentwhentieredOk(e.g. referencing matched tier and expression hash) to keep logs truthful.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/quota.go` around lines 209 - 214, logContent is using modelRatio/completionRatio/audioRatio/modelPrice even when tieredOk is true, producing misleading "0.00" values because quota was overridden by tiered pricing (see modelPriceHelperTiered in relay/helper/price.go). Update the logging in the blocks that set logContent (and similarly in PostAudioConsumeQuota) to detect tieredOk and emit a tiered-specific message that references the matched tier identifier and expression hash (or other tier metadata) instead of the legacy ratio fields; keep the existing non-tiered message path for the modelRatio/completionRatio/audioRatio/modelPrice variables when tieredOk is false so logs remain accurate.
161-205:⚠️ Potential issue | 🟠 MajorWSS tiered settlement only populates
P/Ctotals; audio/text variables in the expression will be unresolved.
PostAudioConsumeQuotacallsBuildTieredTokenParams(usage, ...)which extracts audio/text/image token subcategories and populates allTokenParamsfields (AI,AO,Img, etc.).PostWssConsumeQuotadirectly constructsTokenParams{P: float64(usage.InputTokens), C: float64(usage.OutputTokens)}, passing only totals. Any tiered expression configured for realtime models that references variables likeaudio_input_tokens,text_input_tokens, etc., will find them unmapped (zero) inTokenParams, producing incorrect tiered quotas.Add a
BuildTieredTokenParamsvariant for*dto.RealtimeUsage(extractingInputTokenDetails.AudioTokens,OutputTokenDetails.AudioTokens, etc., usingusedVarsfromrelayInfo.TieredBillingSnapshot.ExprString) so both settlement paths handle token subcategories consistently.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/quota.go` around lines 161 - 205, PostWssConsumeQuota builds TokenParams with only totals (P/C) so tiered expressions referencing subcategories (text/audio/image tokens) get zeroed; add a BuildTieredTokenParams variant that accepts *dto.RealtimeUsage and extracts InputTokenDetails.AudioTokens, OutputTokenDetails.AudioTokens, InputTokenDetails.TextTokens, OutputTokenDetails.TextTokens, Image tokens, etc., populate the corresponding TokenParams fields (AI, AO, TI, TO, Img, etc.) and use relayInfo.TieredBillingSnapshot.ExprString to compute usedVars so you only set variables referenced by the expression; then replace the direct TokenParams{P:..., C:...} construction in PostWssConsumeQuota with a call to this new BuildTieredTokenParams(realtimeUsage, relayInfo) so tiered settlement gets the same subcategory data as PostAudioConsumeQuota.
♻️ Duplicate comments (1)
web/src/i18n/locales/zh-CN.json (1)
3667-4094:⚠️ Potential issue | 🔴 CriticalRemove the duplicate translation keys in this added block.
This block re-declares keys that already exist in the same
translationobject; Biome is failing on缓存读取and缓存创建, and the same overwrite risk applies to repeated labels like是,否,输入,输出,小时,默认, and分组倍率. Keep one shared entry per key, or rename only entries that truly need different contextual meanings.#!/bin/bash # Verify duplicate keys in the zh-CN i18n translation object. python - <<'PY' import json from collections import Counter from pathlib import Path path = Path("web/src/i18n/locales/zh-CN.json") text = path.read_text(encoding="utf-8") duplicates = [] def hook(pairs): counts = Counter(key for key, _ in pairs) duplicates.extend((key, count) for key, count in counts.items() if count > 1) return dict(pairs) json.loads(text, object_pairs_hook=hook) for key, count in duplicates: print(f"{key}\t{count}") PYAs per coding guidelines, translation files in
web/src/i18n/locales/{lang}.jsonuse flat JSON format with Chinese source strings as keys.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/i18n/locales/zh-CN.json` around lines 3667 - 4094, The JSON block adds duplicate translation keys (e.g., "缓存读取", "缓存创建", "是", "否", "输入", "输出", "小时", "默认", "分组倍率", etc.) which breaks Biome; open the zh-CN translation object and remove or consolidate repeated entries so each key appears only once, keeping the canonical value for shared labels and only adding distinct keys when a different contextual string is required (search for keys like "缓存读取" and "缓存创建" and dedupe them, also check other repeated keys listed in the review).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@controller/channel-test.go`:
- Around line 530-541: The non-usePrice branch in settleTestQuota currently
computes quota without applying the user's GroupRatio, causing inconsistency
with helper.ModelPriceHelperPerCall; update both paths: in the
!priceData.UsePrice branch multiply the computed quota by priceData.GroupRatio
(and re-apply the minimum-1 check), and in the fallback return path multiply the
int(priceData.ModelPrice * common.QuotaPerUnit) by priceData.GroupRatio as well
so both branches mirror ModelPriceHelperPerCall behavior.
In `@relay/helper/price.go`:
- Around line 242-308: The tiered handler modelPriceHelperTiered leaves
PriceData.ModelRatio/ModelPrice/CacheRatio zero and uses quotaBeforeGroup==0 to
mark FreeModel, causing downstream logging and semantics errors; update
modelPriceHelperTiered to populate PriceData with tier-aware indicators (e.g.,
set PriceData.UsePrice=true or set a synthetic ModelRatio/ModelPrice/CacheRatio
derived from trace or expr coefficients so downstream reads in service/quota.go
(ModelRatio, ModelPrice, CacheRatio) are meaningful) and change the FreeModel
assignment so it is not based on runtime quotaBeforeGroup==0 (either remove that
check for tiered paths or tie FreeModel to an explicit configuration flag or
groupRatioInfo indicator) while keeping existing snapshot and trace usage
(ExprHash, trace.MatchedTier) intact.
In `@web/src/i18n/locales/en.json`:
- Around line 3677-3919: The diff appended a duplicate block of ~92 JSON keys
(e.g., "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。", "购买订阅获得模型额度/次数", "生产环境
RSA 私钥 Base64 (PKCS#8 DER)", "模型计费编辑器", "缓存创建价格-5分钟", etc.) which overwrites
earlier translations; remove this entire appended duplicate block and instead
edit the original key entries earlier in en.json where the keys first appear to
apply any intended English text changes so no duplicate keys remain and JSON
uniqueness is preserved.
---
Outside diff comments:
In `@service/quota.go`:
- Around line 209-214: logContent is using
modelRatio/completionRatio/audioRatio/modelPrice even when tieredOk is true,
producing misleading "0.00" values because quota was overridden by tiered
pricing (see modelPriceHelperTiered in relay/helper/price.go). Update the
logging in the blocks that set logContent (and similarly in
PostAudioConsumeQuota) to detect tieredOk and emit a tiered-specific message
that references the matched tier identifier and expression hash (or other tier
metadata) instead of the legacy ratio fields; keep the existing non-tiered
message path for the modelRatio/completionRatio/audioRatio/modelPrice variables
when tieredOk is false so logs remain accurate.
- Around line 161-205: PostWssConsumeQuota builds TokenParams with only totals
(P/C) so tiered expressions referencing subcategories (text/audio/image tokens)
get zeroed; add a BuildTieredTokenParams variant that accepts *dto.RealtimeUsage
and extracts InputTokenDetails.AudioTokens, OutputTokenDetails.AudioTokens,
InputTokenDetails.TextTokens, OutputTokenDetails.TextTokens, Image tokens, etc.,
populate the corresponding TokenParams fields (AI, AO, TI, TO, Img, etc.) and
use relayInfo.TieredBillingSnapshot.ExprString to compute usedVars so you only
set variables referenced by the expression; then replace the direct
TokenParams{P:..., C:...} construction in PostWssConsumeQuota with a call to
this new BuildTieredTokenParams(realtimeUsage, relayInfo) so tiered settlement
gets the same subcategory data as PostAudioConsumeQuota.
---
Duplicate comments:
In `@web/src/i18n/locales/zh-CN.json`:
- Around line 3667-4094: The JSON block adds duplicate translation keys (e.g.,
"缓存读取", "缓存创建", "是", "否", "输入", "输出", "小时", "默认", "分组倍率", etc.) which breaks
Biome; open the zh-CN translation object and remove or consolidate repeated
entries so each key appears only once, keeping the canonical value for shared
labels and only adding distinct keys when a different contextual string is
required (search for keys like "缓存读取" and "缓存创建" and dedupe them, also check
other repeated keys listed in the review).
🪄 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
Run ID: d5790894-c1aa-41c2-afa6-ea7097f25dff
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (13)
.gitignorecontroller/channel-test.godto/gemini.godto/openai_response.gogo.modmodel/option.gorelay/common/relay_info.gorelay/helper/price.goservice/quota.goweb/src/components/table/usage-logs/UsageLogsColumnDefs.jsxweb/src/hooks/usage-logs/useUsageLogsData.jsxweb/src/i18n/locales/en.jsonweb/src/i18n/locales/zh-CN.json
✅ Files skipped from review due to trivial changes (3)
- go.mod
- .gitignore
- relay/common/relay_info.go
🚧 Files skipped from review as they are similar to previous changes (2)
- dto/openai_response.go
- model/option.go
| quota := 0 | ||
| if !priceData.UsePrice { | ||
| quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) | ||
| quota = int(math.Round(float64(quota) * priceData.ModelRatio)) | ||
| if priceData.ModelRatio != 0 && quota <= 0 { | ||
| quota = 1 | ||
| } | ||
| return quota, nil | ||
| } | ||
|
|
||
| return int(priceData.ModelPrice * common.QuotaPerUnit), nil | ||
| } |
There was a problem hiding this comment.
settleTestQuota usePrice fallback drops GroupRatio.
The legacy path (helper.ModelPriceHelperPerCall, line 196) computes int(modelPrice * QuotaPerUnit * GroupRatio), but this fallback omits the group ratio:
🔧 Proposed fix
- return int(priceData.ModelPrice * common.QuotaPerUnit), nil
+ return int(priceData.ModelPrice * common.QuotaPerUnit * priceData.GroupRatioInfo.GroupRatio), nilConsider applying the same GroupRatio factor to the non-usePrice branch as well, mirroring ModelPriceHelperPerCall's behavior, to keep channel-test quota consistent with production settlement for users whose group ratio ≠ 1.0.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@controller/channel-test.go` around lines 530 - 541, The non-usePrice branch
in settleTestQuota currently computes quota without applying the user's
GroupRatio, causing inconsistency with helper.ModelPriceHelperPerCall; update
both paths: in the !priceData.UsePrice branch multiply the computed quota by
priceData.GroupRatio (and re-apply the minimum-1 check), and in the fallback
return path multiply the int(priceData.ModelPrice * common.QuotaPerUnit) by
priceData.GroupRatio as well so both branches mirror ModelPriceHelperPerCall
behavior.
| func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo types.GroupRatioInfo) (types.PriceData, error) { | ||
| exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName) | ||
| if !ok { | ||
| return types.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName) | ||
| } | ||
|
|
||
| estimatedCompletionTokens := 0 | ||
| if meta.MaxTokens != 0 { | ||
| estimatedCompletionTokens = meta.MaxTokens | ||
| } | ||
|
|
||
| requestInput, err := ResolveIncomingBillingExprRequestInput(c, info) | ||
| if err != nil { | ||
| return types.PriceData{}, err | ||
| } | ||
|
|
||
| rawCost, trace, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{ | ||
| P: float64(promptTokens), | ||
| C: float64(estimatedCompletionTokens), | ||
| }, requestInput) | ||
| if err != nil { | ||
| return types.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", info.OriginModelName, err) | ||
| } | ||
|
|
||
| // Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does. | ||
| quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit | ||
| preConsumedQuota := billingexpr.QuotaRound(quotaBeforeGroup * groupRatioInfo.GroupRatio) | ||
|
|
||
| freeModel := false | ||
| if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { | ||
| if groupRatioInfo.GroupRatio == 0 || quotaBeforeGroup == 0 { | ||
| preConsumedQuota = 0 | ||
| freeModel = true | ||
| } | ||
| } | ||
|
|
||
| exprHash := billingexpr.ExprHashString(exprStr) | ||
| snapshot := &billingexpr.BillingSnapshot{ | ||
| BillingMode: billing_setting.BillingModeTieredExpr, | ||
| ModelName: info.OriginModelName, | ||
| ExprString: exprStr, | ||
| ExprHash: exprHash, | ||
| GroupRatio: groupRatioInfo.GroupRatio, | ||
| EstimatedPromptTokens: promptTokens, | ||
| EstimatedCompletionTokens: estimatedCompletionTokens, | ||
| EstimatedQuotaBeforeGroup: quotaBeforeGroup, | ||
| EstimatedQuotaAfterGroup: preConsumedQuota, | ||
| EstimatedTier: trace.MatchedTier, | ||
| QuotaPerUnit: common.QuotaPerUnit, | ||
| ExprVersion: billingexpr.ExprVersion(exprStr), | ||
| } | ||
| info.TieredBillingSnapshot = snapshot | ||
| info.BillingRequestInput = &requestInput | ||
|
|
||
| priceData := types.PriceData{ | ||
| FreeModel: freeModel, | ||
| GroupRatioInfo: groupRatioInfo, | ||
| QuotaToPreConsume: preConsumedQuota, | ||
| } | ||
|
|
||
| if common.DebugEnabled { | ||
| println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)) | ||
| } | ||
|
|
||
| info.PriceData = priceData | ||
| return priceData, nil | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm RunExprWithRequest signature (value vs pointer for trace) and the TokenParams fields.
ast-grep --pattern 'func RunExprWithRequest($$$) ($$$)'
rg -nP --type=go -C2 '\bfunc\s+RunExprWithRequest\b'
rg -nP --type=go -C2 '\btype\s+TokenParams\b'
rg -nP --type=go -C2 '\btype\s+TieredResult\b'Repository: QuantumNous/new-api
Length of output: 1553
🏁 Script executed:
#!/bin/bash
# Check quota.go post-consume logging paths (lines 182-214, 299-331)
# and how they use PriceData fields
echo "=== quota.go lines 182-214 ==="
sed -n '182,214p' service/quota.go
echo ""
echo "=== quota.go lines 299-331 ==="
sed -n '299,331p' service/quota.go
echo ""
echo "=== Search for PriceData field reads (ModelRatio, ModelPrice, CompletionRatio, CacheRatio) ==="
rg -n 'PriceData\.|\.ModelRatio|\.ModelPrice|\.CompletionRatio|\.CacheRatio' service/quota.go | head -30Repository: QuantumNous/new-api
Length of output: 3268
🏁 Script executed:
#!/bin/bash
# Check legacy free model logic in the non-tiered path
echo "=== Search for freeModel assignment in non-tiered paths ==="
rg -B5 -A5 'freeModel\s*=' relay/helper/price.go | head -50Repository: QuantumNous/new-api
Length of output: 1414
🏁 Script executed:
#!/bin/bash
# Check if tiered models have any configuration that marks them as free
# and verify the semantics of free models
echo "=== Search for FreeModel handling in types/price ==="
rg -n 'FreeModel' types/price.go
echo ""
echo "=== Search for free model logic in billing_setting ==="
rg -n -B2 -A2 'free.*model|model.*free' model/billing_setting.goRepository: QuantumNous/new-api
Length of output: 308
Tiered PriceData leaves ratio fields zero, affecting downstream logging and free-model classification logic.
Two substantive issues:
-
Unpopulated
PriceDatafields: The returnedPriceDataonly setsFreeModel,GroupRatioInfo, andQuotaToPreConsume. However, downstream post-consume paths inservice/quota.go(lines 182–214, 299–331) readPriceData.ModelRatio,PriceData.ModelPrice, andPriceData.CacheRatiofor logging and other calculations. For tiered models these remain zero-initialized, producing misleading log messages like"模型倍率 0.00,补全倍率 0.00…". Either populate representative values inPriceData(e.g.,UsePrice: trueto indicate tiered pricing; derive a synthetic ratio) or make the logging/quota calculation paths tiered-aware. -
Transient zero-cost marks model as free: On lines 270–276,
freeModelis set totruewheneverquotaBeforeGroup == 0. In the legacy branches (non-tiered paths),freeModelis set only when the model's static configuration indicates zero cost (modelRatio == 0ormodelPrice == 0). Here it fires dynamically whenever the expression evaluates to 0 on pre-consume inputs (e.g., empty prompt with noMaxTokens). A transient zero cost shouldn't semantically mark the model as free. Either dropfreeModelfor tiered models or tie it to an explicit configuration flag rather than runtime expression output.
Also note: trace.MatchedTier on line 289/303 is safe to access; billingexpr.RunExprWithRequest returns (float64, TraceResult, error) where TraceResult is a value type, not a pointer.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@relay/helper/price.go` around lines 242 - 308, The tiered handler
modelPriceHelperTiered leaves PriceData.ModelRatio/ModelPrice/CacheRatio zero
and uses quotaBeforeGroup==0 to mark FreeModel, causing downstream logging and
semantics errors; update modelPriceHelperTiered to populate PriceData with
tier-aware indicators (e.g., set PriceData.UsePrice=true or set a synthetic
ModelRatio/ModelPrice/CacheRatio derived from trace or expr coefficients so
downstream reads in service/quota.go (ModelRatio, ModelPrice, CacheRatio) are
meaningful) and change the FreeModel assignment so it is not based on runtime
quotaBeforeGroup==0 (either remove that check for tiered paths or tie FreeModel
to an explicit configuration flag or groupRatioInfo indicator) while keeping
existing snapshot and trace usage (ExprHash, trace.MatchedTier) intact.
| "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Notice: Endpoint mapping is for Model Marketplace display only and does not affect real model invocation. To configure real invocation, please go to Channel Management.", | ||
| "购买订阅获得模型额度/次数": "Purchase a subscription to get model quota/usage", | ||
| "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "Production RSA private key Base64 (PKCS#8 DER)", | ||
| "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)", | ||
| "生产环境 Waffo 公钥 Base64 (X.509 DER)": "Production Waffo public key Base64 (X.509 DER)", | ||
| "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)", | ||
| "支付方式类型": "Pay Method Type", | ||
| "支付方式名称": "Pay Method Name", | ||
| "获取充值配置失败": "Failed to get topup configuration", | ||
| "获取充值配置异常": "Topup configuration error", | ||
| "分组相关设置": "Group Related Settings", | ||
| "保存分组相关设置": "Save Group Related Settings", | ||
| "此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "This page only shows models without base pricing. After saving, configured models will be removed from this list automatically.", | ||
| "没有未设置定价的模型": "No unpriced models", | ||
| "当前没有未设置定价的模型": "There are currently no models without pricing", | ||
| "模型计费编辑器": "Model Pricing Editor", | ||
| "价格摘要": "Price Summary", | ||
| "当前提示": "Current Notes", | ||
| "这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "This editor uses prices by default and converts them back into the ratio JSON required by the backend when saved.", | ||
| "当前未启用,需要时再打开即可。": "This field is currently disabled. Enable it when needed.", | ||
| "下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "The fields below show which backend values will be written after saving, so you can keep them aligned with the raw JSON editors.", | ||
| "补全价格已锁定": "Completion price is locked", | ||
| "后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Backend fixed ratio: {{ratio}}. This field only displays the converted price.", | ||
| "这些价格都是可选项,不填也可以。": "All of these prices are optional and can be left empty.", | ||
| "请先开启并填写音频输入价格。": "Enable and fill in the audio input price first.", | ||
| "输入模型名称,例如 gpt-4.1": "Enter a model name, for example gpt-4.1", | ||
| "当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "This model currently has both per-request pricing and ratio-based pricing. Saving will overwrite them according to the current billing mode.", | ||
| "当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "This model has derived ratios without an explicit input ratio. Once you fill in the input price, they will be converted into price fields automatically.", | ||
| "按量计费下需要先填写输入价格,才能保存其它价格项。": "For per-token billing, fill in the input price before saving other price fields.", | ||
| "填写音频补全价格前,需要先填写音频输入价格。": "Fill in the audio input price before setting the audio completion price.", | ||
| "模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Model {{name}} is missing an input price, so the ratios for completion, cache, image, and audio pricing cannot be calculated.", | ||
| "模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Model {{name}} is missing an audio input price, so the audio completion ratio cannot be calculated.", | ||
| "批量应用当前模型价格": "Batch Apply Current Model Pricing", | ||
| "请先选择一个作为模板的模型": "Please select a model to use as the template first", | ||
| "请先勾选需要批量设置的模型": "Please select the models you want to update in batch first", | ||
| "已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Applied the pricing configuration of model {{name}} to {{count}} models in batch", | ||
| "将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "The pricing configuration of the currently edited model {{name}} will be applied to the {{count}} selected models.", | ||
| "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Useful for pricing model variants together, for example syncing the pricing of gpt-5.1 to gpt-5.1-high, gpt-5.1-low, and similar models.", | ||
| "已勾选": "Selected", | ||
| "当前编辑": "Editing", | ||
| "已勾选 {{count}} 个模型": "{{count}} models selected", | ||
| "计费方式": "Billing Mode", | ||
| "未设置价格": "Price not set", | ||
| "保存预览": "Save Preview", | ||
| "基础价格": "Base Pricing", | ||
| "扩展价格": "Additional Pricing", | ||
| "额外价格项": "Additional price items", | ||
| "补全价格": "Completion Price", | ||
| "缓存读取价格": "Input Cache Read Price", | ||
| "缓存创建价格": "Input Cache Creation Price", | ||
| "缓存创建价格-5分钟": "Cache Creation Price (5-min)", | ||
| "缓存创建价格-1小时": "Cache Creation Price (1-hour)", | ||
| "缓存创建价格(5分钟)": "Cache Creation Price (5-min)", | ||
| "缓存创建价格(1小时)": "Cache Creation Price (1-hour)", | ||
| "分时缓存 (Claude)": "Timed Cache (Claude)", | ||
| "通用缓存": "Generic Cache", | ||
| "缓存读取": "Cache Read", | ||
| "缓存创建": "Cache Creation", | ||
| "缓存创建-5分钟": "Cache Creation (5-min)", | ||
| "缓存创建-1小时": "Cache Creation (1-hour)", | ||
| "缓存读取 Token (cr)": "Cache Read Tokens (cr)", | ||
| "缓存创建 Token (cc)": "Cache Creation Tokens (cc)", | ||
| "缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)", | ||
| "缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)", | ||
| "图片输入价格": "Image Input Price", | ||
| "音频输入价格": "Audio Input Price", | ||
| "音频输入价格:{{symbol}}{{price}} / 1M tokens": "Audio input price: {{symbol}}{{price}} / 1M tokens", | ||
| "音频补全价格": "Audio Completion Price", | ||
| "音频补全价格:{{symbol}}{{price}} / 1M tokens": "Audio completion price: {{symbol}}{{price}} / 1M tokens", | ||
| "适合 MJ / 任务类等按次收费模型。": "Suitable for MJ and other task-based models billed per request.", | ||
| "该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "This model's completion ratio is fixed to {{ratio}} by the backend. The completion price cannot be changed here.", | ||
| "Web 搜索调用 {{webSearchCallCount}} 次": "Web search called {{webSearchCallCount}} times", | ||
| "文件搜索调用 {{fileSearchCallCount}} 次": "File search called {{fileSearchCallCount}} times", | ||
| "实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Actual charge: {{symbol}}{{total}} (group pricing adjustment included)", | ||
| "图片倍率 {{imageRatio}}": "Image ratio {{imageRatio}}", | ||
| "音频倍率 {{audioRatio}}": "Audio ratio {{audioRatio}}", | ||
| "普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Standard input: {{tokens}} / 1M * model ratio {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cached input: {{tokens}} / 1M * model ratio {{modelRatio}} * cache ratio {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Image input: {{tokens}} / 1M * model ratio {{modelRatio}} * image ratio {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio input: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Output: {{tokens}} / 1M * model ratio {{modelRatio}} * completion ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web search: {{count}} / 1K * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "File search: {{count}} / 1K * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Image generation: 1 call * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "合计:{{total}}": "Total: {{total}}", | ||
| "模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "Model ratio {{modelRatio}}, completion ratio {{completionRatio}}, audio ratio {{audioRatio}}, audio completion ratio {{audioCompletionRatio}}, {{cachePart}}{{ratioType}} {{ratio}}", | ||
| "文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Text output: {{tokens}} / 1M * model ratio {{modelRatio}} * completion ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio output: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * audio completion ratio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "Total: text {{textTotal}} + audio {{audioTotal}} = {{total}}", | ||
| "模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "Model ratio {{modelRatio}}, output ratio {{completionRatio}}, cache ratio {{cacheRatio}}, {{ratioType}} {{ratio}}", | ||
| "缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cache read: {{tokens}} / 1M * model ratio {{modelRatio}} * cache ratio {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * cache creation ratio {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * 5m cache creation ratio {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * 1h cache creation ratio {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Output: {{tokens}} / 1M * model ratio {{modelRatio}} * output ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}", | ||
| "空": "Empty", | ||
| "{{ratioType}} {{ratio}}x": "{{ratioType}} {{ratio}}x", | ||
| "模型价格:{{symbol}}{{price}}": "Model price: {{symbol}}{{price}}", | ||
| "模型价格 {{price}}": "Model price {{price}}", | ||
| "缓存读 {{price}} / 1M tokens": "Cache read {{price}} / 1M tokens", | ||
| "5m缓存创建 {{price}} / 1M tokens": "5m cache creation {{price}} / 1M tokens", | ||
| "1h缓存创建 {{price}} / 1M tokens": "1h cache creation {{price}} / 1M tokens", | ||
| "缓存创建 {{price}} / 1M tokens": "Cache creation {{price}} / 1M tokens", | ||
| "图片输入 {{price}} / 1M tokens": "Image input {{price}} / 1M tokens", | ||
| "输入 {{price}} / 1M tokens": "Input {{price}} / 1M tokens", | ||
| "缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "Cache creation {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}", | ||
| "5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "5m cache creation {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}", | ||
| "1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "1h cache creation {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}", | ||
| "(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}": "(Input {{nonImageInput}} tokens + Image input {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}", | ||
| "图片输入价格:{{symbol}}{{total}} / 1M tokens": "Image input price: {{symbol}}{{total}} / 1M tokens", | ||
| "文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "Text prompt {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + Text completion {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + Audio prompt {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + Audio completion {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}", | ||
| "缓存读取价格:{{symbol}}{{total}} / 1M tokens": "Cache read price: {{symbol}}{{total}} / 1M tokens", | ||
| "补全 {{completion}} tokens * 输出倍率 {{completionRatio}}": "Completion {{completion}} tokens * Output ratio {{completionRatio}}", | ||
| "补全倍率 {{completionRatio}}": "Completion ratio {{completionRatio}}", | ||
| "输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens", | ||
| "输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens", | ||
| "输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens", | ||
| "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens", | ||
| "阶梯计费": "Tiered Billing", | ||
| "输入 Tokens 阶梯": "Input Token Tiers", | ||
| "输出 Tokens 阶梯": "Output Token Tiers", | ||
| "固定阶梯": "Fixed Tier", | ||
| "累进阶梯": "Graduated Tier", | ||
| "上限": "Up To", | ||
| "单价": "Unit Cost", | ||
| "固定费": "Flat Fee", | ||
| "Expr 预览": "Expression Preview", | ||
| "Token 估算器": "Token Estimator", | ||
| "预计费用": "Estimated Cost", | ||
| "原始额度": "Raw Quota", | ||
| "添加阶梯": "Add Tier", | ||
| "无限": "Unlimited", | ||
| "输入 Token 定价": "Input Token Pricing", | ||
| "输出 Token 定价": "Output Token Pricing", | ||
| "统一定价": "Flat Rate", | ||
| "阶梯累进": "Graduated", | ||
| "根据总用量落在哪个档位,所有 Token 都按该档价格计费": "All tokens are charged at the rate of the tier your total usage falls into", | ||
| "用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)": "Usage is charged in segments — each segment at its own tier rate (like utility billing)", | ||
| "Token 用量范围": "Token Usage Range", | ||
| "所有 Token": "All Tokens", | ||
| "前 {{count}} 个": "First {{count}}", | ||
| "超过 {{count}} 个": "Over {{count}}", | ||
| "第 {{n}} 档": "Tier {{n}}", | ||
| "最高档": "Highest Tier", | ||
| "此档上限(Token 数)": "Tier Limit (Token Count)", | ||
| "每百万 Token 价格": "Price per 1M Tokens", | ||
| "进入此档额外收费": "Tier Entry Fee", | ||
| "可选,用量达到此档时加收的固定费用": "Optional fixed fee charged when usage reaches this tier", | ||
| "添加更多档位": "Add More Tiers", | ||
| "输入 Token 数": "Input Tokens", | ||
| "输出 Token 数": "Output Tokens", | ||
| "输入 Token 数量,查看按当前阶梯配置的预计费用。": "Enter token counts to see the estimated cost with the current tier configuration.", | ||
| "开发者": "Developer", | ||
| "阶梯计费详情": "Tiered Billing Details", | ||
| "预估环境": "Estimated Env", | ||
| "实际环境": "Actual Env", | ||
| "预估额度": "Estimated Quota", | ||
| "实际额度": "Actual Quota", | ||
| "跨阶梯": "Crossed Tier", | ||
| "是": "Yes", | ||
| "否": "No", | ||
| "计费明细": "Billing Breakdown", | ||
| "阶梯序号": "Tier #", | ||
| "Token 类型": "Token Type", | ||
| "阶梯内 Token 数": "Tokens in Tier", | ||
| "小计": "Subtotal", | ||
| "输入": "Input", | ||
| "输出": "Output", | ||
| "阶梯配置摘要": "Tier Config Summary", | ||
| "输入阶梯": "Input Tiers", | ||
| "档位名称": "Tier Name", | ||
| "用量范围": "Usage Range", | ||
| "输入 Token": "Input Token", | ||
| "输出 Token": "Output Token", | ||
| "阶梯判断依据": "Tier Criterion", | ||
| "根据哪个维度的 Token 数量决定落在哪一档": "Determines which tier to apply based on this dimension's token count", | ||
| "输入 Token 数 (p)": "Input Tokens (p)", | ||
| "输出 Token 数 (c)": "Output Tokens (c)", | ||
| "变量": "Variables", | ||
| "函数": "Functions", | ||
| "输入计费表达式...": "Enter billing expression...", | ||
| "表达式编辑": "Expression Editor", | ||
| "表达式错误": "Expression Error", | ||
| "命中档位": "Matched Tier", | ||
| "档": "tier(s)", | ||
| "输入 Token 数量,查看按当前配置的预计费用。": "Enter token counts to see the estimated cost.", | ||
| "输入 Token 数量,查看按当前配置的预计费用(不含分组倍率)。": "Enter token counts to see the estimated cost (before group ratio).", | ||
| "条件": "Condition", | ||
| "添加条件": "Add Condition", | ||
| "无条件(兜底档)": "No condition (fallback)", | ||
| "兜底档": "Fallback", | ||
| "预设模板": "Presets", | ||
| "每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。": "Each tier can have 0-2 conditions (on p and c). The last tier is the fallback and needs no condition.", | ||
| "输出阶梯": "Output Tiers", | ||
| "阶": "tiers", | ||
| "规则版本": "Rule Version", | ||
| "时间条件": "Time condition", | ||
| "小时": "Hour", | ||
| "分钟": "Minute", | ||
| "星期": "Weekday", | ||
| "月份": "Month", | ||
| "日期": "Day", | ||
| "时区": "Timezone", | ||
| "跨夜范围": "Cross-midnight range", | ||
| "添加时间规则": "Add time rule", | ||
| "起": "From", | ||
| "止": "To", | ||
| "值": "Value", | ||
| "添加条件组": "Add condition group", | ||
| "添加时间条件": "Add time condition", | ||
| "同时满足": "all must match", | ||
| "新年促销": "New Year promo", | ||
| "第 {{n}} 组": "Group {{n}}", | ||
| "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat", | ||
| "1=一月 ... 12=十二月": "1=Jan ... 12=Dec", | ||
| "动态计费": "Dynamic pricing", | ||
| "价格根据用量档位和请求条件动态调整": "Price adjusts dynamically based on usage tiers and request conditions", | ||
| "分档价格表": "Tiered price table", | ||
| "条件乘数": "Condition multipliers", | ||
| "分组倍率": "Group ratio", | ||
| "将额外乘以上述价格": "will additionally multiply the above prices", | ||
| "默认": "Default", | ||
| "缓存读取": "Cache read", | ||
| "缓存创建": "Cache create", | ||
| "缓存创建-1h": "Cache create (1h)", | ||
| "见上方动态计费详情": "See dynamic pricing details above", | ||
| "含时间条件": "Time rules", | ||
| "含请求条件": "Request rules", | ||
| "例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$", | ||
| "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.", | ||
| "复制密钥": "Copy Key", | ||
| "复制连接信息": "Copy Connection String", | ||
| "检测到剪贴板中的连接信息": "Connection info detected in clipboard", | ||
| "自动填入": "Auto-fill", | ||
| "忽略": "Ignore", | ||
| "从剪贴板粘贴配置": "Paste Config", | ||
| "剪贴板中未检测到连接信息": "No connection info found in clipboard", | ||
| "连接信息已填入": "Connection info applied", | ||
| "无法读取剪贴板": "Cannot read clipboard", | ||
| "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)", | ||
| ",当前无生效订阅,将自动使用钱包": ", no active subscription. Wallet will be used automatically.", | ||
| ",时间:": ",time:", | ||
| ",点击更新": ", click Update" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Lists duplicate translation keys with their first and duplicate line numbers.
python - <<'PY'
import json
import re
from pathlib import Path
path = Path("web/src/i18n/locales/en.json")
pattern = re.compile(r'^\s{4}"((?:\\.|[^"\\])*)"\s*:')
seen = {}
duplicates = []
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
match = pattern.match(line)
if not match:
continue
key = json.loads(f'"{match.group(1)}"')
if key in seen:
duplicates.append((key, seen[key], lineno))
else:
seen[key] = lineno
for key, first, duplicate in duplicates:
print(f"{duplicate}: duplicate key {key!r}; first declared at {first}")
raise SystemExit(1 if duplicates else 0)
PYRepository: QuantumNous/new-api
Length of output: 10388
Remove all duplicate translation keys before merging.
Lines 3677–3919 redeclare 92 keys already defined earlier in the file. JSON keeps the last value, silently overriding existing UI text and failing Biome lint. Delete the entire appended block from lines 3677–3919 and update the original key definitions in place where English text needs changes.
🧰 Tools
🪛 Biome (2.4.11)
[error] 3733-3733: The key 缓存读取 was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 3734-3734: The key 缓存创建 was already declared.
(lint/suspicious/noDuplicateObjectKeys)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/i18n/locales/en.json` around lines 3677 - 3919, The diff appended a
duplicate block of ~92 JSON keys (e.g.,
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。", "购买订阅获得模型额度/次数", "生产环境 RSA 私钥
Base64 (PKCS#8 DER)", "模型计费编辑器", "缓存创建价格-5分钟", etc.) which overwrites earlier
translations; remove this entire appended duplicate block and instead edit the
original key entries earlier in en.json where the keys first appear to apply any
intended English text changes so no duplicate keys remain and JSON uniqueness is
preserved.
Resolve conflicts: - .gitignore: keep nightly additions (.test, skills-lock.json) - relay/helper/price.go: keep both billingexpr and model imports - en.json / zh-CN.json: keep nightly's superset of i18n entries - service/billing_session.go: add missing 3rd arg to DecreaseUserQuota - en.json / zh-CN.json: deduplicate 129+320 duplicate i18n keys
There was a problem hiding this comment.
Actionable comments posted: 12
♻️ Duplicate comments (5)
web/src/i18n/locales/en.json (1)
3677-3919:⚠️ Potential issue | 🔴 CriticalRemove the duplicate appended translation block.
This block redeclares many keys that already exist earlier in
translation; JSON keeps the last value and Biome is still failing on duplicates such as缓存读取and缓存创建. Update the original entries in place instead of appending duplicates.🔎 Read-only verification
#!/bin/bash python - <<'PY' import json import re from pathlib import Path path = Path("web/src/i18n/locales/en.json") pattern = re.compile(r'^\s{4}"((?:\\.|[^"\\])*)"\s*:') seen = {} dups = [] for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): m = pattern.match(line) if not m: continue key = json.loads(f'"{m.group(1)}"') if key in seen: dups.append((lineno, key, seen[key])) else: seen[key] = lineno for lineno, key, first in dups[:200]: print(f"{lineno}: duplicate key {key!r}; first declared at {first}") raise SystemExit(1 if dups else 0) PY🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/i18n/locales/en.json` around lines 3677 - 3919, The JSON file has a duplicated appended translation block that redeclares many keys (e.g., "缓存读取", "缓存创建", "缓存创建-5分钟", "缓存创建-1小时", etc.), causing duplicate-key failures; remove the appended duplicate block and instead update the original key entries in-place so each translation key appears only once (locate the duplicate appended block near the end of the file and merge any corrected English strings into the existing definitions such as the original "缓存读取" and "缓存创建" entries rather than adding new duplicated keys).controller/channel-test.go (1)
521-540:⚠️ Potential issue | 🟡 MinorApply group ratio in the non-tiered fallback quota path.
The fallback still computes channel-test quota without
GroupRatioInfo.GroupRatio, so users in non-1x groups can get test logs that differ from production billing.Proposed fix
if !priceData.UsePrice { quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) - quota = int(math.Round(float64(quota) * priceData.ModelRatio)) - if priceData.ModelRatio != 0 && quota <= 0 { + quota = int(math.Round(float64(quota) * priceData.ModelRatio * priceData.GroupRatioInfo.GroupRatio)) + if priceData.ModelRatio != 0 && priceData.GroupRatioInfo.GroupRatio != 0 && quota <= 0 { quota = 1 } return quota, nil } - return int(priceData.ModelPrice * common.QuotaPerUnit), nil + return int(priceData.ModelPrice * common.QuotaPerUnit * priceData.GroupRatioInfo.GroupRatio), nil🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/channel-test.go` around lines 521 - 540, The non-tiered fallback in settleTestQuota currently ignores group ratio—ensure you apply info.GroupRatioInfo.GroupRatio (defaulting to 1.0 when info or GroupRatioInfo is nil) to both the token-based branch (quota computed from usage.PromptTokens/CompletionTokens and priceData.ModelRatio) and the price-based branch (int(priceData.ModelPrice * common.QuotaPerUnit)); after multiplying by the group ratio, round consistently (same math.Round usage) and preserve the existing min-1 guard (if priceData.ModelRatio != 0 && quota <= 0 then quota = 1).service/text_quota.go (2)
332-342:⚠️ Potential issue | 🟠 MajorDuplicate: inject tiered metadata when settlement falls back.
TryTieredSettlecan succeed with a nil result, but the log metadata injection is gated ontieredResult != nil. In that fallback case, charged tiered requests losebilling_modeandexpr_b64, so the usage-log UI cannot render tiered billing details.Proposed fix
- var tieredResult *billingexpr.TieredResult + var tieredResult *billingexpr.TieredResult + tieredBillingApplied := false if originUsage != nil { var tieredUsedVars map[string]bool if snap := relayInfo.TieredBillingSnapshot; snap != nil { tieredUsedVars = billingexpr.UsedVars(snap.ExprString) } tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, summary.IsClaudeUsageSemantic, tieredUsedVars)) if tieredOk { + tieredBillingApplied = true tieredResult = tieredRes summary.Quota = composeTieredTextQuota(relayInfo, summary, tieredQuota, tieredRes) } } @@ - if tieredResult != nil { + if tieredBillingApplied { InjectTieredBillingInfo(other, relayInfo, tieredResult) }Based on learnings, tiered billing changes should follow
pkg/billingexpr/expr.md, including the settlement → log display flow.Also applies to: 454-456
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/text_quota.go` around lines 332 - 342, The code currently only injects tiered billing metadata when tieredResult is non-nil, so when TryTieredSettle returns success with a nil result the log loses billing_mode and expr_b64; update the logic around TryTieredSettle/ tieredResult to treat tieredOk as the authority for injecting tiered metadata (use the relayInfo and tieredQuota/tieredRes values returned by TryTieredSettle and BuildTieredTokenParams) even if tieredRes (tieredResult) is nil, and ensure composeTieredTextQuota/summary.Quota and the log metadata (billing_mode and expr_b64) are populated when tieredOk is true; apply the same change at the other occurrence referenced (lines ~454-456).
139-155:⚠️ Potential issue | 🟠 MajorDuplicate: use
billingexpr.QuotaRoundin tiered quota composition.
composeTieredTextQuotastill usesdecimal.Round(0), which can drift from the billing expression package’s quota rounding contract. Usebillingexpr.QuotaRoundfor both the scaled tiered result and the surcharge fallback path.Proposed fix
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int { if summary.ToolCallSurchargeQuota.IsZero() { return tieredQuota } if tieredResult != nil { if snap := relayInfo.TieredBillingSnapshot; snap != nil { - return int(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup). - Mul(decimal.NewFromFloat(snap.GroupRatio)). - Add(summary.ToolCallSurchargeQuota). - Round(0). - IntPart()) + return billingexpr.QuotaRound( + tieredResult.ActualQuotaBeforeGroup*snap.GroupRatio + + summary.ToolCallSurchargeQuota.InexactFloat64(), + ) } } - return tieredQuota + int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) + return tieredQuota + billingexpr.QuotaRound(summary.ToolCallSurchargeQuota.InexactFloat64()) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/text_quota.go` around lines 139 - 155, composeTieredTextQuota is using decimal.Round(0) which diverges from the billing package's rounding rules; replace those Round calls with billingexpr.QuotaRound so rounding follows the billing contract: when computing the scaled tiered value use billingexpr.QuotaRound on the result of decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).Mul(decimal.NewFromFloat(snap.GroupRatio)) before adding summary.ToolCallSurchargeQuota, and in the fallback path replace summary.ToolCallSurchargeQuota.Round(0) with billingexpr.QuotaRound(summary.ToolCallSurchargeQuota); keep existing uses of relayInfo.TieredBillingSnapshot, tieredResult.ActualQuotaBeforeGroup and summary.ToolCallSurchargeQuota to locate the spots to change.web/src/i18n/locales/zh-CN.json (1)
3667-4093:⚠️ Potential issue | 🔴 CriticalDuplicate: remove repeated translation keys from the appended block.
The appended translations repeat existing keys; Biome is still failing on duplicate keys such as
缓存读取and缓存创建at Lines 3887-3888. Keep one entry per Chinese source key and remove/consolidate the duplicates before this can pass lint.Run the frontend i18n lint after cleanup:
bun run i18n:lintAs per coding guidelines, frontend i18n uses flat JSON translation files with Chinese source strings as keys.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/i18n/locales/zh-CN.json` around lines 3667 - 4093, The JSON contains duplicate translation keys (e.g., "缓存读取", "缓存创建", "缓存创建-5分钟", "缓存创建-1小时" etc.) in the appended block; remove or consolidate the repeated entries so each Chinese source string appears only once (keep the canonical value for keys like "缓存读取" and "缓存创建" and delete the duplicates), then re-run the frontend i18n lint (bun run i18n:lint) to verify the file passes.
🧹 Nitpick comments (1)
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js (1)
1037-1063: Consolidate duplicated branch and guard empty tiered expression on save.Two minor things in the submit loop:
- The
if (model.billingMode === 'tiered_expr')check is executed back-to-back on lines 1043 and 1053 — the second one is just acontinue. Merge them into a single branch.- When a tiered model has no
billingExpr(e.g., user switched to tiered_expr but cleared/never entered an expression), the code still writesbilling_setting.billing_mode[name] = 'tiered_expr'but skipsbilling_setting.billing_expr[name]. The backend will then see a tier-billed model with no expression, which will fail at pre-consume/settlement time. Either block submit with a validation error, or skip the model entirely fromtieredOutputwhenfinalBillingExpris empty.♻️ Proposed fix
for (const model of models) { if (model.billingMode === 'tiered_expr') { - tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr'; const finalBillingExpr = combineBillingExpr( model.billingExpr, model.requestRuleExpr, ); - if (finalBillingExpr) { - tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr; + if (!finalBillingExpr) { + throw new Error( + t('模型 {{name}} 为阶梯计费但未配置计费表达式', { name: model.name }), + ); } - } - if (model.billingMode === 'tiered_expr') { + tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr'; + tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr; continue; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js` around lines 1037 - 1063, The loop duplicates the same billingMode check and can write a tiered_mode without an expression; merge the two if (model.billingMode === 'tiered_expr') branches into one and only add entries to tieredOutput when combineBillingExpr(model.billingExpr, model.requestRuleExpr) yields a non-empty finalBillingExpr—i.e., compute finalBillingExpr via combineBillingExpr, if truthy set both tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr' and tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr, otherwise skip that model entirely from tieredOutput (or alternatively surface a validation error before submit); update the loop around serializeModel and Object.entries accordingly so non-tiered models are still processed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@controller/channel-test.go`:
- Around line 543-548: Summary: logging loses billing_mode and expr_b64 when
TryTieredSettle applied tiering but returned tieredResult == nil because the
InjectTieredBillingInfo call is guarded. Fix: remove the conditional guard
around service.InjectTieredBillingInfo in buildTestLogOther and invoke
service.InjectTieredBillingInfo(other, info, tieredResult) unconditionally so
InjectTieredBillingInfo can preserve tiered metadata even when tieredResult is
nil; ensure you call it after other is created by service.GenerateTextOtherInfo
and before returning.
In `@pkg/billingexpr/expr.md`:
- Around line 92-106: Update the fenced code blocks in this markdown so they
include language identifiers: change the blocks containing billing expressions
like tier("base", p * 2.5 + c * 15 + cr * 0.25), tier("base", p * 5 + c *
25)|||when(header("anthropic-beta") has "fast-mode") * 6, and other expr-style
lines to use ```expr, and change narrative/plain text blocks such as "Frontend
Editor → Storage → Pre-consume → Settlement → Log Display" and the formula
"quota = exprOutput / 1,000,000 * QuotaPerUnit * groupRatio" to use ```text;
ensure every opening ``` has the matching language tag so markdownlint MD040 is
satisfied.
In `@service/tiered_settle.go`:
- Around line 60-71: The CC field can be negative because it's set to ccTotal -
cc1h; clamp it to zero like P and C are clamped so negative values cannot reduce
billed quota. Modify the TokenParams construction in billingexpr.TokenParams to
compute a local cc := ccTotal - cc1h and if cc < 0 set cc = 0, then set CC: cc
(instead of CC: ccTotal - cc1h) to mirror the existing clamping behavior for P
and C.
In `@service/tool_billing.go`:
- Around line 51-81: The code currently rounds each tool's surcharge when
computing quota (see addItem and the image_generation block using math.Round)
which causes aggregate rounding drift; change addItem and the image_generation
branch to compute and accumulate a float quota contribution (quotaFloat :=
totalPrice * common.QuotaPerUnit * groupRatio) without calling math.Round and do
NOT append any item for image_generation if price <= 0 (mirror addItem's guard);
after all items are added, compute totalQuota once by rounding the accumulated
float total (totalQuota = int(math.Round(floatTotalQuota))); leave
ToolCallItem.Quota either as 0 or set later if you want per-line visible quotas,
but ensure the final totalQuota is computed from the aggregated float before
rounding and remove per-item math.Round usages.
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`:
- Around line 132-138: The tier-price cells currently hardcode "$" in the render
function inside the priceFields mapping; change the cell renderer to use the app
currency by calling getCurrencyConfig() and pulling { symbol, rate } (similar to
renderTieredModelPrice), then render the value as symbol + (v * rate).toFixed(4)
(and keep the '-' for zero/undefined). Update the title if PRICE_SUFFIX needs to
reflect the configured currency symbol by composing it from symbol instead of a
hardcoded "$". Ensure you reference priceFields, hasTiers, tiers, and the render
function when making this change.
In `@web/src/helpers/render.jsx`:
- Around line 2304-2311: The i18n key is being built by interpolating the raw
label into buildBillingPriceText which prevents translating the label; update
the lines array where buildBillingPriceText is called (the code that constructs
lines in renderTieredModelPriceSimple / the const lines block) to use a template
key with a {{label}} placeholder instead of `${label}` (e.g. '
{{label}}:{{symbol}}{{price}} / 1M tokens') and pass label as a substitution in
the options object (alongside symbol, usdAmount, rate) so the label is
translated via its own key rather than baked into the i18n key.
- Around line 2251-2277: The regex in parseTiersFromExpr (tierRe using ([^)]+))
fails on tier bodies with nested parentheses (e.g., (p)*2 + c*8), so update
parseTiersFromExpr to stop using [^)]+ and instead locate the start of the tier
body after matching tier("...", then perform a balanced-parenthesis scan to find
the matching closing ')' and extract the full body string; replace the current
m[3] extraction with that scanned body before calling parseTierBody, keeping the
existing logic for condStr (m[1]) and label (m[2]) intact and falling back to
returning [] on errors.
In `@web/src/helpers/utils.jsx`:
- Around line 903-915: The code currently does `const gr = groupRatio || 1;`
which treats a valid 0 groupRatio as 1 and uses `hasCoeffs = 'p' in varCoeffs ||
'c' in varCoeffs;` which only detects p/c variables; update both: preserve zero
by using a nullish check (use groupRatio ?? 1) when assigning `gr`, and replace
the `hasCoeffs` check with a generic test that detects any billing variable
captured in `varCoeffs` (e.g., check Object.keys(varCoeffs).length > 0 or test
for intersection with the full `BILLING_VARS` set derived from
`BILLING_VAR_REGEX`/`BILLING_VARS`) so cache/media/audio-only vars like `cr` or
`img` are recognized; reference symbols: `gr`, `groupRatio`, `billingExpr`,
`varCoeffs`, `varRe`, `BILLING_VAR_REGEX`, and `hasCoeffs`.
In `@web/src/hooks/usage-logs/useUsageLogsData.jsx`:
- Around line 500-507: The tiered billing branch is not passing the user's
display mode, so renderTieredModelPrice ignores billingDisplayMode; update the
call inside the if (other?.billing_mode === 'tiered_expr' && other?.expr_b64)
block where expandDataLocal.push uses renderTieredModelPrice to include
displayMode: billingDisplayMode (alongside the existing spread of other and
token fields) so the tiered renderer respects the selected price/ratio display
preference.
In `@web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx`:
- Around line 141-148: getExprModeLabel currently returns an empty string for
models with billingMode 'tiered_expr' but no billingExpr, causing a blank tag;
update getExprModeLabel to treat billingExpr after trimming (use
(model.billingExpr || '').trim()) when checking for 'tier(' and return a
sensible fallback label (e.g., t('阶梯计费')) when the expression is empty so the
header/tag never renders blank; adjust the function that references
model.billingMode and billingExpr (getExprModeLabel) accordingly.
In `@web/src/pages/Setting/Ratio/ToolPriceSettings.jsx`:
- Around line 50-57: rowsToObject currently coerces invalid prices to 0 via
Number(row.price) || 0; change it to validate numeric values instead: parse the
price (use Number or parseFloat), ensure it is a finite number and >= 0, and
handle invalid inputs explicitly (e.g., skip the key and surface an
error/validation message or throw) instead of silently writing 0; apply the same
validation logic to the other similar blocks referenced (around the
functions/blocks at lines ~101-113 and ~132-140) so negative, NaN, Infinity, or
non-numeric strings are rejected or reported before saving billing settings.
- Around line 76-93: The effect currently swallows JSON parse errors and
silently replaces malformed saved pricing with DEFAULT_PRICES; change the catch
to preserve the raw invalid text and surface an error instead of overwriting:
when parsing options?.[OPTION_KEY] fails, setJsonText to the raw string (const
raw = options?.[OPTION_KEY]), avoid replacing rows with DEFAULT_PRICES, and flip
a new boolean state (e.g., jsonParseError / setJsonParseError) so the UI can
show a validation error and the save handler (where setRows/setJsonText are
used) can block or require correction; keep references to useEffect, OPTION_KEY,
DEFAULT_PRICES, setRows, setJsonText, objectToRows and add the jsonParseError
state and checks in the save path.
---
Duplicate comments:
In `@controller/channel-test.go`:
- Around line 521-540: The non-tiered fallback in settleTestQuota currently
ignores group ratio—ensure you apply info.GroupRatioInfo.GroupRatio (defaulting
to 1.0 when info or GroupRatioInfo is nil) to both the token-based branch (quota
computed from usage.PromptTokens/CompletionTokens and priceData.ModelRatio) and
the price-based branch (int(priceData.ModelPrice * common.QuotaPerUnit)); after
multiplying by the group ratio, round consistently (same math.Round usage) and
preserve the existing min-1 guard (if priceData.ModelRatio != 0 && quota <= 0
then quota = 1).
In `@service/text_quota.go`:
- Around line 332-342: The code currently only injects tiered billing metadata
when tieredResult is non-nil, so when TryTieredSettle returns success with a nil
result the log loses billing_mode and expr_b64; update the logic around
TryTieredSettle/ tieredResult to treat tieredOk as the authority for injecting
tiered metadata (use the relayInfo and tieredQuota/tieredRes values returned by
TryTieredSettle and BuildTieredTokenParams) even if tieredRes (tieredResult) is
nil, and ensure composeTieredTextQuota/summary.Quota and the log metadata
(billing_mode and expr_b64) are populated when tieredOk is true; apply the same
change at the other occurrence referenced (lines ~454-456).
- Around line 139-155: composeTieredTextQuota is using decimal.Round(0) which
diverges from the billing package's rounding rules; replace those Round calls
with billingexpr.QuotaRound so rounding follows the billing contract: when
computing the scaled tiered value use billingexpr.QuotaRound on the result of
decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).Mul(decimal.NewFromFloat(snap.GroupRatio))
before adding summary.ToolCallSurchargeQuota, and in the fallback path replace
summary.ToolCallSurchargeQuota.Round(0) with
billingexpr.QuotaRound(summary.ToolCallSurchargeQuota); keep existing uses of
relayInfo.TieredBillingSnapshot, tieredResult.ActualQuotaBeforeGroup and
summary.ToolCallSurchargeQuota to locate the spots to change.
In `@web/src/i18n/locales/en.json`:
- Around line 3677-3919: The JSON file has a duplicated appended translation
block that redeclares many keys (e.g., "缓存读取", "缓存创建", "缓存创建-5分钟", "缓存创建-1小时",
etc.), causing duplicate-key failures; remove the appended duplicate block and
instead update the original key entries in-place so each translation key appears
only once (locate the duplicate appended block near the end of the file and
merge any corrected English strings into the existing definitions such as the
original "缓存读取" and "缓存创建" entries rather than adding new duplicated keys).
In `@web/src/i18n/locales/zh-CN.json`:
- Around line 3667-4093: The JSON contains duplicate translation keys (e.g.,
"缓存读取", "缓存创建", "缓存创建-5分钟", "缓存创建-1小时" etc.) in the appended block; remove or
consolidate the repeated entries so each Chinese source string appears only once
(keep the canonical value for keys like "缓存读取" and "缓存创建" and delete the
duplicates), then re-run the frontend i18n lint (bun run i18n:lint) to verify
the file passes.
---
Nitpick comments:
In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js`:
- Around line 1037-1063: The loop duplicates the same billingMode check and can
write a tiered_mode without an expression; merge the two if (model.billingMode
=== 'tiered_expr') branches into one and only add entries to tieredOutput when
combineBillingExpr(model.billingExpr, model.requestRuleExpr) yields a non-empty
finalBillingExpr—i.e., compute finalBillingExpr via combineBillingExpr, if
truthy set both tieredOutput['billing_setting.billing_mode'][model.name] =
'tiered_expr' and tieredOutput['billing_setting.billing_expr'][model.name] =
finalBillingExpr, otherwise skip that model entirely from tieredOutput (or
alternatively surface a validation error before submit); update the loop around
serializeModel and Object.entries accordingly so non-tiered models are still
processed.
🪄 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
Run ID: eb0e4246-b330-4169-97d7-4e5b85a0b3f3
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (61)
.cursor/rules/project.mdc.github/workflows/docker-image-nightly.yml.gitignoreAGENTS.mdCLAUDE.mdcontroller/channel-test.gocontroller/channel_test_internal_test.godto/gemini.godto/openai_response.gogo.modmodel/option.gomodel/pricing.gopkg/billingexpr/billingexpr_test.gopkg/billingexpr/compile.gopkg/billingexpr/expr.mdpkg/billingexpr/round.gopkg/billingexpr/run.gopkg/billingexpr/settle.gopkg/billingexpr/types.gorelay/audio_handler.gorelay/channel/gemini/relay-gemini.gorelay/chat_completions_via_responses.gorelay/common/billing.gorelay/common/relay_info.gorelay/embedding_handler.gorelay/helper/billing_expr_request.gorelay/helper/billing_expr_request_test.gorelay/helper/price.gorelay/helper/price_test.goservice/billing_session.goservice/log_info_generate.goservice/quota.goservice/text_quota.goservice/text_quota_test.goservice/tiered_settle.goservice/tiered_settle_test.goservice/tool_billing.gosetting/billing_setting/tiered_billing.gosetting/model_setting/claude_test.gosetting/operation_setting/tools.goweb/src/components/settings/RatioSetting.jsxweb/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsxweb/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsxweb/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsxweb/src/components/table/model-pricing/modal/components/ModelEndpoints.jsxweb/src/components/table/model-pricing/modal/components/ModelPricingTable.jsxweb/src/components/table/model-pricing/view/card/PricingCardView.jsxweb/src/components/table/usage-logs/UsageLogsColumnDefs.jsxweb/src/constants/billing.constants.jsweb/src/constants/index.jsweb/src/helpers/render.jsxweb/src/helpers/utils.jsxweb/src/hooks/usage-logs/useUsageLogsData.jsxweb/src/i18n/locales/en.jsonweb/src/i18n/locales/zh-CN.jsonweb/src/index.cssweb/src/pages/Setting/Ratio/ToolPriceSettings.jsxweb/src/pages/Setting/Ratio/components/ModelPricingEditor.jsxweb/src/pages/Setting/Ratio/components/TieredPricingEditor.jsxweb/src/pages/Setting/Ratio/components/requestRuleExpr.jsweb/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
💤 Files with no reviewable changes (1)
- .cursor/rules/project.mdc
✅ Files skipped from review due to trivial changes (14)
- .gitignore
- go.mod
- relay/embedding_handler.go
- AGENTS.md
- relay/chat_completions_via_responses.go
- web/src/index.css
- setting/model_setting/claude_test.go
- web/src/constants/index.js
- relay/helper/price_test.go
- web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
- web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
- .github/workflows/docker-image-nightly.yml
- web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
- service/billing_session.go
🚧 Files skipped from review as they are similar to previous changes (20)
- dto/openai_response.go
- web/src/components/settings/RatioSetting.jsx
- pkg/billingexpr/round.go
- relay/audio_handler.go
- relay/common/billing.go
- CLAUDE.md
- service/log_info_generate.go
- web/src/constants/billing.constants.js
- relay/helper/billing_expr_request.go
- controller/channel_test_internal_test.go
- web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
- web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
- setting/billing_setting/tiered_billing.go
- pkg/billingexpr/compile.go
- pkg/billingexpr/run.go
- pkg/billingexpr/settle.go
- model/option.go
- service/quota.go
- setting/operation_setting/tools.go
- web/src/pages/Setting/Ratio/components/requestRuleExpr.js
| func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} { | ||
| other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, | ||
| usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) | ||
| if tieredResult != nil { | ||
| service.InjectTieredBillingInfo(other, info, tieredResult) | ||
| } |
There was a problem hiding this comment.
Preserve tiered log metadata when settlement falls back.
TryTieredSettle can apply tiered billing but return tieredResult == nil on expression errors. With the current guard, channel-test logs lose billing_mode and expr_b64; InjectTieredBillingInfo already handles a nil result by omitting only matched_tier.
Proposed fix
- if tieredResult != nil {
+ if info != nil &&
+ info.TieredBillingSnapshot != nil &&
+ info.TieredBillingSnapshot.BillingMode == "tiered_expr" {
service.InjectTieredBillingInfo(other, info, tieredResult)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} { | |
| other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, | |
| usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) | |
| if tieredResult != nil { | |
| service.InjectTieredBillingInfo(other, info, tieredResult) | |
| } | |
| func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} { | |
| other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, | |
| usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) | |
| if info != nil && | |
| info.TieredBillingSnapshot != nil && | |
| info.TieredBillingSnapshot.BillingMode == "tiered_expr" { | |
| service.InjectTieredBillingInfo(other, info, tieredResult) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@controller/channel-test.go` around lines 543 - 548, Summary: logging loses
billing_mode and expr_b64 when TryTieredSettle applied tiering but returned
tieredResult == nil because the InjectTieredBillingInfo call is guarded. Fix:
remove the conditional guard around service.InjectTieredBillingInfo in
buildTestLogOther and invoke service.InjectTieredBillingInfo(other, info,
tieredResult) unconditionally so InjectTieredBillingInfo can preserve tiered
metadata even when tieredResult is nil; ensure you call it after other is
created by service.GenerateTextOtherInfo and before returning.
| ``` | ||
| # Simple flat pricing | ||
| tier("base", p * 2.5 + c * 15 + cr * 0.25) | ||
|
|
||
| # Multi-tier (Claude Sonnet style) | ||
| p <= 200000 | ||
| ? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6) | ||
| : tier("long_context", p * 6 + c * 22.5 + cr * 0.6 + cc * 7.5 + cc1h * 12) | ||
|
|
||
| # Image model (no separate cache/audio pricing — those tokens stay in p/c) | ||
| tier("base", p * 2 + c * 8 + img * 2.5) | ||
|
|
||
| # Multimodal with audio | ||
| tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11) | ||
| ``` |
There was a problem hiding this comment.
Add language identifiers to fenced code blocks.
markdownlint flags these fences with MD040. Use expr/text where appropriate so docs lint stays clean.
📝 Proposed doc lint fix
-```
+```expr
# Simple flat pricing
tier("base", p * 2.5 + c * 15 + cr * 0.25)
@@
-```
+```
-```
+```expr
tier("base", p * 5 + c * 25)|||when(header("anthropic-beta") has "fast-mode") * 6- +text
Frontend Editor → Storage → Pre-consume → Settlement → Log Display
-```
+```text
quota = exprOutput / 1,000,000 * QuotaPerUnit * groupRatio
</details>
Also applies to: 112-114, 124-126, 206-208
<details>
<summary>🧰 Tools</summary>
<details>
<summary>🪛 markdownlint-cli2 (0.22.0)</summary>
[warning] 92-92: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
</details>
</details>
<details>
<summary>🤖 Prompt for AI Agents</summary>
Verify each finding against the current code and only fix it if needed.
In @pkg/billingexpr/expr.md around lines 92 - 106, Update the fenced code blocks
in this markdown so they include language identifiers: change the blocks
containing billing expressions like tier("base", p * 2.5 + c * 15 + cr * 0.25),
tier("base", p * 5 + c * 25)|||when(header("anthropic-beta") has "fast-mode") *
6, and other expr-style lines to use expr, and change narrative/plain text blocks such as "Frontend Editor → Storage → Pre-consume → Settlement → Log Display" and the formula "quota = exprOutput / 1,000,000 * QuotaPerUnit * groupRatio" to use text; ensure every opening ``` has the matching language
tag so markdownlint MD040 is satisfied.
</details>
<!-- fingerprinting:phantom:medusa:ibis -->
<!-- This is an auto-generated comment by CodeRabbit -->
| return billingexpr.TokenParams{ | ||
| P: p, | ||
| C: c, | ||
| CR: cr, | ||
| CC: ccTotal - cc1h, | ||
| CC1h: cc1h, | ||
| Img: img, | ||
| ImgO: imgO, | ||
| AI: ai, | ||
| AO: ao, | ||
| } | ||
| } |
There was a problem hiding this comment.
Clamp CC to non-negative.
CC: ccTotal - cc1h can go negative if the upstream provider reports a nonzero ClaudeCacheCreation1hTokens but does not include it in PromptTokensDetails.CachedCreationTokens (seen in some Claude-compatible proxies/response shapes, and also possible with transient accounting mismatches). A negative CC flows straight into cc * rate in the expression and silently reduces the billed quota. P and C are already clamped above — apply the same to CC for symmetry.
🛡️ Proposed fix
if p < 0 {
p = 0
}
if c < 0 {
c = 0
}
+ cc := ccTotal - cc1h
+ if cc < 0 {
+ cc = 0
+ }
return billingexpr.TokenParams{
P: p,
C: c,
CR: cr,
- CC: ccTotal - cc1h,
+ CC: cc,
CC1h: cc1h,
Img: img,
ImgO: imgO,
AI: ai,
AO: ao,
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@service/tiered_settle.go` around lines 60 - 71, The CC field can be negative
because it's set to ccTotal - cc1h; clamp it to zero like P and C are clamped so
negative values cannot reduce billed quota. Modify the TokenParams construction
in billingexpr.TokenParams to compute a local cc := ccTotal - cc1h and if cc < 0
set cc = 0, then set CC: cc (instead of CC: ccTotal - cc1h) to mirror the
existing clamping behavior for P and C.
| totalPrice := pricePer1K * float64(count) / 1000 | ||
| quota := int(math.Round(totalPrice * common.QuotaPerUnit * groupRatio)) | ||
| items = append(items, ToolCallItem{ | ||
| Name: toolName, | ||
| CallCount: count, | ||
| PricePer1K: pricePer1K, | ||
| TotalPrice: totalPrice, | ||
| Quota: quota, | ||
| }) | ||
| totalQuota += quota | ||
| } | ||
|
|
||
| if usage.WebSearchCalls > 0 && usage.WebSearchToolName != "" { | ||
| addItem(usage.WebSearchToolName, usage.WebSearchCalls) | ||
| } | ||
|
|
||
| if usage.FileSearchCalls > 0 { | ||
| addItem("file_search", usage.FileSearchCalls) | ||
| } | ||
|
|
||
| if usage.ImageGenerationCall { | ||
| price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize) | ||
| quota := int(math.Round(price * common.QuotaPerUnit * groupRatio)) | ||
| items = append(items, ToolCallItem{ | ||
| Name: "image_generation", | ||
| CallCount: 1, | ||
| PricePer1K: price * 1000, | ||
| TotalPrice: price, | ||
| Quota: quota, | ||
| }) | ||
| totalQuota += quota |
There was a problem hiding this comment.
Round the aggregate surcharge, not each tool line.
Line 52 rounds every item before adding it to totalQuota, so multiple fractional tool surcharges can over/under-charge compared with the previous aggregate decimal path. Also mirror addItem for image generation by skipping non-positive resolved prices before appending a breakdown item.
💰 Proposed fix
func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResult {
var items []ToolCallItem
- totalQuota := 0
+ totalRawQuota := 0.0
addItem := func(toolName string, count int) {
if count <= 0 {
return
}
@@
if pricePer1K <= 0 {
return
}
totalPrice := pricePer1K * float64(count) / 1000
- quota := int(math.Round(totalPrice * common.QuotaPerUnit * groupRatio))
+ rawQuota := totalPrice * common.QuotaPerUnit * groupRatio
+ quota := int(math.Round(rawQuota))
items = append(items, ToolCallItem{
Name: toolName,
CallCount: count,
PricePer1K: pricePer1K,
TotalPrice: totalPrice,
Quota: quota,
})
- totalQuota += quota
+ totalRawQuota += rawQuota
}
@@
if usage.ImageGenerationCall {
price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize)
- quota := int(math.Round(price * common.QuotaPerUnit * groupRatio))
- items = append(items, ToolCallItem{
- Name: "image_generation",
- CallCount: 1,
- PricePer1K: price * 1000,
- TotalPrice: price,
- Quota: quota,
- })
- totalQuota += quota
+ if price > 0 {
+ rawQuota := price * common.QuotaPerUnit * groupRatio
+ quota := int(math.Round(rawQuota))
+ items = append(items, ToolCallItem{
+ Name: "image_generation",
+ CallCount: 1,
+ PricePer1K: price * 1000,
+ TotalPrice: price,
+ Quota: quota,
+ })
+ totalRawQuota += rawQuota
+ }
}
return ToolCallResult{
- TotalQuota: totalQuota,
+ TotalQuota: int(math.Round(totalRawQuota)),
Items: items,
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@service/tool_billing.go` around lines 51 - 81, The code currently rounds each
tool's surcharge when computing quota (see addItem and the image_generation
block using math.Round) which causes aggregate rounding drift; change addItem
and the image_generation branch to compute and accumulate a float quota
contribution (quotaFloat := totalPrice * common.QuotaPerUnit * groupRatio)
without calling math.Round and do NOT append any item for image_generation if
price <= 0 (mirror addItem's guard); after all items are added, compute
totalQuota once by rounding the accumulated float total (totalQuota =
int(math.Round(floatTotalQuota))); leave ToolCallItem.Quota either as 0 or set
later if you want per-line visible quotas, but ensure the final totalQuota is
computed from the aggregated float before rounding and remove per-item
math.Round usages.
| ...priceFields | ||
| .filter(([field]) => hasTiers && tiers.some((tier) => tier[field] > 0)) | ||
| .map(([field, label]) => ({ | ||
| title: `${t(label)} (${PRICE_SUFFIX})`, | ||
| dataIndex: field, | ||
| render: (v) => v > 0 ? <Text strong>${v.toFixed(4)}</Text> : '-', | ||
| })), |
There was a problem hiding this comment.
Tier-price cells hardcode $, ignoring the configured currency.
Other tiered/price renderers in web/src/helpers/render.jsx use getCurrencyConfig() to honor the user's quota_display_type (USD/CNY/CUSTOM). Here the cell renders ${v.toFixed(4)} directly, so users on CNY/CUSTOM see a dollar sign while every other breakdown view uses their currency. The tier coefficients are already "per 1M tokens in USD-equivalent units" in the rest of the code, so the same conversion path applies.
♻️ Suggested change
Pull { symbol, rate } from getCurrencyConfig() (as done in renderTieredModelPrice) and render `${symbol}${(v * rate).toFixed(4)}` instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`
around lines 132 - 138, The tier-price cells currently hardcode "$" in the
render function inside the priceFields mapping; change the cell renderer to use
the app currency by calling getCurrencyConfig() and pulling { symbol, rate }
(similar to renderTieredModelPrice), then render the value as symbol + (v *
rate).toFixed(4) (and keep the '-' for zero/undefined). Update the title if
PRICE_SUFFIX needs to reflect the configured currency symbol by composing it
from symbol instead of a hardcoded "$". Ensure you reference priceFields,
hasTiers, tiers, and the render function when making this change.
| const gr = groupRatio || 1; | ||
| const exprBody = billingExpr.replace(/^v\d+:/, ''); | ||
| const tierMatches = exprBody.match(/tier\(/g) || []; | ||
| const tierCount = tierMatches.length; | ||
|
|
||
| const varCoeffs = {}; | ||
| const varRe = new RegExp(BILLING_VAR_REGEX.source, 'g'); | ||
| let vm; | ||
| while ((vm = varRe.exec(exprBody)) !== null) { | ||
| if (!(vm[1] in varCoeffs)) varCoeffs[vm[1]] = Number(vm[2]); | ||
| } | ||
| const hasCoeffs = 'p' in varCoeffs || 'c' in varCoeffs; | ||
|
|
There was a problem hiding this comment.
Preserve zero group ratios and show non-base billing variables.
groupRatio || 1 misrepresents valid 0 ratios as full price. Also, hasCoeffs only recognizes p/c, so cache/media/audio-only expressions such as cr * ... or img * ... can render an empty summary despite BILLING_VARS including those variables.
Proposed fix
- const gr = groupRatio || 1;
+ const gr = groupRatio ?? 1;
const exprBody = billingExpr.replace(/^v\d+:/, '');
const tierMatches = exprBody.match(/tier\(/g) || [];
const tierCount = tierMatches.length;
const varCoeffs = {};
const varRe = new RegExp(BILLING_VAR_REGEX.source, 'g');
let vm;
while ((vm = varRe.exec(exprBody)) !== null) {
if (!(vm[1] in varCoeffs)) varCoeffs[vm[1]] = Number(vm[2]);
}
- const hasCoeffs = 'p' in varCoeffs || 'c' in varCoeffs;
+ const hasCoeffs = Object.keys(varCoeffs).length > 0;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const gr = groupRatio || 1; | |
| const exprBody = billingExpr.replace(/^v\d+:/, ''); | |
| const tierMatches = exprBody.match(/tier\(/g) || []; | |
| const tierCount = tierMatches.length; | |
| const varCoeffs = {}; | |
| const varRe = new RegExp(BILLING_VAR_REGEX.source, 'g'); | |
| let vm; | |
| while ((vm = varRe.exec(exprBody)) !== null) { | |
| if (!(vm[1] in varCoeffs)) varCoeffs[vm[1]] = Number(vm[2]); | |
| } | |
| const hasCoeffs = 'p' in varCoeffs || 'c' in varCoeffs; | |
| const gr = groupRatio ?? 1; | |
| const exprBody = billingExpr.replace(/^v\d+:/, ''); | |
| const tierMatches = exprBody.match(/tier\(/g) || []; | |
| const tierCount = tierMatches.length; | |
| const varCoeffs = {}; | |
| const varRe = new RegExp(BILLING_VAR_REGEX.source, 'g'); | |
| let vm; | |
| while ((vm = varRe.exec(exprBody)) !== null) { | |
| if (!(vm[1] in varCoeffs)) varCoeffs[vm[1]] = Number(vm[2]); | |
| } | |
| const hasCoeffs = Object.keys(varCoeffs).length > 0; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/helpers/utils.jsx` around lines 903 - 915, The code currently does
`const gr = groupRatio || 1;` which treats a valid 0 groupRatio as 1 and uses
`hasCoeffs = 'p' in varCoeffs || 'c' in varCoeffs;` which only detects p/c
variables; update both: preserve zero by using a nullish check (use groupRatio
?? 1) when assigning `gr`, and replace the `hasCoeffs` check with a generic test
that detects any billing variable captured in `varCoeffs` (e.g., check
Object.keys(varCoeffs).length > 0 or test for intersection with the full
`BILLING_VARS` set derived from `BILLING_VAR_REGEX`/`BILLING_VARS`) so
cache/media/audio-only vars like `cr` or `img` are recognized; reference
symbols: `gr`, `groupRatio`, `billingExpr`, `varCoeffs`, `varRe`,
`BILLING_VAR_REGEX`, and `hasCoeffs`.
| function rowsToObject(rows) { | ||
| const prices = {}; | ||
| for (const row of rows) { | ||
| const k = row.key.trim(); | ||
| if (!k) continue; | ||
| prices[k] = Number(row.price) || 0; | ||
| } | ||
| return prices; |
There was a problem hiding this comment.
Validate prices before saving billing settings.
JSON mode can save negative or non-numeric prices because rowsToObject uses Number(row.price) || 0. That can silently turn invalid values into free tool calls, or persist negative billing rates.
🛡️ Proposed fix
function rowsToObject(rows) {
const prices = {};
for (const row of rows) {
const k = row.key.trim();
if (!k) continue;
- prices[k] = Number(row.price) || 0;
+ if (Object.prototype.hasOwnProperty.call(prices, k)) {
+ throw new Error(`Duplicate tool price key: ${k}`);
+ }
+ const price = Number(row.price);
+ if (!Number.isFinite(price) || price < 0) {
+ throw new Error(`Invalid price for ${k}`);
+ }
+ prices[k] = price;
}
return prices;
}
@@
const parsed = JSON.parse(text);
- if (typeof parsed !== 'object' || Array.isArray(parsed)) {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setJsonError(t('JSON 必须是对象'));
return;
}
- setRows(objectToRows(parsed));
+ rowsToObject(objectToRows(parsed));
+ setRows(objectToRows(parsed));
setJsonError('');
@@
- const currentPrices = useMemo(() => rowsToObject(rows), [rows]);
-
const handleSave = async () => {
setSaving(true);
try {
+ const currentPrices = rowsToObject(rows);
const res = await API.put('/api/option/', {
key: OPTION_KEY,
value: JSON.stringify(currentPrices),Also applies to: 101-113, 132-140
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/Setting/Ratio/ToolPriceSettings.jsx` around lines 50 - 57,
rowsToObject currently coerces invalid prices to 0 via Number(row.price) || 0;
change it to validate numeric values instead: parse the price (use Number or
parseFloat), ensure it is a finite number and >= 0, and handle invalid inputs
explicitly (e.g., skip the key and surface an error/validation message or throw)
instead of silently writing 0; apply the same validation logic to the other
similar blocks referenced (around the functions/blocks at lines ~101-113 and
~132-140) so negative, NaN, Infinity, or non-numeric strings are rejected or
reported before saving billing settings.
| useEffect(() => { | ||
| let prices = {}; | ||
| try { | ||
| const raw = options?.[OPTION_KEY]; | ||
| if (raw) { | ||
| prices = typeof raw === 'string' ? JSON.parse(raw) : raw; | ||
| } | ||
| } catch { | ||
| prices = {}; | ||
| } | ||
|
|
||
| if (!prices || Object.keys(prices).length === 0) { | ||
| prices = { ...DEFAULT_PRICES }; | ||
| } | ||
|
|
||
| setRows(objectToRows(prices)); | ||
| setJsonText(JSON.stringify(prices, null, 2)); | ||
| }, [options]); |
There was a problem hiding this comment.
Don’t silently replace malformed saved pricing with defaults.
If options[OPTION_KEY] contains invalid JSON, this resets the editor to DEFAULT_PRICES; a subsequent save would overwrite the existing billing config without showing the admin the broken value.
🛡️ Proposed fix
useEffect(() => {
- let prices = {};
+ let prices = {};
+ const raw = options?.[OPTION_KEY];
try {
- const raw = options?.[OPTION_KEY];
if (raw) {
prices = typeof raw === 'string' ? JSON.parse(raw) : raw;
}
- } catch {
- prices = {};
+ } catch (e) {
+ setRows([]);
+ setJsonText(String(raw));
+ setJsonError(`${t('现有工具价格配置解析失败')}: ${e.message}`);
+ setMode('json');
+ return;
}
- if (!prices || Object.keys(prices).length === 0) {
+ if (!prices || typeof prices !== 'object' || Array.isArray(prices) || Object.keys(prices).length === 0) {
prices = { ...DEFAULT_PRICES };
}
setRows(objectToRows(prices));
setJsonText(JSON.stringify(prices, null, 2));
+ setJsonError('');
}, [options]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/Setting/Ratio/ToolPriceSettings.jsx` around lines 76 - 93, The
effect currently swallows JSON parse errors and silently replaces malformed
saved pricing with DEFAULT_PRICES; change the catch to preserve the raw invalid
text and surface an error instead of overwriting: when parsing
options?.[OPTION_KEY] fails, setJsonText to the raw string (const raw =
options?.[OPTION_KEY]), avoid replacing rows with DEFAULT_PRICES, and flip a new
boolean state (e.g., jsonParseError / setJsonParseError) so the UI can show a
validation error and the save handler (where setRows/setJsonText are used) can
block or require correction; keep references to useEffect, OPTION_KEY,
DEFAULT_PRICES, setRows, setJsonText, objectToRows and add the jsonParseError
state and checks in the save path.
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
service/billing_session.go (1)
148-177:⚠️ Potential issue | 🟠 MajorLock
GetPreConsumedQuotanow that reservations mutate it.
Reserveupdatess.preConsumedQuotaunders.mu, butGetPreConsumedQuotareads it without the same lock. That can race once reservation happens after session creation.🔒 Proposed fix
func (s *BillingSession) GetPreConsumedQuota() int { + s.mu.Lock() + defer s.mu.Unlock() return s.preConsumedQuota }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/billing_session.go` around lines 148 - 177, GetPreConsumedQuota currently reads s.preConsumedQuota without holding the session mutex while Reserve mutates it under s.mu, leading to a race; modify GetPreConsumedQuota to acquire the session lock (e.g., use s.mu.RLock() / s.mu.RUnlock() or s.mu.Lock() / s.mu.Unlock()) around the read and then return s.preConsumedQuota to ensure safe concurrent access with Reserve, and update any tests or linter annotations if needed.web/src/helpers/render.jsx (1)
2203-2215:⚠️ Potential issue | 🟡 MinorShow file-search calls in ratio-mode log summaries too.
file_searchis included in price-mode summaries, but when price display is disabled this branch only reportswebSearch; file-search-only logs fall through to the generic text and hide charged file-search calls.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/render.jsx` around lines 2203 - 2215, The ratio-mode branch that currently only reports webSearch calls (the else if (webSearch) block) omits file_search calls so file-search-only logs are hidden; update that branch in render.jsx to include fileSearch and/or fileSearchCallCount (similar to the price-mode summary) — e.g., check fileSearch alongside webSearch and append fileSearchCallCount (or combined call count) to the i18next.t string and its interpolation object (refer to variables webSearch, fileSearch, webSearchCallCount, fileSearchCallCount and the existing ratioLabel/ratio variables) so file-search calls appear in ratio-mode summaries.
♻️ Duplicate comments (11)
service/tool_billing.go (1)
51-81:⚠️ Potential issue | 🟠 MajorRound the aggregate surcharge once.
Rounding each tool line before summing can drift from the aggregate billing amount when multiple fractional surcharges are present. Accumulate raw quota contributions and round only
TotalQuota.💰 Proposed fix
func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResult { var items []ToolCallItem - totalQuota := 0 + totalRawQuota := 0.0 addItem := func(toolName string, count int) { @@ if pricePer1K <= 0 { return } totalPrice := pricePer1K * float64(count) / 1000 - quota := int(math.Round(totalPrice * common.QuotaPerUnit * groupRatio)) + rawQuota := totalPrice * common.QuotaPerUnit * groupRatio + quota := int(math.Round(rawQuota)) items = append(items, ToolCallItem{ Name: toolName, CallCount: count, PricePer1K: pricePer1K, TotalPrice: totalPrice, Quota: quota, }) - totalQuota += quota + totalRawQuota += rawQuota } @@ if usage.ImageGenerationCall { price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize) - quota := int(math.Round(price * common.QuotaPerUnit * groupRatio)) - items = append(items, ToolCallItem{ - Name: "image_generation", - CallCount: 1, - PricePer1K: price * 1000, - TotalPrice: price, - Quota: quota, - }) - totalQuota += quota + if price > 0 { + rawQuota := price * common.QuotaPerUnit * groupRatio + quota := int(math.Round(rawQuota)) + items = append(items, ToolCallItem{ + Name: "image_generation", + CallCount: 1, + PricePer1K: price * 1000, + TotalPrice: price, + Quota: quota, + }) + totalRawQuota += rawQuota + } } return ToolCallResult{ - TotalQuota: totalQuota, + TotalQuota: int(math.Round(totalRawQuota)), Items: items, } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/tool_billing.go` around lines 51 - 81, The code currently rounds each item's quota before summing, causing drift; change it to accumulate raw (float64) quota contributions and round only once at the end: introduce a rawTotalQuota float64, compute per-item rawQuota = price * common.QuotaPerUnit * groupRatio inside addItem and the image_generation block, add rawQuota to rawTotalQuota (do not add the rounded int to totalQuota), still populate ToolCallItem.Quota with an integer representation if required (e.g., int(rawQuota) or int(math.Floor(rawQuota))) for display, and after all items are added set totalQuota = int(math.Round(rawTotalQuota)). Ensure you update usages in addItem, the image_generation block, and any initialization of totalQuota/rawTotalQuota accordingly.web/src/helpers/render.jsx (2)
2304-2310:⚠️ Potential issue | 🟡 MinorKeep
labelout of the i18n key.The template literal bakes the raw label into the translation key, so labels are not translated through their own stable keys. Use a
{{label}}placeholder and passi18next.t(label).♻️ Proposed fix
.filter(([field]) => tier[field] > 0) .map(([field, label]) => - buildBillingPriceText(`${label}:{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }), + buildBillingPriceText('{{label}}:{{symbol}}{{price}} / 1M tokens', { + label: i18next.t(label), + symbol, + usdAmount: tier[field], + rate, + }), ),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/render.jsx` around lines 2304 - 2310, The i18n key currently inlined the raw label into the template; update the buildBillingPriceText call so the template uses a {{label}} placeholder instead of embedding `${label}`, and pass a translated label via i18next.t(label) in the replacement object (alongside symbol, usdAmount: tier[field], rate). Locate the map over priceLines and modify the template string and replacement object used by buildBillingPriceText (referencing priceLines, tier, buildBillingPriceText, label, symbol, usdAmount, rate) so labels are translated through i18next.t(label) rather than baked into the key.
2251-2277:⚠️ Potential issue | 🟠 MajorParse
tier(...)bodies with balanced parentheses.This still uses
([^)]+), so expressions such astier("x", (p)*2 + c*8)ortier("x", max(p, c))are truncated beforeparseTierBody, producing empty/incorrect tier prices. Use a small balanced-parenthesis scanner after matchingtier("label",.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/helpers/render.jsx` around lines 2251 - 2277, The current parseTiersFromExpr uses a regex with ([^)]+) which fails for nested parentheses in the tier(...) second argument; update parseTiersFromExpr (around the tierRe / while loop and use of m[3]) to first match up to tier("LABEL", then locate the character index of the opening parenthesis for the second argument and run a small scanner that advances one char at a time counting open/close parens until they balance (handling nested parentheses and ignoring string escapes if needed) to extract the full argument string, then pass that balanced substring into parseTierBody, set tier.label from the captured label, and preserve the existing condition parsing logic; remove reliance on the ([^)]+) capture for the body.web/src/i18n/locales/zh-CN.json (1)
3731-3731:⚠️ Potential issue | 🔴 CriticalRemove the duplicate
变量translation key.Line 3731 redeclares
"变量"; the same key already exists at Line 909 in thistranslationobject. Keep one shared entry or rename this one if it needs a different context.#!/bin/bash # Verify duplicate keys in the zh-CN translation object. python - <<'PY' import json from pathlib import Path path = Path("web/src/i18n/locales/zh-CN.json") dupes = [] def hook(pairs): seen = set() for key, _ in pairs: if key in seen: dupes.append(key) seen.add(key) return dict(pairs) json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=hook) if dupes: print("Duplicate keys:") for key in sorted(set(dupes)): print(f"- {key}") raise SystemExit(1) print("No duplicate keys found.") PY🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/i18n/locales/zh-CN.json` at line 3731, Remove the duplicate translation key "变量" from the zh-CN translation object: locate the second occurrence (the one at line ~3731) and delete it so only the original "变量" key remains (or if this entry needs different context, rename this duplicate to a distinct key name). Ensure the JSON remains valid (no trailing commas) after removal and run the provided duplicate-key check to verify no duplicates remain.web/src/hooks/usage-logs/useUsageLogsData.jsx (1)
500-507:⚠️ Potential issue | 🟡 MinorPass
billingDisplayModeto the tiered renderer.The standard renderers receive
displayMode, but this tiered path still omits it, so tiered billing rows can ignore the user's price/ratio display preference.Proposed fix
value: renderTieredModelPrice({ ...other, prompt_tokens: logs[i].prompt_tokens, completion_tokens: logs[i].completion_tokens, + displayMode: billingDisplayMode, }),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/hooks/usage-logs/useUsageLogsData.jsx` around lines 500 - 507, The tiered-billing branch that builds expandDataLocal currently calls renderTieredModelPrice without the user's display preference; update the expandDataLocal.push call (the block checking other?.billing_mode === 'tiered_expr' && other?.expr_b64) to pass the existing billingDisplayMode (or displayMode used by other renderers) into renderTieredModelPrice so the tiered renderer honors the user's price/ratio display setting.web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx (1)
141-148:⚠️ Potential issue | 🟡 MinorMake tier-mode detection tolerate formatted expressions.
includes('tier(')mislabels valid formatted expressions such astier ("base", ...)as generic expression billing.Proposed fix
const getExprModeLabel = useCallback((model) => { if (model?.billingMode !== 'tiered_expr') { return ''; } - return (model.billingExpr || '').includes('tier(') + const expr = model.billingExpr || ''; + if (!expr.trim()) { + return t('表达式/阶梯计费'); + } + return /\btier\s*\(/.test(expr) ? t('阶梯计费') : t('表达式计费'); }, [t]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx` around lines 141 - 148, The model expression detection in getExprModeLabel misidentifies formatted expressions because it uses (model.billingExpr || '').includes('tier('); change this to test for the "tier" token followed by optional whitespace and a parenthesis (e.g. use a regex like /\btier\s*\(/i against model.billingExpr) so expressions like 'tier ("base", ...)' are correctly recognized as tiered billing and still return t('阶梯计费'); keep the billingMode check (model?.billingMode !== 'tiered_expr') unchanged and only replace the includes(...) check with the regex test.web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx (1)
39-39:⚠️ Potential issue | 🟡 MinorUse the configured currency instead of hardcoding
$.Users configured for CNY/CUSTOM will still see dollar-prefixed tier prices here.
Proposed fix
-import { parseTiersFromExpr } from '../../../../../helpers'; +import { getCurrencyConfig, parseTiersFromExpr } from '../../../../../helpers'; @@ -const PRICE_SUFFIX = '$/1M tokens'; - @@ const hasTiers = tiers && tiers.length > 0; const hasRules = ruleGroups && ruleGroups.length > 0; + const { symbol, rate } = getCurrencyConfig(); + const priceSuffix = `${symbol}/1M tokens`; @@ .filter(([field]) => hasTiers && tiers.some((tier) => tier[field] > 0)) .map(([field, label]) => ({ - title: `${t(label)} (${PRICE_SUFFIX})`, + title: `${t(label)} (${priceSuffix})`, dataIndex: field, - render: (v) => v > 0 ? <Text strong>${v.toFixed(4)}</Text> : '-', + render: (v) => v > 0 ? <Text strong>{symbol}{(v * rate).toFixed(4)}</Text> : '-', })),Also applies to: 132-138
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx` at line 39, PRICE_SUFFIX is hardcoded to '$/1M tokens' causing non-USD users to see wrong currency; change PRICE_SUFFIX to derive the symbol from the component's configured currency (e.g., use a prop or context like currency or currencySymbol) and build the suffix as `${currencySymbol}/1M tokens` with a sensible fallback (e.g., '$') — update the PRICE_SUFFIX constant and every place the hardcoded '$' is used (including the tier price rendering in the component where the suffix is appended) so the UI uses the configured currency symbol for CNY/CUSTOM users.service/text_quota.go (2)
139-155:⚠️ Potential issue | 🟠 MajorUse
billingexpr.QuotaRoundfor tiered quota composition.This tiered path bypasses the billing package’s central quota rounding helper, so composed quota can drift from other tiered settlement paths.
Proposed fix
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int { if summary.ToolCallSurchargeQuota.IsZero() { return tieredQuota } if tieredResult != nil { if snap := relayInfo.TieredBillingSnapshot; snap != nil { - return int(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup). + return billingexpr.QuotaRound(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup). Mul(decimal.NewFromFloat(snap.GroupRatio)). Add(summary.ToolCallSurchargeQuota). - Round(0). - IntPart()) + InexactFloat64()) } } - return tieredQuota + int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) + return tieredQuota + billingexpr.QuotaRound(summary.ToolCallSurchargeQuota.InexactFloat64()) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/text_quota.go` around lines 139 - 155, composeTieredTextQuota currently computes and rounds the composed quota inline which bypasses billingexpr.QuotaRound; update it to build the composed quota as a decimal using tieredResult.ActualQuotaBeforeGroup * snap.GroupRatio + summary.ToolCallSurchargeQuota (or the fallback path using tieredQuota + summary.ToolCallSurchargeQuota) and then pass that decimal value through billingexpr.QuotaRound to produce the final int; reference composeTieredTextQuota, relayInfo.TieredBillingSnapshot, tieredResult.ActualQuotaBeforeGroup, snap.GroupRatio, summary.ToolCallSurchargeQuota and billingexpr.QuotaRound to locate where to replace the current Mul/Add/Round/IntPart logic so all tiered quota composition uses billingexpr.QuotaRound.
332-342:⚠️ Potential issue | 🟠 MajorInject tiered log metadata whenever tiered settlement applies.
TryTieredSettlecan returntieredOk == truewithtieredRes == nil; gating ontieredResultdrops tiered metadata from charged fallback requests.Proposed fix
var tieredResult *billingexpr.TieredResult + tieredBillingApplied := false if originUsage != nil { var tieredUsedVars map[string]bool if snap := relayInfo.TieredBillingSnapshot; snap != nil { tieredUsedVars = billingexpr.UsedVars(snap.ExprString) } tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, summary.IsClaudeUsageSemantic, tieredUsedVars)) if tieredOk { + tieredBillingApplied = true tieredResult = tieredRes summary.Quota = composeTieredTextQuota(relayInfo, summary, tieredQuota, tieredRes) } } @@ - if tieredResult != nil { + if tieredBillingApplied { InjectTieredBillingInfo(other, relayInfo, tieredResult) }Also applies to: 454-456
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@service/text_quota.go` around lines 332 - 342, TryTieredSettle may return tieredOk == true with tieredRes == nil but the current code only injects tiered metadata when tieredResult is non-nil; change the logic in the block handling TryTieredSettle (around the tieredResult variable and the call sites that set summary.Quota/composeTieredTextQuota) to treat tieredOk as the signal to inject tiered log/charge metadata even if tieredRes is nil: always assign tieredResult = tieredRes when tieredOk is true and ensure the code path that attaches tiered metadata for charged fallback requests uses tieredOk (or the saved tieredResult variable, which may be nil) rather than gating on non-nil tieredResult; apply the same change to the other occurrence at the later block (lines noted in the comment).controller/channel-test.go (2)
530-540:⚠️ Potential issue | 🟡 MinorApply the group ratio in channel-test fallback settlement.
The fallback quota paths still omit
priceData.GroupRatioInfo.GroupRatio, so channel-test logs can under/over-report quota compared with production settlement for users whose group ratio is not1.Proposed fix
if !priceData.UsePrice { quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) quota = int(math.Round(float64(quota) * priceData.ModelRatio)) - if priceData.ModelRatio != 0 && quota <= 0 { + quota = int(math.Round(float64(quota) * priceData.GroupRatioInfo.GroupRatio)) + if priceData.ModelRatio != 0 && priceData.GroupRatioInfo.GroupRatio != 0 && quota <= 0 { quota = 1 } return quota, nil } - return int(priceData.ModelPrice * common.QuotaPerUnit), nil + return int(priceData.ModelPrice * common.QuotaPerUnit * priceData.GroupRatioInfo.GroupRatio), nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/channel-test.go` around lines 530 - 540, The fallback quota computation in the !priceData.UsePrice branch does not apply priceData.GroupRatioInfo.GroupRatio, causing mismatches with production; update the quota calculation in the function that computes quota (the block using usage.PromptTokens, usage.CompletionTokens, priceData.CompletionRatio, priceData.ModelRatio) to multiply the final quota by priceData.GroupRatioInfo.GroupRatio (treat missing/nil ratio as 1.0), and also apply the same GroupRatio multiplication to the return value in the price-enabled branch (the return of int(priceData.ModelPrice * common.QuotaPerUnit)) so both paths consistently include GroupRatioInfo.GroupRatio.
543-548:⚠️ Potential issue | 🟡 MinorPreserve tiered log metadata when tiered settlement falls back.
TryTieredSettlecan apply tiered billing while returning a nil result; this guard then dropsbilling_modeandexpr_b64from channel-test logs.Proposed fix
- if tieredResult != nil { + if info != nil && + info.TieredBillingSnapshot != nil && + info.TieredBillingSnapshot.BillingMode == "tiered_expr" { service.InjectTieredBillingInfo(other, info, tieredResult) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/channel-test.go` around lines 543 - 548, buildTestLogOther currently drops tiered billing metadata when TryTieredSettle falls back because it only injects metadata when tieredResult != nil; update buildTestLogOther to preserve billing metadata even if tieredResult is nil by checking for the tiered metadata source that TryTieredSettle populates (e.g. fields on info or usage set by TryTieredSettle) and copying billing_mode and expr_b64 into the other map when present, while still calling service.InjectTieredBillingInfo(other, info, tieredResult) when tieredResult != nil; look for references to TryTieredSettle, buildTestLogOther, service.InjectTieredBillingInfo and service.GenerateTextOtherInfo to implement this.
🧹 Nitpick comments (5)
web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx (1)
20-35: UseuseTranslation()inside this React component.This component currently receives
tas a prop; switch to the hook here and remove the prop from callers.Proposed fix
import React from 'react'; import { Avatar, Tag, Table, Typography } from '@douyinfe/semi-ui'; import { IconPriceTag } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; @@ -export default function DynamicPricingBreakdown({ billingExpr, t }) { +export default function DynamicPricingBreakdown({ billingExpr }) { + const { t } = useTranslation(); + const { billingExpr: baseExpr, requestRuleExpr: ruleExpr } = splitBillingExprAndRequestRules(billingExpr || '');As per coding guidelines, "
web/src/**/*.{tsx,ts,jsx,js}: UseuseTranslation()hook and callt('中文key')to access translations in React components".Also applies to: 91-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx` around lines 20 - 35, DynamicPricingBreakdown.jsx currently accepts a t prop; switch this component to use the useTranslation() hook instead: import { useTranslation } from 'react-i18next', call const { t } = useTranslation() inside the DynamicPricingBreakdown component, remove t from the component's props signature and usages expecting an injected prop, and update any internal calls (including the strings around lines referenced 91-97) to use the hook-provided t. Also remove t from all caller invocations of DynamicPricingBreakdown so callers stop passing t as a prop.web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js (1)
1037-1063: Refactor: collapse the two consecutivetiered_exprchecks.In the loop the same
billingMode === 'tiered_expr'condition is evaluated twice (once to populatetieredOutput, then again tocontinue). A single if/else keeps the intent clearer and avoids future drift if one branch is updated but not the other.♻️ Proposed refactor
for (const model of models) { if (model.billingMode === 'tiered_expr') { tieredOutput['billing_setting.billing_mode'][model.name] = 'tiered_expr'; const finalBillingExpr = combineBillingExpr( model.billingExpr, model.requestRuleExpr, ); if (finalBillingExpr) { tieredOutput['billing_setting.billing_expr'][model.name] = finalBillingExpr; } - } - if (model.billingMode === 'tiered_expr') { continue; } const serialized = serializeModel(model, t);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js` around lines 1037 - 1063, The loop currently checks model.billingMode === 'tiered_expr' twice; collapse into a single if/else: when model.billingMode === 'tiered_expr' compute finalBillingExpr via combineBillingExpr(model.billingExpr, model.requestRuleExpr'), set tieredOutput['billing_setting.billing_mode'][model.name]='tiered_expr' and, if finalBillingExpr, set tieredOutput['billing_setting.billing_expr'][model.name]=finalBillingExpr, then continue; otherwise (else) call serializeModel(model, t) and merge its non-null entries into output[key][model.name] as before. Ensure you update the loop in useModelPricingEditorState.js to use this single if/else flow.web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx (2)
914-932: Dead.replace('_', '_').
f.var.replace('_', '_')is a no-op and just obscures the intent of the regex assembly. Either drop the map transform or replace it with whatever escaping was originally planned (none of the currentf.varvalues —cr,cc,cc1h,img,img_o,ai,ao— contain regex metacharacters, so a plain.map((f) => f.var)is sufficient).♻️ Proposed cleanup
- const varNames = EXTRA_ESTIMATOR_FIELDS.map((f) => f.var.replace('_', '_')).join('|'); + const varNames = EXTRA_ESTIMATOR_FIELDS.map((f) => f.var).join('|');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 914 - 932, In CacheTokenEstimatorInputs the varNames string is built using a no-op f.var.replace('_', '_') which should be simplified or properly escaped; update the varNames line so it uses EXTRA_ESTIMATOR_FIELDS.map((f) => f.var).join('|') or, if you want to be robust, escape regex meta-characters with something like .map(f => f.var.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|'); keep the rest of the RegExp test against effectiveExpr unchanged.
960-983: Consider alternative tonew Function()to avoid future CSP violations.The token estimator uses
new Function(...)at runtime to evaluate expressions, which requires'unsafe-eval'in any Content-Security-Policy. While CSP is not currently configured, if a strict CSP is ever deployed without this exception, the estimator will silently fail (caught at line 980) and display "表达式错误" for all expressions, including valid ones. Consider implementing a simple expression parser for the limited grammar needed (p, c, tier, min/max/abs/ceil/floor,+ - * / ? :) to eliminate this CSP dependency and improve security posture.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx` around lines 960 - 983, The evalExprLocally function currently uses new Function(...) which will break under strict CSP; replace the runtime-eval approach with a small, safe expression evaluator that parses the limited grammar (variables p, c, tier(), numeric literals, functions min/max/abs/ceil/floor, operators + - * / and the ternary ?:) rather than calling new Function; implement a parser+interpreter (or embed a tiny expression library) that returns the same shape { cost, matchedTier, error }, preserves the tier callback behavior (track matchedTier when tier(name, value) is invoked), and keeps the existing extraTokenValues lookup for EXTRA_ESTIMATOR_FIELDS and error handling to surface parse/eval errors instead of relying on catching CSP-eval failures.web/src/pages/Setting/Ratio/components/requestRuleExpr.js (1)
417-443: Parse result is computed twice per part.
tryParseRequestRuleExpr(part)is called once for the null-check and again for.length— the first parse (which walks regex/splits) is thrown away. Cache it:♻️ Proposed refactor
- parts.forEach((part) => { - if (tryParseRequestRuleExpr(part) !== null && tryParseRequestRuleExpr(part).length > 0) { - ruleParts.push(part); - } else { - baseParts.push(part); - } - }); + parts.forEach((part) => { + const parsed = tryParseRequestRuleExpr(part); + if (parsed !== null && parsed.length > 0) { + ruleParts.push(part); + } else { + baseParts.push(part); + } + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/Setting/Ratio/components/requestRuleExpr.js` around lines 417 - 443, In splitBillingExprAndRequestRules, avoid calling tryParseRequestRuleExpr(part) twice for each part; store the result of tryParseRequestRuleExpr(part) in a local variable (e.g., parsed or match) inside the parts.forEach loop and use that cached value for the null-check and length check, pushing to ruleParts when parsed !== null && parsed.length > 0 and to baseParts otherwise; ensure you still call unwrapOuterParens on baseParts[0] and join ruleParts with ' * ' as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/docker-image-nightly.yml:
- Around line 3-7: The workflow currently triggers on pushes to nightly and can
publish calciumion/new-api:nightly by combining mutable per-arch tags (e.g.,
nightly-amd64, nightly-arm64) from different runs, causing races; change the
publishing flow so per-arch images are pushed with immutable identifiers (e.g.,
include GITHUB_SHA or GITHUB_RUN_ID like nightly-<sha>-amd64,
nightly-<sha>-arm64) and only create/push the multi-arch
calciumion/new-api:nightly tag from a single orchestrating job that has built or
collected all arch images for the same commit (either by running all arch builds
in one workflow or by using a workflow_run/concurrency pattern to aggregate
artifacts and then create the manifest). Ensure references to nightly-amd64 and
nightly-arm64 are replaced with the immutable names when assembling the final
nightly manifest.
- Around line 37-44: The nightly version is being recomputed in every job (the
step named "Determine nightly version" with id version), causing inconsistent
tags; instead create a single dedicated job (e.g., job id determine_version or
version) that runs once, computes VERSION="nightly-$(date +'%Y%m%d')-$(git
rev-parse --short HEAD')" and emits it via the job output (echo "value=$VERSION"
>> $GITHUB_OUTPUT), then make all other jobs depend on that job and consume the
version through needs.version.outputs.value (or
needs.determine_version.outputs.value) rather than recalculating it in each job;
update places that currently echo to $GITHUB_ENV or recompute the date to use
the shared job output so the same version string is used across the entire
workflow run.
In `@service/log_info_generate.go`:
- Around line 271-280: InjectTieredBillingInfo currently dereferences relayInfo
and writes into other without nil checks; guard against nil inputs by returning
early if other == nil or relayInfo == nil, and also verify
relayInfo.TieredBillingSnapshot != nil before accessing snap.ExprString; only
set other["billing_mode"], other["expr_b64"], and other["matched_tier"] when the
respective values and maps are non-nil (use result != nil and snap != nil
checks), referencing the InjectTieredBillingInfo function,
relayInfo.TieredBillingSnapshot, snap.ExprString, result.MatchedTier, and the
other map to locate and update the logic.
In `@setting/billing_setting/tiered_billing.go`:
- Around line 74-80: The smoke test currently only checks "result < 0" which
allows NaN and ±Inf to pass; update the validation after
billingexpr.RunExprWithRequest (the block where result is returned) to
explicitly reject non-finite values by checking math.IsNaN(result) ||
math.IsInf(result, 0) and return an error referencing the vector (v.P, v.C) and
the non-finite result before the existing negative-value check; keep the
existing error messages/formatting style used for the RunExprWithRequest error
and the negative-result error.
- Around line 35-45: GetBillingMode and GetBillingExpr read the config maps
billingSetting.BillingMode and billingSetting.BillingExpr unsafely, risking
concurrent map panics when config.GlobalConfig.LoadFromDB() mutates them via
json.Unmarshal; fix by making accesses safe: either guard reads with the same
lock used when updating the maps (introduce/read from a RLock in
GetBillingMode/GetBillingExpr and ensure ConfigManager/LoadFromDB uses the
matching Lock/RUnlock when replacing or mutating billingSetting), or switch to
atomic snapshot semantics (replace the maps with a new map instance and publish
it via an atomic pointer) or use a thread-safe map wrapper for
billingSetting.BillingMode and billingSetting.BillingExpr so
GetBillingMode/GetBillingExpr can read without races while relay/helper/price.go
continues to call them.
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`:
- Around line 69-76: The output for the SOURCE_TIME branch incorrectly appends
":00~:00" to non-hour time functions (so MATCH_RANGE renders "星期 1:00~5:00");
update the cond.source === SOURCE_TIME block to only append hour-specific clock
formatting when cond.timeFunc is the hour function (e.g., TIME_FUNC_HOUR or
whatever constant/value represents hours in your TIME_FUNC_* set), otherwise
render ranges/values without adding ":00" (use TIME_FUNC_LABELS[cond.timeFunc]
as before, conditionalize adding ":00" to cond.rangeStart/cond.rangeEnd and to
single-value display, leaving timezone handling unchanged).
In `@web/src/helpers/render.jsx`:
- Around line 1640-1666: The function renderModelPrice currently destructures
completion_ratio into the const completionRatio (and similarly an audio ratio
variable derived from audio_input_seperate_price) and later reassigns those
const bindings, causing "Assignment to constant variable" errors; fix by making
those values mutable from the start—either provide defaults in the destructuring
or destructure into differently named bindings and then assign to let variables
(e.g., change completionRatio to a let-computed value or destructure as
completionRatioRaw and set let completionRatio = completionRatioRaw; similarly
handle the audio ratio derived from
audio_input_seperate_price/audio_input_token_count/audio_input_price) so all
subsequent reassignment sites in renderModelPrice operate on mutable variables.
In `@web/src/pages/Setting/Ratio/components/requestRuleExpr.js`:
- Around line 153-187: The splitTopLevelMultiply and splitTopLevelAnd functions
only track parenthesis depth and therefore mis-split when operator substrings
appear inside string literals; update both functions to also track an inString
boolean (toggle when seeing an unescaped double-quote) and treat escaped quotes
(backslash) correctly so you only check for ' * ' and ' && ' when depth === 0
and inString === false; adjust index advancement logic accordingly and ensure
you still trim/push parts and return parts.filter(Boolean) as before.
In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js`:
- Around line 875-884: The handler handleBillingModeChange currently writes a
zero-cost template into billingExpr when switching billingMode to 'tiered_expr',
which can unintentionally make models free; change the logic in
handleBillingModeChange (and the upsertModel callback) so that if value ===
'tiered_expr' and !model.billingExpr you either leave billingExpr
undefined/empty (''), or set a small non-zero placeholder (e.g. a minimal price
expression) instead of 'tier("base", p * 0 + c * 0)'; keep
selectedModel/upsertModel usage the same and ensure the editor UI still provides
its visual default when billingExpr is empty.
---
Outside diff comments:
In `@service/billing_session.go`:
- Around line 148-177: GetPreConsumedQuota currently reads s.preConsumedQuota
without holding the session mutex while Reserve mutates it under s.mu, leading
to a race; modify GetPreConsumedQuota to acquire the session lock (e.g., use
s.mu.RLock() / s.mu.RUnlock() or s.mu.Lock() / s.mu.Unlock()) around the read
and then return s.preConsumedQuota to ensure safe concurrent access with
Reserve, and update any tests or linter annotations if needed.
In `@web/src/helpers/render.jsx`:
- Around line 2203-2215: The ratio-mode branch that currently only reports
webSearch calls (the else if (webSearch) block) omits file_search calls so
file-search-only logs are hidden; update that branch in render.jsx to include
fileSearch and/or fileSearchCallCount (similar to the price-mode summary) —
e.g., check fileSearch alongside webSearch and append fileSearchCallCount (or
combined call count) to the i18next.t string and its interpolation object (refer
to variables webSearch, fileSearch, webSearchCallCount, fileSearchCallCount and
the existing ratioLabel/ratio variables) so file-search calls appear in
ratio-mode summaries.
---
Duplicate comments:
In `@controller/channel-test.go`:
- Around line 530-540: The fallback quota computation in the !priceData.UsePrice
branch does not apply priceData.GroupRatioInfo.GroupRatio, causing mismatches
with production; update the quota calculation in the function that computes
quota (the block using usage.PromptTokens, usage.CompletionTokens,
priceData.CompletionRatio, priceData.ModelRatio) to multiply the final quota by
priceData.GroupRatioInfo.GroupRatio (treat missing/nil ratio as 1.0), and also
apply the same GroupRatio multiplication to the return value in the
price-enabled branch (the return of int(priceData.ModelPrice *
common.QuotaPerUnit)) so both paths consistently include
GroupRatioInfo.GroupRatio.
- Around line 543-548: buildTestLogOther currently drops tiered billing metadata
when TryTieredSettle falls back because it only injects metadata when
tieredResult != nil; update buildTestLogOther to preserve billing metadata even
if tieredResult is nil by checking for the tiered metadata source that
TryTieredSettle populates (e.g. fields on info or usage set by TryTieredSettle)
and copying billing_mode and expr_b64 into the other map when present, while
still calling service.InjectTieredBillingInfo(other, info, tieredResult) when
tieredResult != nil; look for references to TryTieredSettle, buildTestLogOther,
service.InjectTieredBillingInfo and service.GenerateTextOtherInfo to implement
this.
In `@service/text_quota.go`:
- Around line 139-155: composeTieredTextQuota currently computes and rounds the
composed quota inline which bypasses billingexpr.QuotaRound; update it to build
the composed quota as a decimal using tieredResult.ActualQuotaBeforeGroup *
snap.GroupRatio + summary.ToolCallSurchargeQuota (or the fallback path using
tieredQuota + summary.ToolCallSurchargeQuota) and then pass that decimal value
through billingexpr.QuotaRound to produce the final int; reference
composeTieredTextQuota, relayInfo.TieredBillingSnapshot,
tieredResult.ActualQuotaBeforeGroup, snap.GroupRatio,
summary.ToolCallSurchargeQuota and billingexpr.QuotaRound to locate where to
replace the current Mul/Add/Round/IntPart logic so all tiered quota composition
uses billingexpr.QuotaRound.
- Around line 332-342: TryTieredSettle may return tieredOk == true with
tieredRes == nil but the current code only injects tiered metadata when
tieredResult is non-nil; change the logic in the block handling TryTieredSettle
(around the tieredResult variable and the call sites that set
summary.Quota/composeTieredTextQuota) to treat tieredOk as the signal to inject
tiered log/charge metadata even if tieredRes is nil: always assign tieredResult
= tieredRes when tieredOk is true and ensure the code path that attaches tiered
metadata for charged fallback requests uses tieredOk (or the saved tieredResult
variable, which may be nil) rather than gating on non-nil tieredResult; apply
the same change to the other occurrence at the later block (lines noted in the
comment).
In `@service/tool_billing.go`:
- Around line 51-81: The code currently rounds each item's quota before summing,
causing drift; change it to accumulate raw (float64) quota contributions and
round only once at the end: introduce a rawTotalQuota float64, compute per-item
rawQuota = price * common.QuotaPerUnit * groupRatio inside addItem and the
image_generation block, add rawQuota to rawTotalQuota (do not add the rounded
int to totalQuota), still populate ToolCallItem.Quota with an integer
representation if required (e.g., int(rawQuota) or int(math.Floor(rawQuota)))
for display, and after all items are added set totalQuota =
int(math.Round(rawTotalQuota)). Ensure you update usages in addItem, the
image_generation block, and any initialization of totalQuota/rawTotalQuota
accordingly.
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`:
- Line 39: PRICE_SUFFIX is hardcoded to '$/1M tokens' causing non-USD users to
see wrong currency; change PRICE_SUFFIX to derive the symbol from the
component's configured currency (e.g., use a prop or context like currency or
currencySymbol) and build the suffix as `${currencySymbol}/1M tokens` with a
sensible fallback (e.g., '$') — update the PRICE_SUFFIX constant and every place
the hardcoded '$' is used (including the tier price rendering in the component
where the suffix is appended) so the UI uses the configured currency symbol for
CNY/CUSTOM users.
In `@web/src/helpers/render.jsx`:
- Around line 2304-2310: The i18n key currently inlined the raw label into the
template; update the buildBillingPriceText call so the template uses a {{label}}
placeholder instead of embedding `${label}`, and pass a translated label via
i18next.t(label) in the replacement object (alongside symbol, usdAmount:
tier[field], rate). Locate the map over priceLines and modify the template
string and replacement object used by buildBillingPriceText (referencing
priceLines, tier, buildBillingPriceText, label, symbol, usdAmount, rate) so
labels are translated through i18next.t(label) rather than baked into the key.
- Around line 2251-2277: The current parseTiersFromExpr uses a regex with
([^)]+) which fails for nested parentheses in the tier(...) second argument;
update parseTiersFromExpr (around the tierRe / while loop and use of m[3]) to
first match up to tier("LABEL", then locate the character index of the opening
parenthesis for the second argument and run a small scanner that advances one
char at a time counting open/close parens until they balance (handling nested
parentheses and ignoring string escapes if needed) to extract the full argument
string, then pass that balanced substring into parseTierBody, set tier.label
from the captured label, and preserve the existing condition parsing logic;
remove reliance on the ([^)]+) capture for the body.
In `@web/src/hooks/usage-logs/useUsageLogsData.jsx`:
- Around line 500-507: The tiered-billing branch that builds expandDataLocal
currently calls renderTieredModelPrice without the user's display preference;
update the expandDataLocal.push call (the block checking other?.billing_mode ===
'tiered_expr' && other?.expr_b64) to pass the existing billingDisplayMode (or
displayMode used by other renderers) into renderTieredModelPrice so the tiered
renderer honors the user's price/ratio display setting.
In `@web/src/i18n/locales/zh-CN.json`:
- Line 3731: Remove the duplicate translation key "变量" from the zh-CN
translation object: locate the second occurrence (the one at line ~3731) and
delete it so only the original "变量" key remains (or if this entry needs
different context, rename this duplicate to a distinct key name). Ensure the
JSON remains valid (no trailing commas) after removal and run the provided
duplicate-key check to verify no duplicates remain.
In `@web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx`:
- Around line 141-148: The model expression detection in getExprModeLabel
misidentifies formatted expressions because it uses (model.billingExpr ||
'').includes('tier('); change this to test for the "tier" token followed by
optional whitespace and a parenthesis (e.g. use a regex like /\btier\s*\(/i
against model.billingExpr) so expressions like 'tier ("base", ...)' are
correctly recognized as tiered billing and still return t('阶梯计费'); keep the
billingMode check (model?.billingMode !== 'tiered_expr') unchanged and only
replace the includes(...) check with the regex test.
---
Nitpick comments:
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`:
- Around line 20-35: DynamicPricingBreakdown.jsx currently accepts a t prop;
switch this component to use the useTranslation() hook instead: import {
useTranslation } from 'react-i18next', call const { t } = useTranslation()
inside the DynamicPricingBreakdown component, remove t from the component's
props signature and usages expecting an injected prop, and update any internal
calls (including the strings around lines referenced 91-97) to use the
hook-provided t. Also remove t from all caller invocations of
DynamicPricingBreakdown so callers stop passing t as a prop.
In `@web/src/pages/Setting/Ratio/components/requestRuleExpr.js`:
- Around line 417-443: In splitBillingExprAndRequestRules, avoid calling
tryParseRequestRuleExpr(part) twice for each part; store the result of
tryParseRequestRuleExpr(part) in a local variable (e.g., parsed or match) inside
the parts.forEach loop and use that cached value for the null-check and length
check, pushing to ruleParts when parsed !== null && parsed.length > 0 and to
baseParts otherwise; ensure you still call unwrapOuterParens on baseParts[0] and
join ruleParts with ' * ' as before.
In `@web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx`:
- Around line 914-932: In CacheTokenEstimatorInputs the varNames string is built
using a no-op f.var.replace('_', '_') which should be simplified or properly
escaped; update the varNames line so it uses EXTRA_ESTIMATOR_FIELDS.map((f) =>
f.var).join('|') or, if you want to be robust, escape regex meta-characters with
something like .map(f => f.var.replace(/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&')).join('|'); keep the rest of the RegExp test against effectiveExpr
unchanged.
- Around line 960-983: The evalExprLocally function currently uses new
Function(...) which will break under strict CSP; replace the runtime-eval
approach with a small, safe expression evaluator that parses the limited grammar
(variables p, c, tier(), numeric literals, functions min/max/abs/ceil/floor,
operators + - * / and the ternary ?:) rather than calling new Function;
implement a parser+interpreter (or embed a tiny expression library) that returns
the same shape { cost, matchedTier, error }, preserves the tier callback
behavior (track matchedTier when tier(name, value) is invoked), and keeps the
existing extraTokenValues lookup for EXTRA_ESTIMATOR_FIELDS and error handling
to surface parse/eval errors instead of relying on catching CSP-eval failures.
In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js`:
- Around line 1037-1063: The loop currently checks model.billingMode ===
'tiered_expr' twice; collapse into a single if/else: when model.billingMode ===
'tiered_expr' compute finalBillingExpr via combineBillingExpr(model.billingExpr,
model.requestRuleExpr'), set
tieredOutput['billing_setting.billing_mode'][model.name]='tiered_expr' and, if
finalBillingExpr, set
tieredOutput['billing_setting.billing_expr'][model.name]=finalBillingExpr, then
continue; otherwise (else) call serializeModel(model, t) and merge its non-null
entries into output[key][model.name] as before. Ensure you update the loop in
useModelPricingEditorState.js to use this single if/else flow.
🪄 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
Run ID: c3cabeb3-f8b5-4e7b-82a4-e171c0484259
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (61)
.cursor/rules/project.mdc.github/workflows/docker-image-nightly.yml.gitignoreAGENTS.mdCLAUDE.mdcontroller/channel-test.gocontroller/channel_test_internal_test.godto/gemini.godto/openai_response.gogo.modmodel/option.gomodel/pricing.gopkg/billingexpr/billingexpr_test.gopkg/billingexpr/compile.gopkg/billingexpr/expr.mdpkg/billingexpr/round.gopkg/billingexpr/run.gopkg/billingexpr/settle.gopkg/billingexpr/types.gorelay/audio_handler.gorelay/channel/gemini/relay-gemini.gorelay/chat_completions_via_responses.gorelay/common/billing.gorelay/common/relay_info.gorelay/embedding_handler.gorelay/helper/billing_expr_request.gorelay/helper/billing_expr_request_test.gorelay/helper/price.gorelay/helper/price_test.goservice/billing_session.goservice/log_info_generate.goservice/quota.goservice/text_quota.goservice/text_quota_test.goservice/tiered_settle.goservice/tiered_settle_test.goservice/tool_billing.gosetting/billing_setting/tiered_billing.gosetting/model_setting/claude_test.gosetting/operation_setting/tools.goweb/src/components/settings/RatioSetting.jsxweb/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsxweb/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsxweb/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsxweb/src/components/table/model-pricing/modal/components/ModelEndpoints.jsxweb/src/components/table/model-pricing/modal/components/ModelPricingTable.jsxweb/src/components/table/model-pricing/view/card/PricingCardView.jsxweb/src/components/table/usage-logs/UsageLogsColumnDefs.jsxweb/src/constants/billing.constants.jsweb/src/constants/index.jsweb/src/helpers/render.jsxweb/src/helpers/utils.jsxweb/src/hooks/usage-logs/useUsageLogsData.jsxweb/src/i18n/locales/en.jsonweb/src/i18n/locales/zh-CN.jsonweb/src/index.cssweb/src/pages/Setting/Ratio/ToolPriceSettings.jsxweb/src/pages/Setting/Ratio/components/ModelPricingEditor.jsxweb/src/pages/Setting/Ratio/components/TieredPricingEditor.jsxweb/src/pages/Setting/Ratio/components/requestRuleExpr.jsweb/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
💤 Files with no reviewable changes (1)
- .cursor/rules/project.mdc
✅ Files skipped from review due to trivial changes (14)
- dto/openai_response.go
- web/src/constants/index.js
- relay/embedding_handler.go
- .gitignore
- relay/chat_completions_via_responses.go
- AGENTS.md
- web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
- relay/helper/billing_expr_request.go
- setting/model_setting/claude_test.go
- web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
- web/src/index.css
- go.mod
- pkg/billingexpr/billingexpr_test.go
- pkg/billingexpr/compile.go
🚧 Files skipped from review as they are similar to previous changes (19)
- relay/common/billing.go
- relay/audio_handler.go
- model/option.go
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
- CLAUDE.md
- relay/common/relay_info.go
- pkg/billingexpr/round.go
- model/pricing.go
- relay/helper/price_test.go
- web/src/components/settings/RatioSetting.jsx
- web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
- relay/channel/gemini/relay-gemini.go
- service/quota.go
- relay/helper/price.go
- service/text_quota_test.go
- web/src/pages/Setting/Ratio/ToolPriceSettings.jsx
- pkg/billingexpr/run.go
- service/tiered_settle_test.go
- web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
| on: | ||
| push: | ||
| branches: | ||
| - nightly | ||
| workflow_dispatch: |
There was a problem hiding this comment.
Avoid publishing nightly from mutable per-arch tags during overlapping runs.
Two pushes to nightly can interleave: one run may create calciumion/new-api:nightly from nightly-amd64 and nightly-arm64 tags produced by different commits, or an older run can finish last and move nightly backward.
🐛 Proposed fix
on:
push:
branches:
- nightly
workflow_dispatch:
inputs:
name:
description: "reason"
required: false
+
+concurrency:
+ group: docker-nightly-${{ github.ref }}
+ cancel-in-progress: true
@@
- name: Create & push manifest (Docker Hub - nightly)
run: |
docker buildx imagetools create \
-t calciumion/new-api:nightly \
- calciumion/new-api:nightly-amd64 \
- calciumion/new-api:nightly-arm64
+ calciumion/new-api:${VERSION}-amd64 \
+ calciumion/new-api:${VERSION}-arm64Also applies to: 101-106
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/docker-image-nightly.yml around lines 3 - 7, The workflow
currently triggers on pushes to nightly and can publish
calciumion/new-api:nightly by combining mutable per-arch tags (e.g.,
nightly-amd64, nightly-arm64) from different runs, causing races; change the
publishing flow so per-arch images are pushed with immutable identifiers (e.g.,
include GITHUB_SHA or GITHUB_RUN_ID like nightly-<sha>-amd64,
nightly-<sha>-arm64) and only create/push the multi-arch
calciumion/new-api:nightly tag from a single orchestrating job that has built or
collected all arch images for the same commit (either by running all arch builds
in one workflow or by using a workflow_run/concurrency pattern to aggregate
artifacts and then create the manifest). Ensure references to nightly-amd64 and
nightly-arm64 are replaced with the immutable names when assembling the final
nightly manifest.
| - name: Determine nightly version | ||
| id: version | ||
| run: | | ||
| VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" | ||
| echo "$VERSION" > VERSION | ||
| echo "value=$VERSION" >> $GITHUB_OUTPUT | ||
| echo "VERSION=$VERSION" >> $GITHUB_ENV | ||
| echo "Publishing version: $VERSION for ${{ matrix.arch }}" |
There was a problem hiding this comment.
Compute the nightly version once per workflow run.
Each job recomputes nightly-$(date)-<sha> independently. If the arm job is queued across UTC midnight, or the manifest job starts after midnight, the versioned manifest can reference tags that were never pushed.
🐛 Proposed fix
jobs:
+ prepare_version:
+ name: Prepare nightly version
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ value: ${{ steps.version.outputs.value }}
+ steps:
+ - name: Check out (shallow)
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Determine nightly version
+ id: version
+ run: |
+ VERSION="nightly-$(date -u +'%Y%m%d')-$(git rev-parse --short HEAD)"
+ echo "value=$VERSION" >> "$GITHUB_OUTPUT"
+
build_single_arch:
name: Build & push (${{ matrix.arch }}) [native]
+ needs: [prepare_version]
@@
- name: Determine nightly version
id: version
run: |
- VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
+ VERSION="${{ needs.prepare_version.outputs.value }}"
echo "$VERSION" > VERSION
- echo "value=$VERSION" >> $GITHUB_OUTPUT
- echo "VERSION=$VERSION" >> $GITHUB_ENV
+ echo "value=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "Publishing version: $VERSION for ${{ matrix.arch }}"
@@
create_manifests:
name: Create multi-arch manifests (Docker Hub)
- needs: [build_single_arch]
+ needs: [prepare_version, build_single_arch]
runs-on: ubuntu-latest
+ env:
+ VERSION: ${{ needs.prepare_version.outputs.value }}
@@
- - name: Check out (shallow)
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Determine nightly version
- id: version
- run: |
- VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
- echo "value=$VERSION" >> $GITHUB_OUTPUT
- echo "VERSION=$VERSION" >> $GITHUB_ENV
-Also applies to: 88-93
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/docker-image-nightly.yml around lines 37 - 44, The nightly
version is being recomputed in every job (the step named "Determine nightly
version" with id version), causing inconsistent tags; instead create a single
dedicated job (e.g., job id determine_version or version) that runs once,
computes VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD')" and
emits it via the job output (echo "value=$VERSION" >> $GITHUB_OUTPUT), then make
all other jobs depend on that job and consume the version through
needs.version.outputs.value (or needs.determine_version.outputs.value) rather
than recalculating it in each job; update places that currently echo to
$GITHUB_ENV or recompute the date to use the shared job output so the same
version string is used across the entire workflow run.
| func GetBillingMode(model string) string { | ||
| if mode, ok := billingSetting.BillingMode[model]; ok { | ||
| return mode | ||
| } | ||
| return BillingModeRatio | ||
| } | ||
|
|
||
| func GetBillingExpr(model string) (string, bool) { | ||
| expr, ok := billingSetting.BillingExpr[model] | ||
| return expr, ok | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify how config.GlobalConfig.Register-backed structs are updated and whether map fields are mutated in place.
# Expected: settings updates either hold a read/write lock used by these accessors too, or publish immutable replacements.
rg -n -C4 'func .*Register|type .*GlobalConfig|billing_setting|BillingMode|BillingExpr|Unmarshal|Update.*Config|Save.*Config'Repository: QuantumNous/new-api
Length of output: 50376
🏁 Script executed:
# Find BillingSetting struct definition and billingSetting variable declaration
rg -n 'type BillingSetting|billingSetting\s*:=|var\s+billingSetting|BillingMode\s*map|BillingExpr\s*map|BillingMode\s*\*RWMap|BillingExpr\s*\*RWMap' --type goRepository: QuantumNous/new-api
Length of output: 414
🏁 Script executed:
# Read the full BillingSetting struct and initialization in tiered_billing.go
head -n 50 setting/billing_setting/tiered_billing.go | tail -n +15
# Search for how billingSetting is registered and updated
rg -n 'billingSetting|GlobalConfig.*Register|config\.Register' setting/billing_setting/tiered_billing.goRepository: QuantumNous/new-api
Length of output: 1501
🏁 Script executed:
# Find config.GlobalConfig.Register implementation
rg -n 'func.*Register|type GlobalConfig' config/ --type go | head -20
# Search for how settings are unmarshaled/updated when config changes
rg -n 'Unmarshal.*billingSetting|json\.Unmarshal.*setting' --type goRepository: QuantumNous/new-api
Length of output: 294
🏁 Script executed:
# Search for GlobalConfig definition more broadly
rg -n 'type GlobalConfig|var GlobalConfig' --type go
# Find config package location
fd -t f 'config.go|globalconfig' --type f | head -10Repository: QuantumNous/new-api
Length of output: 309
🏁 Script executed:
# Read the config manager implementation
head -n 150 setting/config/config.go | tail -n +1
# Look for Register method and how it handles updates
rg -n 'func.*Register|func.*Unmarshal|mutation|lock' setting/config/config.go | head -30Repository: QuantumNous/new-api
Length of output: 3576
🏁 Script executed:
# Find updateConfigFromMap implementation to confirm maps are mutated in-place
rg -n 'func updateConfigFromMap' setting/config/config.go -A 20Repository: QuantumNous/new-api
Length of output: 551
🏁 Script executed:
# Get more of the updateConfigFromMap implementation
rg -n 'func updateConfigFromMap' setting/config/config.go -A 50 | head -70Repository: QuantumNous/new-api
Length of output: 1320
🏁 Script executed:
# Get the complete updateConfigFromMap, focusing on map handling
rg -n 'func updateConfigFromMap' setting/config/config.go -A 100 | grep -A 50 'reflect.Map'Repository: QuantumNous/new-api
Length of output: 306
🏁 Script executed:
# Verify the complete flow: check if LoadFromDB or any update path holds locks that readers also use
rg -n 'GetBillingMode|GetBillingExpr' --type go -B 3 -A 3
# Confirm json.Unmarshal behavior on maps (in-place mutation)
rg -n 'json.Unmarshal' setting/config/config.goRepository: QuantumNous/new-api
Length of output: 3212
Verify these hot-path maps are not mutated in place.
These accessors read config-registered maps directly without synchronization. When config.GlobalConfig.LoadFromDB() updates billing settings, it mutates billingSetting.BillingMode and billingSetting.BillingExpr in place via json.Unmarshal(). Concurrent requests executing GetBillingMode() and GetBillingExpr() (called from relay/helper/price.go) perform unsynchronized reads, creating a data race that can trigger Go's concurrent map panic. Either add read locks coordinated with ConfigManager's update path, switch to atomic snapshots, or use a thread-safe map wrapper for these fields.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@setting/billing_setting/tiered_billing.go` around lines 35 - 45,
GetBillingMode and GetBillingExpr read the config maps
billingSetting.BillingMode and billingSetting.BillingExpr unsafely, risking
concurrent map panics when config.GlobalConfig.LoadFromDB() mutates them via
json.Unmarshal; fix by making accesses safe: either guard reads with the same
lock used when updating the maps (introduce/read from a RLock in
GetBillingMode/GetBillingExpr and ensure ConfigManager/LoadFromDB uses the
matching Lock/RUnlock when replacing or mutating billingSetting), or switch to
atomic snapshot semantics (replace the maps with a new map instance and publish
it via an atomic pointer) or use a thread-safe map wrapper for
billingSetting.BillingMode and billingSetting.BillingExpr so
GetBillingMode/GetBillingExpr can read without races while relay/helper/price.go
continues to call them.
| result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request) | ||
| if err != nil { | ||
| return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err) | ||
| } | ||
| if result < 0 { | ||
| return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result) | ||
| } |
There was a problem hiding this comment.
Reject non-finite expression results in the smoke test.
result < 0 lets NaN and ±Inf pass. Those values can later poison quota conversion or settlement math.
🧪 Proposed fix
import (
"fmt"
+ "math"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/setting/config"
)
@@
if err != nil {
return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err)
}
+ if math.IsNaN(result) || math.IsInf(result, 0) {
+ return fmt.Errorf("vector {p=%g, c=%g}: result %f is not finite", v.P, v.C, result)
+ }
if result < 0 {
return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request) | |
| if err != nil { | |
| return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err) | |
| } | |
| if result < 0 { | |
| return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result) | |
| } | |
| import ( | |
| "fmt" | |
| "math" | |
| "github.com/QuantumNous/new-api/pkg/billingexpr" | |
| "github.com/QuantumNous/new-api/setting/config" | |
| ) | |
| // ... (rest of function) | |
| result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request) | |
| if err != nil { | |
| return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err) | |
| } | |
| if math.IsNaN(result) || math.IsInf(result, 0) { | |
| return fmt.Errorf("vector {p=%g, c=%g}: result %f is not finite", v.P, v.C, result) | |
| } | |
| if result < 0 { | |
| return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@setting/billing_setting/tiered_billing.go` around lines 74 - 80, The smoke
test currently only checks "result < 0" which allows NaN and ±Inf to pass;
update the validation after billingexpr.RunExprWithRequest (the block where
result is returned) to explicitly reject non-finite values by checking
math.IsNaN(result) || math.IsInf(result, 0) and return an error referencing the
vector (v.P, v.C) and the non-finite result before the existing negative-value
check; keep the existing error messages/formatting style used for the
RunExprWithRequest error and the negative-result error.
| if (cond.source === SOURCE_TIME) { | ||
| const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc); | ||
| const tz = cond.timezone || 'UTC'; | ||
| if (cond.mode === MATCH_RANGE) { | ||
| return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`; | ||
| } | ||
| const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' }; | ||
| return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`; |
There was a problem hiding this comment.
Only append clock formatting to hour ranges.
MATCH_RANGE for weekday/month/day currently renders like 星期 1:00~5:00, which is misleading.
Proposed fix
const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc);
const tz = cond.timezone || 'UTC';
if (cond.mode === MATCH_RANGE) {
- return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`;
+ const suffix = cond.timeFunc === 'hour' ? ':00' : '';
+ return `${fn} ${cond.rangeStart}${suffix}~${cond.rangeEnd}${suffix} (${tz})`;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (cond.source === SOURCE_TIME) { | |
| const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc); | |
| const tz = cond.timezone || 'UTC'; | |
| if (cond.mode === MATCH_RANGE) { | |
| return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`; | |
| } | |
| const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' }; | |
| return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`; | |
| if (cond.source === SOURCE_TIME) { | |
| const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc); | |
| const tz = cond.timezone || 'UTC'; | |
| if (cond.mode === MATCH_RANGE) { | |
| const suffix = cond.timeFunc === 'hour' ? ':00' : ''; | |
| return `${fn} ${cond.rangeStart}${suffix}~${cond.rangeEnd}${suffix} (${tz})`; | |
| } | |
| const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' }; | |
| return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx`
around lines 69 - 76, The output for the SOURCE_TIME branch incorrectly appends
":00~:00" to non-hour time functions (so MATCH_RANGE renders "星期 1:00~5:00");
update the cond.source === SOURCE_TIME block to only append hour-specific clock
formatting when cond.timeFunc is the hour function (e.g., TIME_FUNC_HOUR or
whatever constant/value represents hours in your TIME_FUNC_* set), otherwise
render ranges/values without adding ":00" (use TIME_FUNC_LABELS[cond.timeFunc]
as before, conditionalize adding ":00" to cond.rangeStart/cond.rangeEnd and to
single-value display, leaving timezone handling unchanged).
| export function splitTopLevelMultiply(expr) { | ||
| const parts = []; | ||
| let start = 0; | ||
| let depth = 0; | ||
| for (let index = 0; index < expr.length; index += 1) { | ||
| const char = expr[index]; | ||
| if (char === '(') depth += 1; | ||
| if (char === ')') depth -= 1; | ||
| if (depth === 0 && expr.slice(index, index + 3) === ' * ') { | ||
| parts.push(expr.slice(start, index).trim()); | ||
| start = index + 3; | ||
| index += 2; | ||
| } | ||
| } | ||
| parts.push(expr.slice(start).trim()); | ||
| return parts.filter(Boolean); | ||
| } | ||
|
|
||
| function splitTopLevelAnd(expr) { | ||
| const parts = []; | ||
| let start = 0; | ||
| let depth = 0; | ||
| for (let i = 0; i < expr.length; i += 1) { | ||
| const c = expr[i]; | ||
| if (c === '(') depth += 1; | ||
| if (c === ')') depth -= 1; | ||
| if (depth === 0 && expr.slice(i, i + 4) === ' && ') { | ||
| parts.push(expr.slice(start, i).trim()); | ||
| start = i + 4; | ||
| i += 3; | ||
| } | ||
| } | ||
| parts.push(expr.slice(start).trim()); | ||
| return parts.filter(Boolean); | ||
| } |
There was a problem hiding this comment.
Top-level splitters don't account for string literals.
splitTopLevelMultiply and splitTopLevelAnd track only parenthesis depth, so a string literal containing * or && at depth 0 will be misinterpreted as an operator boundary. Example that currently mis-splits:
(header("x && y") == "foo" ? 2 : 1)
At depth 0 the && inside "x && y" is not reachable (it's inside the parenthesized condition), so in practice this mostly shows up if admins write expressions like param("a * b") == ... * 2. Consider tracking inside-string state (toggle on unescaped ") alongside depth before matching operators.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/Setting/Ratio/components/requestRuleExpr.js` around lines 153 -
187, The splitTopLevelMultiply and splitTopLevelAnd functions only track
parenthesis depth and therefore mis-split when operator substrings appear inside
string literals; update both functions to also track an inString boolean (toggle
when seeing an unescaped double-quote) and treat escaped quotes (backslash)
correctly so you only check for ' * ' and ' && ' when depth === 0 and inString
=== false; adjust index advancement logic accordingly and ensure you still
trim/push parts and return parts.filter(Boolean) as before.
| const handleBillingModeChange = (value) => { | ||
| if (!selectedModel) return; | ||
| upsertModel(selectedModel.name, (model) => { | ||
| const next = { ...model, billingMode: value }; | ||
| if (value === 'tiered_expr' && !model.billingExpr) { | ||
| next.billingExpr = 'tier("base", p * 0 + c * 0)'; | ||
| } | ||
| return next; | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Zero-cost default template may lead to unintentionally free billing.
'tier("base", p * 0 + c * 0)' is written into billingExpr the moment the user switches a model to tiered_expr. If the admin switches mode, doesn't expand the editor, and hits save, the model is silently configured to cost 0. Consider leaving billingExpr empty to force the user to fill in the editor (which already provides a default visual tier), or use a small non-zero placeholder so a forgotten save doesn't give free access.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js` around lines
875 - 884, The handler handleBillingModeChange currently writes a zero-cost
template into billingExpr when switching billingMode to 'tiered_expr', which can
unintentionally make models free; change the logic in handleBillingModeChange
(and the upsertModel callback) so that if value === 'tiered_expr' and
!model.billingExpr you either leave billingExpr undefined/empty (''), or set a
small non-zero placeholder (e.g. a minimal price expression) instead of
'tier("base", p * 0 + c * 0)'; keep selectedModel/upsertModel usage the same and
ensure the editor UI still provides its visual default when billingExpr is
empty.
- render.jsx: change const destructuring of completionRatio/audioRatio to use raw names with ?? 0 defaults, preventing "Assignment to constant variable" errors in renderModelPrice, renderAudioModelPrice, and renderClaudeModelPrice - TieredPricingEditor.jsx: add missing MATCH_GTE import, remove misleading alias help text, preserve conditions for single-tier configs
- quota.go: add missing SettleBilling call in PostWssConsumeQuota - text_quota.go: gate InjectTieredBillingInfo on tieredBillingApplied bool instead of tieredResult != nil, so fallback billing still logs metadata - price.go: remove quotaBeforeGroup == 0 from freeModel condition to avoid bypassing settlement for output-only expressions - tiered_settle.go: split cc/cc1h subtraction using UsageSemantic to distinguish OpenAI vs Claude cache creation token formats - pricing.go: only set BillingMode when a non-empty expression exists - useModelPricingEditorState.js: only write billing_mode when finalBillingExpr is non-empty
- log_info_generate.go: add nil guard in InjectTieredBillingInfo - billing_expr_request.go: merge headers instead of replacing - go.mod: remove incorrect // indirect on expr-lang/expr - ToolPriceSettings.jsx: add null check in syncToVisual - tool_billing.go: fix PricePer1K for image_generation (per-call, not per-1K) - utils.jsx: add minute() to time condition regex - useUsageLogsData.jsx: pass displayMode to renderTieredModelPrice - AGENTS.md, CLAUDE.md: fix Rule 6/7 ordering - relay-gemini.go: add TEXT modality case in CandidatesTokensDetails
feat: support for tiered billing expressions in the billing system
feat: support for tiered billing expressions in the billing system
Important
📝 变更描述 / Description
引入 billingexpr 表达式计费系统,用一条表达式字符串完整定义模型的计费逻辑(阶梯定价、缓存/图片/音频差异化、时间条件乘数、请求条件乘数),替代原有分散在多个 ratio JSON 字段中的隐式规则。
核心变更:
pkg/billingexpr/):基于 expr-lang/expr 实现编译→缓存→求值流程,支持tier()/when()/clamp()等内置函数,变量按需检测(AST 自省),价格直接使用供应商公示的 $/1M tokens,无需倍率换算。service/tiered_settle.go、service/text_quota.go):预扣/后扣流程中检测模型是否配置了表达式,若有则走 billingexpr 求值路径,否则回退原有倍率逻辑。service/tool_billing.go、setting/operation_setting/tools.go):独立计算 web_search / file_search / image_generation 的调用次数并按后台配置的单价计费,附加到 token 费用之上。web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx):可视化编辑阶梯/条件/时间规则,实时预览表达式和预估费用;新增 Tool Price Settings 页面。.github/workflows/docker-image-nightly.yml):nightly 分支推送时自动构建多架构镜像。🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
go build ./...编译通过,go test ./pkg/billingexpr/... ./service/...全部 PASS(含 1023 行表达式引擎测试、739 行阶梯结算测试)。Summary by CodeRabbit
New Features
Improvements
Tests
Documentation
Chores