Skip to content

feat: support Claude messages token counting#5596

Open
lyzgeorge wants to merge 2 commits into
QuantumNous:mainfrom
lyzgeorge:codex/claude-count-tokens
Open

feat: support Claude messages token counting#5596
lyzgeorge wants to merge 2 commits into
QuantumNous:mainfrom
lyzgeorge:codex/claude-count-tokens

Conversation

@lyzgeorge

@lyzgeorge lyzgeorge commented Jun 18, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

  • 本描述已按模板整理;本 PR 的代码由 AI-assisted 方式完成,并由当前工作区检查后提交。

📝 变更描述 / Description

支持 Anthropic Claude 官方 POST /v1/messages/count_tokens 请求。新增路由会进入 Claude relay,按原始请求路径识别 count_tokens,转发到上游 /v1/messages/count_tokens,成功响应直接透传 input_tokens 等官方返回字段,不再走普通 Messages 响应解析和文本生成扣费结算。

同时补充 relay mode、请求路径识别和 Claude adaptor URL 的单元测试,确保普通 /v1/messages 路径保持不变。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

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

📸 运行证明 / Proof of Work

  • git diff --cached --check passed before commit.
  • git diff --check passed after edits.
  • Local go test / gofmt could not be run because this environment does not have go or gofmt on PATH.
  • Implementation follows Anthropic docs for POST /v1/messages/count_tokens, which returns token count data such as input_tokens.

Summary by CodeRabbit

  • New Features
    • Claude count_tokens API endpoint is now supported, allowing token counting without incurring service charges
    • Subscription pricing system updated to calculate costs based on user groups, enabling dynamic pricing based on group membership

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds support for the Claude /v1/messages/count_tokens endpoint by introducing a new RelayModeClaudeCountTokens constant, detection helpers, a new HTTP route, adaptor URL switching, and handler/billing bypass that skips quota pre-consumption and forwards the upstream response directly. Separately, subscription payment handlers for epay and waffo-pancake now compute amounts using group-based pricing instead of the plan's fixed PriceAmount.

Changes

Claude count_tokens endpoint support

Layer / File(s) Summary
RelayModeClaudeCountTokens constant and Path2RelayMode mapping
relay/constant/relay_mode.go, relay/constant/relay_mode_test.go
Adds the RelayModeClaudeCountTokens iota constant and extends Path2RelayMode to map /v1/messages/count_tokens to it, with a test asserting the mapping.
IsClaudeCountTokensRequest detection helpers and tests
relay/common/relay_info.go, relay/common/relay_info_test.go
Adds IsClaudeCountTokensRequestPath and IsClaudeCountTokensRequest with URL-parse path matching and a RelayMode shortcut, tested for positive/negative cases including query parameters.
HTTP route registration and adaptor URL switching
router/relay-router.go, relay/channel/claude/adaptor.go, relay/channel/claude/adaptor_test.go
Registers POST /v1/messages/count_tokens wired to controller.Relay; Adaptor.GetRequestURL dynamically selects the count_tokens path when detected; adaptor tests cover both URL outcomes.
Claude handler request/response bypass and billing skip
relay/claude_handler.go, controller/relay.go
ClaudeHelper clears MaxTokens and Stream for count_tokens requests and adds an early-return response path that forwards the upstream body directly, bypassing adaptor.DoResponse and quota consumption. controller/relay.go skips ModelPriceHelper and PreConsumeBilling, setting FinalPreConsumedQuota to 0.

Group-based subscription payment amount

Layer / File(s) Summary
Group-adjusted payMoney in epay and waffo-pancake handlers
controller/subscription_payment_epay.go, controller/subscription_payment_waffo_pancake.go
Both handlers now fetch the user's group, compute payMoney via a group pricing helper, reject amounts below 0.01, store payMoney in the SubscriptionOrder, and use it in the checkout request and log output.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RelayController
  participant ClaudeHelper
  participant UpstreamClaude

  Client->>RelayController: POST /v1/messages/count_tokens
  RelayController->>RelayController: IsClaudeCountTokensRequest → FinalPreConsumedQuota=0, skip PreConsumeBilling
  RelayController->>ClaudeHelper: ClaudeHelper(info, request)
  ClaudeHelper->>ClaudeHelper: clear MaxTokens, MaxTokensToSample, Stream=nil
  ClaudeHelper->>UpstreamClaude: GET /v1/messages/count_tokens (via adaptor URL)
  UpstreamClaude-->>ClaudeHelper: response body
  ClaudeHelper->>ClaudeHelper: IsClaudeCountTokensRequest → read + close body, skip DoResponse/quota
  ClaudeHelper-->>Client: copy raw response body, return nil
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • QuantumNous/new-api#1775: Modifies the same controller/relay.go pre-consumption/billing flow and relayInfo.FinalPreConsumedQuota handling that this PR's Claude count_tokens billing bypass builds on top of.

