Skip to content

feat: support for tiered billing expressions in the billing system - #4409

Merged
Calcium-Ion merged 20 commits into
mainfrom
nightly
Apr 23, 2026
Merged

feat: support for tiered billing expressions in the billing system#4409
Calcium-Ion merged 20 commits into
mainfrom
nightly

Conversation

@Calcium-Ion

@Calcium-Ion Calcium-Ion commented Apr 23, 2026

Copy link
Copy Markdown
Member

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

引入 billingexpr 表达式计费系统,用一条表达式字符串完整定义模型的计费逻辑(阶梯定价、缓存/图片/音频差异化、时间条件乘数、请求条件乘数),替代原有分散在多个 ratio JSON 字段中的隐式规则。

核心变更:

  1. billingexpr 引擎pkg/billingexpr/):基于 expr-lang/expr 实现编译→缓存→求值流程,支持 tier()/when()/clamp() 等内置函数,变量按需检测(AST 自省),价格直接使用供应商公示的 $/1M tokens,无需倍率换算。
  2. 阶梯结算集成service/tiered_settle.goservice/text_quota.go):预扣/后扣流程中检测模型是否配置了表达式,若有则走 billingexpr 求值路径,否则回退原有倍率逻辑。
  3. 工具调用计费service/tool_billing.gosetting/operation_setting/tools.go):独立计算 web_search / file_search / image_generation 的调用次数并按后台配置的单价计费,附加到 token 费用之上。
  4. 前端编辑器web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx):可视化编辑阶梯/条件/时间规则,实时预览表达式和预估费用;新增 Tool Price Settings 页面。
  5. Nightly Docker CI.github/workflows/docker-image-nightly.yml):nightly 分支推送时自动构建多架构镜像。

🚀 变更类型 / Type of change

  • ✨ 新功能 (New feature)

🔗 关联任务 / Related Issue

  • N/A

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 Issues 与 PRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

go build ./... 编译通过,go test ./pkg/billingexpr/... ./service/... 全部 PASS(含 1023 行表达式引擎测试、739 行阶梯结算测试)。

Summary by CodeRabbit

  • New Features

    • Tiered/dynamic billing expressions with editor/preview UI, tool-price configuration UI, nightly multi-arch Docker image publishing, and richer token breakdowns (image/audio/candidates/cache).
  • Improvements

    • Tiered billing integrated end-to-end: pre/post-consume quota, pricing estimator, usage displays, reservation flow, and log injection of matched tiers.
  • Tests

    • Extensive unit/integration tests and benchmarks for expression compilation, settlement, token handling and service integration.
  • Documentation

    • Added comprehensive billing-expression spec and editor guidance; removed prior documented project convention rules.
  • Chores

    • Minor ignore-list updates.

Calcium-Ion and others added 14 commits March 17, 2026 16:59
…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.
…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.
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
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Removes 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

