Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
91ed4e1
feat: implement tiered billing expression evaluation and related func…
Calcium-Ion Mar 16, 2026
f0589cc
feat: enhance tiered billing functionality and UI components
Calcium-Ion Mar 16, 2026
f6c0852
refactor: update billing calculations to use quota per unit
Calcium-Ion Mar 16, 2026
5b03b39
feat: enhance tiered billing logic and improve variable handling in p…
Calcium-Ion Mar 16, 2026
c5405b2
feat: add billing expression system documentation and enhance tiered …
Calcium-Ion Mar 17, 2026
6e3ef48
feat: implement tool pricing settings UI and enhance tool call quota …
Calcium-Ion Mar 17, 2026
fbca256
feat: add nightly branch trigger to Docker image workflow
Calcium-Ion Mar 17, 2026
44fc10b
feat: update tiered pricing presets and expressions for improved clar…
Calcium-Ion Mar 17, 2026
d66311e
feat: add Doubao Seed 1.8 pricing tier for enhanced discount calculat…
Calcium-Ion Mar 17, 2026
d385d7a
feat: replace Card components with divs for improved layout consistency
Calcium-Ion Mar 17, 2026
35d0704
Merge branch 'origin/main' into nightly
Calcium-Ion Apr 1, 2026
0220df8
fix(channel-test): support tiered billing model tests (#4145)
yyhhyyyyyy Apr 9, 2026
4d2993e
Merge remote-tracking branch 'origin/main' into nightly
Calcium-Ion Apr 9, 2026
1fe9f6f
fix(billing): preserve text tool surcharges in tiered settlement
yyhhyyyyyy Apr 9, 2026
5c4ed5b
fix(billing): use tieredQuota fallback in composeTieredTextQuota erro…
Calcium-Ion Apr 23, 2026
55b7e48
Merge pull request #4162 from yyhhyyyyyy/fix/tiered-text-tool-surcharge
Calcium-Ion Apr 23, 2026
6bde1a9
Merge origin/main into nightly
Calcium-Ion Apr 23, 2026
8eeae00
fix: resolve runtime crashes in render.jsx and TieredPricingEditor.jsx
Calcium-Ion Apr 23, 2026
3e5f2ee
fix(billing): correct tiered billing settlement and edge cases
Calcium-Ion Apr 23, 2026
eab478b
fix: miscellaneous quick fixes from CodeRabbit review
Calcium-Ion Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 0 additions & 137 deletions .cursor/rules/project.mdc

This file was deleted.

113 changes: 113 additions & 0 deletions .github/workflows/docker-image-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Publish Docker image (nightly)

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

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.

inputs:
name:
description: "reason"
required: false

jobs:
build_single_arch:
name: Build & push (${{ matrix.arch }}) [native]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}

permissions:
contents: read

steps:
- 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 "$VERSION" > VERSION
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Publishing version: $VERSION for ${{ matrix.arch }}"
Comment on lines +37 to +44

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.


- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api

- name: Build & push single-arch
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: true
tags: |
calciumion/new-api:nightly-${{ matrix.arch }}
calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false

create_manifests:
name: Create multi-arch manifests (Docker Hub)
needs: [build_single_arch]
runs-on: ubuntu-latest

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

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- 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

- name: Create & push manifest (Docker Hub - versioned nightly)
run: |
docker buildx imagetools create \
-t calciumion/new-api:${VERSION} \
calciumion/new-api:${VERSION}-amd64 \
calciumion/new-api:${VERSION}-arm64
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ data/
.gomodcache/
.gocache-temp
.gopath

token_estimator_test.go
.test
token_estimator_test.go
skills-lock.json
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,7 @@ For request structs that are parsed from client JSON and then re-marshaled to up
- field absent in client JSON => `nil` => omitted on marshal;
- field explicitly set to zero/false => non-`nil` pointer => must still be sent upstream.
- Avoid using non-pointer scalars with `omitempty` for optional request parameters, because zero values (`0`, `0.0`, `false`) will be silently dropped during marshal.

### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`

When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,7 @@ For request structs that are parsed from client JSON and then re-marshaled to up
- field absent in client JSON => `nil` => omitted on marshal;
- field explicitly set to zero/false => non-`nil` pointer => must still be sent upstream.
- Avoid using non-pointer scalars with `omitempty` for optional request parameters, because zero values (`0`, `0.0`, `false`) will be silently dropped during marshal.

### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`

When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.
68 changes: 56 additions & 12 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
Expand Down Expand Up @@ -233,6 +234,15 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
info.IsChannelTest = true
info.InitChannelMeta(c)

err = attachTestBillingRequestInput(info, request)
if err != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
}
}

err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return testResult{
Expand Down Expand Up @@ -469,21 +479,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
}
info.SetEstimatePromptTokens(usage.PromptTokens)

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
}
} else {
quota = int(priceData.ModelPrice * common.QuotaPerUnit)
}
quota, tieredResult := settleTestQuota(info, priceData, usage)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
consumedTime := float64(milliseconds) / 1000.0
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
other := buildTestLogOther(c, info, priceData, usage, tieredResult)
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
ChannelId: channel.Id,
PromptTokens: usage.PromptTokens,
Expand All @@ -505,6 +505,50 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
}
}

func attachTestBillingRequestInput(info *relaycommon.RelayInfo, request dto.Request) error {
if info == nil {
return nil
}

input, err := helper.BuildBillingExprRequestInputFromRequest(request, info.RequestHeaders)
if err != nil {
return err
}
info.BillingRequestInput = &input
return nil
}

func settleTestQuota(info *relaycommon.RelayInfo, priceData types.PriceData, usage *dto.Usage) (int, *billingexpr.TieredResult) {
if usage != nil && info != nil && info.TieredBillingSnapshot != nil {
isClaudeUsageSemantic := usage.UsageSemantic == "anthropic" || info.GetFinalRequestRelayFormat() == types.RelayFormatClaude
usedVars := billingexpr.UsedVars(info.TieredBillingSnapshot.ExprString)
if ok, quota, result := service.TryTieredSettle(info, service.BuildTieredTokenParams(usage, isClaudeUsageSemantic, usedVars)); ok {
return quota, result
}
}

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

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.


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

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.

return other
}

func coerceTestUsage(usageAny any, isStream bool, estimatePromptTokens int) (*dto.Usage, error) {
switch u := usageAny.(type) {
case *dto.Usage:
Expand Down
Loading
Loading