Suggested reviewers

  • seefs001
  • Calcium-Ion

🐇 A new path to count the tokens with care,
No quota consumed, no billing to bear!
The relay hops quick to /count_tokens today,
And group-priced subscriptions now find their own way.
"No charge for counting!" the rabbit declares,
Hopping through handlers without any snares! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 Claude messages token counting' accurately summarizes the main change: adding support for Anthropic Claude's token counting endpoint.
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

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/subscription_payment_epay.go`:
- Line 92: The int64 cast in the getPayMoney call is truncating the fractional
cents from plan.PriceAmount (a float64 representing decimal currency amounts).
Instead of casting directly to int64, multiply plan.PriceAmount by 100 to
convert dollars to cents, then cast to int64, ensuring full precision is
preserved throughout the payment calculation. Make the corresponding adjustment
to the getPayMoney function signature and implementation to handle the input as
cents rather than dollars. Apply this same fix to the identical pattern in
subscription_payment_waffo_pancake.go to ensure consistency across all payment
methods.

In `@controller/subscription_payment_waffo_pancake.go`:
- Around line 82-86: In the getWaffoPancakePayMoney function call, remove the
int64() type cast on plan.PriceAmount to prevent truncation of decimal values;
pass plan.PriceAmount directly to the function. Additionally, change the payment
amount threshold comparison from < 0.01 to <= 0.01 to align with the consistent
threshold check used in topup_waffo_pancake.go for the same payment provider.

In `@relay/channel/claude/adaptor_test.go`:
- Around line 14-16: The RelayInfo struct literal is attempting to set
ChannelBaseUrl directly, but since ChannelMeta is embedded as a pointer
(*ChannelMeta) rather than directly embedded, Go's struct literal syntax cannot
set its promoted fields this way. Fix this by explicitly initializing the
ChannelMeta field with a pointer to a new ChannelMeta struct that contains the
ChannelBaseUrl value. This applies to both locations mentioned in the comment
(around lines 14 and 26) where RelayInfo struct literals are being constructed
with ChannelBaseUrl and RelayMode fields.
🪄 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: d22d449d-3bdb-4dbb-bfd1-d825443a1944

📥 Commits

Reviewing files that changed from the base of the PR and between f7dae5c and dd76d7b.

📒 Files selected for processing (11)
  • controller/relay.go
  • controller/subscription_payment_epay.go
  • controller/subscription_payment_waffo_pancake.go
  • relay/channel/claude/adaptor.go
  • relay/channel/claude/adaptor_test.go
  • relay/claude_handler.go
  • relay/common/relay_info.go
  • relay/common/relay_info_test.go
  • relay/constant/relay_mode.go
  • relay/constant/relay_mode_test.go
  • router/relay-router.go

c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(int64(plan.PriceAmount), group)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify plan.PriceAmount type and getPayMoney signature
echo "=== Check SubscriptionPlan model for PriceAmount type ==="
rg -n "PriceAmount" --type go -C3 | head -60

echo ""
echo "=== Check getPayMoney signature ==="
ast-grep --pattern 'func getPayMoney($$$) $_ {
  $$$
}'

Repository: QuantumNous/new-api

Length of output: 5059


🏁 Script executed:

#!/bin/bash
# Verify the actual line 92 in subscription_payment_epay.go
echo "=== Lines 85-105 in subscription_payment_epay.go ==="
sed -n '85,105p' controller/subscription_payment_epay.go

echo ""
echo "=== Check SubscriptionOrder Money field type ==="
rg -n "type SubscriptionOrder struct" -A 20 model/subscription.go | head -30

echo ""
echo "=== Search for cents convention patterns ==="
rg -n "int64.*100\|cents\|\* 100" --type go controller/ | head -20

Repository: QuantumNous/new-api

Length of output: 1651


🏁 Script executed:

#!/bin/bash
# Check how other payment methods handle plan.PriceAmount
echo "=== Check other payment method handlers (balance, stripe, etc) ==="
rg -n "payMoney\|Money.*plan.PriceAmount\|Money.*plan\.Price" --type go controller/ -B2 -A2 | head -50

echo ""
echo "=== Check if there's a float64-based payment function ==="
rg -n "func.*Payment.*float64\|func.*Money.*float64" --type go controller/ | grep -v test

echo ""
echo "=== Look at balance payment implementation for comparison ==="
rg -n "PaymentMethodBalance\|PaymentProviderBalance" --type go -A10 controller/subscription*.go | head -60

Repository: QuantumNous/new-api

Length of output: 252


🏁 Script executed:

#!/bin/bash
# Check the full subscription payment file for other methods
echo "=== Check subscription_payment files ==="
ls -la controller/subscription_payment*.go

echo ""
echo "=== Check balance payment method in detail ==="
rg -n "handleSubscriptionBalancePay\|PaymentMethodBalance" --type go -B5 -A15 model/subscription.go | head -80

echo ""
echo "=== Check how getPayMoney is used elsewhere ==="
rg -n "getPayMoney" --type go -B2 -A2

Repository: QuantumNous/new-api

Length of output: 1689


🏁 Script executed:

#!/bin/bash
# Check other subscription payment methods for how they handle plan.PriceAmount
echo "=== Check subscription_payment_stripe.go ==="
sed -n '1,120p' controller/subscription_payment_stripe.go | grep -A5 -B5 "plan.PriceAmount\|Money.*plan"

echo ""
echo "=== Check subscription_payment_creem.go ==="
sed -n '1,120p' controller/subscription_payment_creem.go | grep -A5 -B5 "plan.PriceAmount\|Money.*plan"

echo ""
echo "=== Check subscription_payment_waffo_pancake.go ==="
sed -n '1,120p' controller/subscription_payment_waffo_pancake.go | grep -A5 -B5 "plan.PriceAmount\|Money.*plan"

Repository: QuantumNous/new-api

Length of output: 1532


🏁 Script executed:

#!/bin/bash
# Check getWaffoPancakePayMoney function
echo "=== Check getWaffoPancakePayMoney function ==="
rg -n "func getWaffoPancakePayMoney" --type go -A20

echo ""
echo "=== Check complete epay payment file to understand context ==="
cat -n controller/subscription_payment_epay.go | sed -n '1,120p'

Repository: QuantumNous/new-api

Length of output: 5439


🏁 Script executed:

#!/bin/bash
# Final verification: Check if topup Amount is in cents or dollars
echo "=== Check TopupRequest structure ==="
rg -n "type TopupRequest\|type.*Request struct" --type go controller/topup.go -A10 | head -40

echo ""
echo "=== Check how req.Amount is used in topup vs subscription ==="
echo "In topup.go context:"
rg -n "req.Amount" controller/topup.go | head -10

echo ""
echo "=== Verify if there's a cents convention in topup ==="
rg -n "Amount.*100\|cents" --type go controller/topup.go | head -10

Repository: QuantumNous/new-api

Length of output: 1132


int64() truncation loses fractional price amounts—affects billing accuracy

plan.PriceAmount is float64 (stored as decimal(10,6) in the database), but casting to int64 truncates decimals. A plan priced at $9.99 becomes $9, undercharging by $0.99.

The issue appears in both subscription payment methods:

  • subscription_payment_epay.go line 92
  • subscription_payment_waffo_pancake.go (similar pattern)

Other payment methods (Stripe, Creem) avoid this by using plan.PriceAmount directly without conversion.

Consider either:

  1. Passing cents: int64(plan.PriceAmount * 100) and adjusting getPayMoney to work in cents, or
  2. Creating a float-aware helper for subscription pricing that preserves precision (similar to how Stripe/Creem handle it).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/subscription_payment_epay.go` at line 92, The int64 cast in the
getPayMoney call is truncating the fractional cents from plan.PriceAmount (a
float64 representing decimal currency amounts). Instead of casting directly to
int64, multiply plan.PriceAmount by 100 to convert dollars to cents, then cast
to int64, ensuring full precision is preserved throughout the payment
calculation. Make the corresponding adjustment to the getPayMoney function
signature and implementation to handle the input as cents rather than dollars.
Apply this same fix to the identical pattern in
subscription_payment_waffo_pancake.go to ensure consistency across all payment
methods.

Comment on lines +82 to +86
payMoney := getWaffoPancakePayMoney(int64(plan.PriceAmount), user.Group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐金额过低"})
return
}

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 | ⚡ Quick win

Same int64() truncation issue as epay handler

int64(plan.PriceAmount) truncates decimal prices (see comment on subscription_payment_epay.go line 92).

Additionally, the comparison < 0.01 differs from the existing topup_waffo_pancake.go which uses <= 0.01 (context snippet 2, line 45). For consistency within the same payment provider, consider aligning the threshold check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/subscription_payment_waffo_pancake.go` around lines 82 - 86, In
the getWaffoPancakePayMoney function call, remove the int64() type cast on
plan.PriceAmount to prevent truncation of decimal values; pass plan.PriceAmount
directly to the function. Additionally, change the payment amount threshold
comparison from < 0.01 to <= 0.01 to align with the consistent threshold check
used in topup_waffo_pancake.go for the same payment provider.

Comment on lines +14 to +16
ChannelBaseUrl: "https://api.anthropic.com",
RelayMode: relayconstant.RelayModeClaudeCountTokens,
}

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
set -euo pipefail

echo "== RelayInfo and ChannelMeta declarations =="
rg -n --type=go 'type RelayInfo struct|type ChannelMeta struct|ChannelBaseUrl' relay/common/relay_info.go relay/common/channel_meta.go || true

echo
echo "== Invalid test literal usage =="
rg -nPU --type=go 'RelayInfo\s*{(?s).*?ChannelBaseUrl\s*:' relay/channel/claude/adaptor_test.go

Repository: QuantumNous/new-api

Length of output: 952


🏁 Script executed:

# View the RelayInfo and ChannelMeta struct definitions
sed -n '64,115p' relay/common/relay_info.go

Repository: QuantumNous/new-api

Length of output: 1580


🏁 Script executed:

# View the full test context around the problematic lines
sed -n '10,35p' relay/channel/claude/adaptor_test.go

Repository: QuantumNous/new-api

Length of output: 818


🏁 Script executed:

sed -n '89,120p' relay/common/relay_info.go

Repository: QuantumNous/new-api

Length of output: 1084


🏁 Script executed:

sed -n '120,150p' relay/common/relay_info.go

Repository: QuantumNous/new-api

Length of output: 1502


🏁 Script executed:

sed -n '150,180p' relay/common/relay_info.go

Repository: QuantumNous/new-api

Length of output: 1502


🏁 Script executed:

sed -n '180,210p' relay/common/relay_info.go

Repository: QuantumNous/new-api

Length of output: 1474


Fix invalid RelayInfo struct literals (compile blocker).

Lines 14 and 26 attempt to initialize ChannelBaseUrl directly on RelayInfo, but ChannelMeta is embedded as a pointer (*ChannelMeta), not a direct field. Struct literal syntax cannot set promoted fields of pointer-embedded types. Initialize ChannelMeta explicitly instead.

Proposed fix
 func TestGetRequestURLUsesMessagesCountTokensPath(t *testing.T) {
 	adaptor := &Adaptor{}
 	info := &relaycommon.RelayInfo{
-		ChannelBaseUrl: "https://api.anthropic.com",
-		RelayMode:      relayconstant.RelayModeClaudeCountTokens,
+		ChannelMeta: &relaycommon.ChannelMeta{
+			ChannelBaseUrl: "https://api.anthropic.com",
+		},
+		RelayMode: relayconstant.RelayModeClaudeCountTokens,
 	}
@@
 func TestGetRequestURLKeepsMessagesPathForClaudeMessages(t *testing.T) {
 	adaptor := &Adaptor{}
 	info := &relaycommon.RelayInfo{
-		ChannelBaseUrl: "https://api.anthropic.com",
-		RequestURLPath:  "/v1/messages",
+		ChannelMeta: &relaycommon.ChannelMeta{
+			ChannelBaseUrl: "https://api.anthropic.com",
+		},
+		RequestURLPath: "/v1/messages",
 	}
🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 14-14: : # github.com/QuantumNous/new-api/relay/channel/claude [github.com/QuantumNous/new-api/relay/channel/claude.test]
relay/channel/claude/adaptor_test.go:14:3: unknown field ChannelBaseUrl in struct literal of type "github.com/QuantumNous/new-api/relay/common".RelayInfo
relay/channel/claude/adaptor_test.go:26:3: unknown field ChannelBaseUrl in struct literal of type "github.com/QuantumNous/new-api/relay/common".RelayInfo

(typecheck)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/claude/adaptor_test.go` around lines 14 - 16, The RelayInfo
struct literal is attempting to set ChannelBaseUrl directly, but since
ChannelMeta is embedded as a pointer (*ChannelMeta) rather than directly
embedded, Go's struct literal syntax cannot set its promoted fields this way.
Fix this by explicitly initializing the ChannelMeta field with a pointer to a
new ChannelMeta struct that contains the ChannelBaseUrl value. This applies to
both locations mentioned in the comment (around lines 14 and 26) where RelayInfo
struct literals are being constructed with ChannelBaseUrl and RelayMode fields.

Source: Linters/SAST tools

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.

1 participant