Cohort / File(s) Summary
Billing Expression Core
pkg/billingexpr/types.go, pkg/billingexpr/compile.go, pkg/billingexpr/run.go, pkg/billingexpr/settle.go, pkg/billingexpr/round.go
Adds expression versioning/hash, compile+cache, runtime env (token/request/header/time helpers), tier() tracing, cost→quota conversion, rounding utility, and cache invalidation APIs.
Billing Expr Spec & Tests
pkg/billingexpr/expr.md, pkg/billingexpr/billingexpr_test.go
Adds expression language spec and a large E2E/benchmark test suite covering parsing, execution, tier selection, rounding, request-driven predicates, and fuzz/benchmarks.
Service Integration & Quota Paths
service/tiered_settle.go, service/quota.go, service/text_quota.go, service/billing_session.go, service/log_info_generate.go, service/tiered_settle_test.go, service/text_quota_test.go
Implements TryTieredSettle, token-param normalization, quota override/composition (including tool-call surcharge), BillingSession.Reserve, logging injection of tier info, and tests/benchmarks.
Relay & Request Handling
relay/helper/billing_expr_request.go, relay/helper/billing_expr_request_test.go, relay/helper/price.go, relay/helper/price_test.go, relay/common/relay_info.go, relay/common/billing.go
Adds RequestInput builders, stores BillingSnapshot/RequestInput on RelayInfo, routes tiered pre-consume pricing, adds BillingSettler.Reserve, and tests for preloaded request usage.
Controller & Tests
controller/channel-test.go, controller/channel_test_internal_test.go
Attach BillingRequestInput in channel tests and refactor test quota settlement to prefer tiered billing with unit tests.
Model/Config & Tool Pricing
setting/billing_setting/tiered_billing.go, setting/operation_setting/tools.go, model/pricing.go, model/option.go
Adds billing_setting (billing_mode/billing_expr) with smoke tests, configurable tool-price index and lookup APIs, and pricing struct fields/hooks to expose billing_mode/billing_expr.
Tool Billing Utility
service/tool_billing.go
New utility to compute aggregated quota for tool calls (web/file/image) using model/tool prices with groupRatio scaling and rounding.
Relay/DTO Small Fixes
dto/gemini.go, dto/openai_response.go, relay/channel/gemini/relay-gemini.go, relay/audio_handler.go, relay/chat_completions_via_responses.go, relay/embedding_handler.go
Adds candidate-level token details and image token field; maps modalities into usage; standardizes error wrapping/status and uses io.Reader for adaptor requests.
Frontend: Editor & State
web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx, web/src/pages/Setting/Ratio/components/requestRuleExpr.js, web/src/pages/Setting/Ratio/ToolPriceSettings.jsx, web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx, web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
Adds visual/raw tiered pricing editor, request-rule parser/serializer, presets, token estimator, tool-price editor, and state handlers for billingExpr/requestRuleExpr (includes new public TieredPricingEditor component).
Frontend: Display & Render
web/src/helpers/render.jsx, web/src/helpers/utils.jsx, web/src/components/.../DynamicPricingBreakdown.jsx, web/src/components/.../ModelDetailSideSheet.jsx, web/src/components/.../ModelPricingTable.jsx, web/src/components/.../PricingCardView.jsx, web/src/hooks/usage-logs/useUsageLogsData.jsx, web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
Refactors render helpers to opts objects, adds tiered-expression parsers/renderers, integrates dynamic pricing UI and usage-log tiered rendering, and adjusts various components/layouts.
Frontend: Constants, I18n, Styling
web/src/constants/billing.constants.js, web/src/constants/index.js, web/src/i18n/locales/en.json, web/src/i18n/locales/zh-CN.json, web/src/index.css
Adds billing variable registry/constants, barrel re-export, extensive i18n additions (EN/ZH) for tiered UI, and mobile CSS spacing tweaks.
Frontend: Small UI Edits
web/src/components/settings/RatioSetting.jsx, web/src/components/table/model-pricing/modal/components/*, web/src/pages/Setting/Ratio/ToolPriceSettings.jsx
Adds tool-price tab, layout adjustments, DynamicPricingBreakdown component and wiring.
CI, Docs & Misc
.github/workflows/docker-image-nightly.yml, .cursor/rules/project.mdc, AGENTS.md, CLAUDE.md, .gitignore, go.mod
Adds nightly multi-arch Docker workflow, deletes .cursor project rules doc, documents billingexpr requirement, updates .gitignore, and adds indirect expr module to go.mod.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

ready to merge

Suggested reviewers

  • seefs001
  • creamlike1024

Poem

🐰
I nibbled tiers in twilight code,
stitched tiny exprs where tokens flowed,
snapshots kept and tiers aligned,
quotas rounded, logs refined,
Hoppy billing, carrots owed. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: support for tiered billing expressions in the billing system' directly describes the PR's main objective: introducing tiered billing expression support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nightly

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Add SettleBilling call to finalize tiered quota settlement.

PostWssConsumeQuota applies tiered quota but lacks the SettleBilling call present in PostAudioConsumeQuota (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 in PostAudioConsumeQuota.

🤖 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 | 🔴 Critical

Do not assign to destructured const bindings.

These helpers destructure completionRatio, audioRatio, and audioCompletionRatio with const, 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 audioRatio and audioCompletionRatio before any operations like parseFloat().

🤖 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 | 🟡 Minor

Mirror 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 | 🟠 Major

Remove token_estimator_test.go from .gitignore or clarify why it must be excluded.

Other Go test files in this repository are committed:

  • controller/token_test.go
  • pkg/billingexpr/billingexpr_test.go
  • relay/helper/billing_expr_request_test.go
  • service/task_billing_test.go

Adding token_estimator_test.go to .gitignore is 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 | 🟠 Major

Map candidate TEXT tokens into completion details too.

This now records image/audio candidate tokens, but leaves CompletionTokenDetails.TextTokens at zero when Gemini returns TEXT in candidatesTokensDetails. 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 | 🟠 Major

Prevent overlapping runs from publishing a stale or mixed nightly manifest.

The manifest is composed from mutable nightly-amd64 / nightly-arm64 tags. 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}-arm64

Also 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 | 🟠 Major

Compute VERSION once per workflow run.

The workflow computes version in build_single_arch (lines 37-43) and again in create_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 | 🟠 Major

Add validation to reject invalid quota values before int conversion.

int(math.Round(f)) silently converts NaN, Inf, and out-of-range values to min int64 (–9223372036854775808), corrupting billing settlement. The smoke test only checks result < 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 | 🟠 Major

Guard the exported helper against nil inputs.

Line 272 dereferences relayInfo, and Line 276 writes into other; 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 | 🟠 Major

Include 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 though RealtimeUsage contains those details. Mirror the PostAudioConsumeQuota variable-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 | 🟠 Major

Apply 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 | 🟠 Major

Tiered override silently drops auxiliary tool quotas from billed amount.

summary.Quota returned by calculateTextQuotaSummary includes quotas for web_search, claude_web_search, file_search, audio_input, and image_generation_call (lines 245-249 / 263-267). When tiered settlement succeeds, summary.Quota = tieredQuota replaces the entire total with only the expression-derived quota, discarding these auxiliary costs.

Note: web_search and file_search have no corresponding variables in tiered expressions (only p, c, cr, cc, cc1h, img, imgO, ai, ao are supported). They cannot be recovered via the expression. Meanwhile, code at lines 320-334 still appends "Web Search 调用 ..." and "File Search 调用 ..." to extraContent, and lines 380-402 write web_search_price and file_search_price to other. The user is told they were charged for these tools, but the quota deducted does not include them.

For audio_input and image_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 from summary.Quota at line 316, creating the same mismatch.

Either guard against using tiered_expr with auxiliary tools that lack expression variables, or ensure all auxiliary costs computed in calculateTextQuotaSummary are preserved and added back to tieredQuota before overriding summary.Quota.

service/billing_session.go-165-170 (1)

165-170: ⚠️ Potential issue | 🟠 Major

Track subscription extra reserve before the token-reserve rollback path.

If reserveFunding(delta) succeeds for a subscription, then reserveToken(delta) fails and rollbackFundingReserve(delta) also fails, the extra subscription usage is left in the DB but extraReserved is still 0. A later Refund cannot 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 | 🟠 Major

Merge missing headers instead of only handling an empty header map.

A preloaded BillingRequestInput with partial headers skips info.RequestHeaders entirely, 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 | 🟠 Major

Do not mark tiered billing as free just because the estimate is zero.

For expressions that charge output tokens, estimatedCompletionTokens can be 0 when max_tokens is absent, but the actual completion may still be billable. Marking FreeModel here 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 | 🟠 Major

Split cache-creation token normalization by cc vs cc1h.

Line 36 subtracts all cache-creation tokens when either variable is used, but line 64 only charges ccTotal - cc1h through CC. Expressions that reference only one cache-creation variable can underbill the other, and CC can 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 | 🟠 Major

Remove 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 | 🟠 Major

Synchronize map access in billing setting hot-path functions.

GetBillingMode() and GetBillingExpr() read unsynchronized maps that LoadFromDB() 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 | 🟠 Major

Do 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 | 🟠 Major

Parse 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) or tier("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 | 🟠 Major

Do not show single-tier conditions unless they are serialized.

For tiers.length === 1, generateExprFromVisualConfig() always returns tier(...) and ignores conditions, 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 | 🟠 Major

Remove 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 like p, c, cr, cc, and cc1h. 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 | 🟠 Major

Reject empty tiered billing expressions before saving.

combineBillingExpr() returns '' when billingExpr is empty, but this still saves billing_mode: "tiered_expr" without a corresponding executable billing_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 | 🟠 Major

Add missing MATCH_GTE import.

Line 1024 uses MATCH_GTE, but this constant is not imported from requestRuleExpr.js. Switching an existing condition to "时间条件" will throw a ReferenceError.

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 | 🟡 Minor

Remove // indirect marker—expr is directly imported.

The project directly imports github.com/expr-lang/expr in pkg/billingexpr/run.go and pkg/billingexpr/compile.go, so this dependency must be marked as direct. Remove the // indirect annotation; otherwise go mod tidy will 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

syncToVisual crashes when the user enters null.

typeof null === 'object' and Array.isArray(null) === false, so a textarea value of null passes the validation, then objectToRows(null) throws TypeError: Cannot convert undefined or null to object at Object.entries(null). A raw string of "null" parsed via JSON.parse produces 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

PricePer1K for image_generation is a fabricated value.

GPT-Image-1 is billed per single call (quality × size), not per 1K calls. Reporting PricePer1K: price * 1000 alongside CallCount: 1 will display e.g. "$167 per 1K" in logs/UI for a single high-quality 1024×1024 image (actual price ≈ $0.167). Consider setting PricePer1K to 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 | 🟡 Minor

Default tool prices are sticky — admins cannot remove a built-in entry via the UI.

RebuildToolPriceIndex always layers defaultToolPrices and defaultToolPriceOverrides underneath toolPriceSetting.Prices. If an admin deletes e.g. web_search in ToolPriceSettings.jsx and saves, the payload no longer contains the key, but after reload the index restores the default 10.0. The only way to suppress a built-in tool is to set its price to 0 (which then skips billing via the pricePer1K <= 0 guard in service/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 | 🟡 Minor

Assert the encoded expression payload, not just presence.

require.NotEmpty would still pass if expr_b64 contains 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 | 🟡 Minor

Keep 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 | 🟡 Minor

Pass 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 | 🟡 Minor

Only expose tiered_expr when an expression is present.

This currently sets billing_mode: "tiered_expr" even when GetBillingExpr misses 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 | 🟡 Minor

Add 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) documents minute(tz) alongside hour, weekday, month, day. This regex omits it, so an expression using minute(...) 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

📥 Commits

Reviewing files that changed from the base of the PR and between 11f8d42 and 4d2993e.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (60)
  • .cursor/rules/project.mdc
  • .github/workflows/docker-image-nightly.yml
  • .gitignore
  • AGENTS.md
  • CLAUDE.md
  • controller/channel-test.go
  • controller/channel_test_internal_test.go
  • dto/gemini.go
  • dto/openai_response.go
  • go.mod
  • model/option.go
  • model/pricing.go
  • pkg/billingexpr/billingexpr_test.go
  • pkg/billingexpr/compile.go
  • pkg/billingexpr/expr.md
  • pkg/billingexpr/round.go
  • pkg/billingexpr/run.go
  • pkg/billingexpr/settle.go
  • pkg/billingexpr/types.go
  • relay/audio_handler.go
  • relay/channel/gemini/relay-gemini.go
  • relay/chat_completions_via_responses.go
  • relay/common/billing.go
  • relay/common/relay_info.go
  • relay/embedding_handler.go
  • relay/helper/billing_expr_request.go
  • relay/helper/billing_expr_request_test.go
  • relay/helper/price.go
  • relay/helper/price_test.go
  • service/billing_session.go
  • service/log_info_generate.go
  • service/quota.go
  • service/text_quota.go
  • service/tiered_settle.go
  • service/tiered_settle_test.go
  • service/tool_billing.go
  • setting/billing_setting/tiered_billing.go
  • setting/model_setting/claude_test.go
  • setting/operation_setting/tools.go
  • web/src/components/settings/RatioSetting.jsx
  • web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  • web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx
  • web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
  • web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
  • web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  • web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  • web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  • web/src/constants/billing.constants.js
  • web/src/constants/index.js
  • web/src/helpers/render.jsx
  • web/src/helpers/utils.jsx
  • web/src/hooks/usage-logs/useUsageLogsData.jsx
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/zh-CN.json
  • web/src/index.css
  • web/src/pages/Setting/Ratio/ToolPriceSettings.jsx
  • web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/requestRuleExpr.js
  • web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
💤 Files with no reviewable changes (1)
  • .cursor/rules/project.mdc

Comment on lines +2905 to +2906
"缓存读取": "缓存读取",
"缓存创建": "缓存创建",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4d2993e and 55b7e48.

📒 Files selected for processing (2)
  • service/text_quota.go
  • service/text_quota_test.go

Comment thread service/text_quota.go
Comment thread service/text_quota.go
@Calcium-Ion Calcium-Ion changed the title Nightly feat: support for tiered billing expressions in the billing system. Apr 23, 2026
@Calcium-Ion Calcium-Ion changed the title feat: support for tiered billing expressions in the billing system. feat: support for tiered billing expressions in the billing system Apr 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Log content still reports legacy ratios when tiered settlement is applied.

When tieredOk is true, quota is overridden but logContent is still built from modelRatio/completionRatio/audioRatio/modelPrice, which for tiered models come from PriceData with zero values (see relay/helper/price.go modelPriceHelperTiered). The recorded log will read 模型倍率 0.00,补全倍率 0.00…, which is misleading for anyone inspecting consumption logs. The same applies to PostAudioConsumeQuota lines 326–331.

Consider emitting a tiered-specific logContent when tieredOk (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 | 🟠 Major

WSS tiered settlement only populates P/C totals; audio/text variables in the expression will be unresolved.

PostAudioConsumeQuota calls BuildTieredTokenParams(usage, ...) which extracts audio/text/image token subcategories and populates all TokenParams fields (AI, AO, Img, etc.). PostWssConsumeQuota directly constructs TokenParams{P: float64(usage.InputTokens), C: float64(usage.OutputTokens)}, passing only totals. Any tiered expression configured for realtime models that references variables like audio_input_tokens, text_input_tokens, etc., will find them unmapped (zero) in TokenParams, producing incorrect tiered quotas.

Add a BuildTieredTokenParams variant for *dto.RealtimeUsage (extracting InputTokenDetails.AudioTokens, OutputTokenDetails.AudioTokens, etc., using usedVars from relayInfo.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 | 🔴 Critical

Remove the duplicate translation keys in this added block.

This block re-declares keys that already exist in the same translation object; 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}")
PY

As per coding guidelines, translation files in web/src/i18n/locales/{lang}.json use 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

📥 Commits

Reviewing files that changed from the base of the PR and between 55b7e48 and 63e661a.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (13)
  • .gitignore
  • controller/channel-test.go
  • dto/gemini.go
  • dto/openai_response.go
  • go.mod
  • model/option.go
  • relay/common/relay_info.go
  • relay/helper/price.go
  • service/quota.go
  • web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  • web/src/hooks/usage-logs/useUsageLogsData.jsx
  • web/src/i18n/locales/en.json
  • web/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

Comment on lines +530 to +541
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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), nil

Consider 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.

Comment thread relay/helper/price.go
Comment on lines +242 to +308
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -30

Repository: 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 -50

Repository: 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.go

Repository: QuantumNous/new-api

Length of output: 308


Tiered PriceData leaves ratio fields zero, affecting downstream logging and free-model classification logic.

Two substantive issues:

  1. Unpopulated PriceData fields: The returned PriceData only sets FreeModel, GroupRatioInfo, and QuotaToPreConsume. However, downstream post-consume paths in service/quota.go (lines 182–214, 299–331) read PriceData.ModelRatio, PriceData.ModelPrice, and PriceData.CacheRatio for logging and other calculations. For tiered models these remain zero-initialized, producing misleading log messages like "模型倍率 0.00,补全倍率 0.00…". Either populate representative values in PriceData (e.g., UsePrice: true to indicate tiered pricing; derive a synthetic ratio) or make the logging/quota calculation paths tiered-aware.

  2. Transient zero-cost marks model as free: On lines 270–276, freeModel is set to true whenever quotaBeforeGroup == 0. In the legacy branches (non-tiered paths), freeModel is set only when the model's static configuration indicates zero cost (modelRatio == 0 or modelPrice == 0). Here it fires dynamically whenever the expression evaluates to 0 on pre-consume inputs (e.g., empty prompt with no MaxTokens). A transient zero cost shouldn't semantically mark the model as free. Either drop freeModel for 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.

Comment thread web/src/i18n/locales/en.json Outdated
Comment on lines +3677 to +3919
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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)
PY

Repository: 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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (5)
web/src/i18n/locales/en.json (1)

3677-3919: ⚠️ Potential issue | 🔴 Critical

Remove 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 | 🟡 Minor

Apply 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 | 🟠 Major

Duplicate: inject tiered metadata when settlement falls back.

TryTieredSettle can succeed with a nil result, but the log metadata injection is gated on tieredResult != nil. In that fallback case, charged tiered requests lose billing_mode and expr_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 | 🟠 Major

Duplicate: use billingexpr.QuotaRound in tiered quota composition.

composeTieredTextQuota still uses decimal.Round(0), which can drift from the billing expression package’s quota rounding contract. Use billingexpr.QuotaRound for 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 | 🔴 Critical

Duplicate: 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:lint

As 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:

  1. The if (model.billingMode === 'tiered_expr') check is executed back-to-back on lines 1043 and 1053 — the second one is just a continue. Merge them into a single branch.
  2. When a tiered model has no billingExpr (e.g., user switched to tiered_expr but cleared/never entered an expression), the code still writes billing_setting.billing_mode[name] = 'tiered_expr' but skips billing_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 from tieredOutput when finalBillingExpr is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 63e661a and a517342.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (61)
  • .cursor/rules/project.mdc
  • .github/workflows/docker-image-nightly.yml
  • .gitignore
  • AGENTS.md
  • CLAUDE.md
  • controller/channel-test.go
  • controller/channel_test_internal_test.go
  • dto/gemini.go
  • dto/openai_response.go
  • go.mod
  • model/option.go
  • model/pricing.go
  • pkg/billingexpr/billingexpr_test.go
  • pkg/billingexpr/compile.go
  • pkg/billingexpr/expr.md
  • pkg/billingexpr/round.go
  • pkg/billingexpr/run.go
  • pkg/billingexpr/settle.go
  • pkg/billingexpr/types.go
  • relay/audio_handler.go
  • relay/channel/gemini/relay-gemini.go
  • relay/chat_completions_via_responses.go
  • relay/common/billing.go
  • relay/common/relay_info.go
  • relay/embedding_handler.go
  • relay/helper/billing_expr_request.go
  • relay/helper/billing_expr_request_test.go
  • relay/helper/price.go
  • relay/helper/price_test.go
  • service/billing_session.go
  • service/log_info_generate.go
  • service/quota.go
  • service/text_quota.go
  • service/text_quota_test.go
  • service/tiered_settle.go
  • service/tiered_settle_test.go
  • service/tool_billing.go
  • setting/billing_setting/tiered_billing.go
  • setting/model_setting/claude_test.go
  • setting/operation_setting/tools.go
  • web/src/components/settings/RatioSetting.jsx
  • web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  • web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx
  • web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
  • web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
  • web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  • web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  • web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  • web/src/constants/billing.constants.js
  • web/src/constants/index.js
  • web/src/helpers/render.jsx
  • web/src/helpers/utils.jsx
  • web/src/hooks/usage-logs/useUsageLogsData.jsx
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/zh-CN.json
  • web/src/index.css
  • web/src/pages/Setting/Ratio/ToolPriceSettings.jsx
  • web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/requestRuleExpr.js
  • web/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

Comment on lines +543 to +548
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment thread pkg/billingexpr/expr.md
Comment on lines +92 to +106
```
# 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)
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 -->

Comment thread service/tiered_settle.go
Comment on lines +60 to +71
return billingexpr.TokenParams{
P: p,
C: c,
CR: cr,
CC: ccTotal - cc1h,
CC1h: cc1h,
Img: img,
ImgO: imgO,
AI: ai,
AO: ao,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread service/tool_billing.go
Comment on lines +51 to +81
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +132 to +138
...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> : '-',
})),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread web/src/helpers/utils.jsx
Comment on lines +903 to +915
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested 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;
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`.

Comment thread web/src/hooks/usage-logs/useUsageLogsData.jsx
Comment thread web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
Comment on lines +50 to +57
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +76 to +93
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Lock GetPreConsumedQuota now that reservations mutate it.

Reserve updates s.preConsumedQuota under s.mu, but GetPreConsumedQuota reads 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 | 🟡 Minor

Show file-search calls in ratio-mode log summaries too.

file_search is included in price-mode summaries, but when price display is disabled this branch only reports webSearch; 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 | 🟠 Major

Round 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 | 🟡 Minor

Keep label out 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 pass i18next.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 | 🟠 Major

Parse tier(...) bodies with balanced parentheses.

This still uses ([^)]+), so expressions such as tier("x", (p)*2 + c*8) or tier("x", max(p, c)) are truncated before parseTierBody, producing empty/incorrect tier prices. Use a small balanced-parenthesis scanner after matching tier("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 | 🔴 Critical

Remove the duplicate 变量 translation key.

Line 3731 redeclares "变量"; the same key already exists at Line 909 in this translation object. 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 | 🟡 Minor

Pass billingDisplayMode to 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 | 🟡 Minor

Make tier-mode detection tolerate formatted expressions.

includes('tier(') mislabels valid formatted expressions such as tier ("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 | 🟡 Minor

Use 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 | 🟠 Major

Use billingexpr.QuotaRound for 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 | 🟠 Major

Inject tiered log metadata whenever tiered settlement applies.

TryTieredSettle can return tieredOk == true with tieredRes == nil; gating on tieredResult drops 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 | 🟡 Minor

Apply 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 not 1.

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 | 🟡 Minor

Preserve tiered log metadata when tiered settlement falls back.

TryTieredSettle can apply tiered billing while returning a nil result; this guard then drops billing_mode and expr_b64 from 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: Use useTranslation() inside this React component.

This component currently receives t as 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}: Use useTranslation() hook and call t('中文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 consecutive tiered_expr checks.

In the loop the same billingMode === 'tiered_expr' condition is evaluated twice (once to populate tieredOutput, then again to continue). 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 current f.var values — 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 to new 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

📥 Commits

Reviewing files that changed from the base of the PR and between a517342 and 6bde1a9.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (61)
  • .cursor/rules/project.mdc
  • .github/workflows/docker-image-nightly.yml
  • .gitignore
  • AGENTS.md
  • CLAUDE.md
  • controller/channel-test.go
  • controller/channel_test_internal_test.go
  • dto/gemini.go
  • dto/openai_response.go
  • go.mod
  • model/option.go
  • model/pricing.go
  • pkg/billingexpr/billingexpr_test.go
  • pkg/billingexpr/compile.go
  • pkg/billingexpr/expr.md
  • pkg/billingexpr/round.go
  • pkg/billingexpr/run.go
  • pkg/billingexpr/settle.go
  • pkg/billingexpr/types.go
  • relay/audio_handler.go
  • relay/channel/gemini/relay-gemini.go
  • relay/chat_completions_via_responses.go
  • relay/common/billing.go
  • relay/common/relay_info.go
  • relay/embedding_handler.go
  • relay/helper/billing_expr_request.go
  • relay/helper/billing_expr_request_test.go
  • relay/helper/price.go
  • relay/helper/price_test.go
  • service/billing_session.go
  • service/log_info_generate.go
  • service/quota.go
  • service/text_quota.go
  • service/text_quota_test.go
  • service/tiered_settle.go
  • service/tiered_settle_test.go
  • service/tool_billing.go
  • setting/billing_setting/tiered_billing.go
  • setting/model_setting/claude_test.go
  • setting/operation_setting/tools.go
  • web/src/components/settings/RatioSetting.jsx
  • web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  • web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx
  • web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
  • web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
  • web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  • web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  • web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  • web/src/constants/billing.constants.js
  • web/src/constants/index.js
  • web/src/helpers/render.jsx
  • web/src/helpers/utils.jsx
  • web/src/hooks/usage-logs/useUsageLogsData.jsx
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/zh-CN.json
  • web/src/index.css
  • web/src/pages/Setting/Ratio/ToolPriceSettings.jsx
  • web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
  • web/src/pages/Setting/Ratio/components/requestRuleExpr.js
  • web/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

Comment on lines +3 to +7
on:
push:
branches:
- nightly
workflow_dispatch:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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}-arm64

Also 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.

Comment on lines +37 to +44
- 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 }}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment thread service/log_info_generate.go
Comment on lines +35 to +45
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 go

Repository: 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.go

Repository: 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 go

Repository: 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 -10

Repository: 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 -30

Repository: 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 20

Repository: 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 -70

Repository: 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.go

Repository: 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.

Comment on lines +74 to +80
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +69 to +76
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})`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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).

Comment thread web/src/helpers/render.jsx
Comment on lines +153 to +187
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines 875 to +884
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;
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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
@Calcium-Ion
Calcium-Ion merged commit 65b1654 into main Apr 23, 2026
2 of 3 checks passed
Jinxuans referenced this pull request in TokFlux-Org/TokFlux May 9, 2026
feat: support for tiered billing expressions in the billing system
@coderabbitai coderabbitai Bot mentioned this pull request May 11, 2026
11 tasks
xyfacai pushed a commit to xyfacai/new-api that referenced this pull request May 30, 2026
feat: support for tiered billing expressions in the billing system
@Calcium-Ion
Calcium-Ion deleted the nightly branch June 13, 2026 08:34
@coderabbitai coderabbitai Bot mentioned this pull request Jul 7, 2026
11 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Jul 17, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants