Skip to content

feat(claude): support /v1/messages/count_tokens with local estimation - #4441

Open
daymade wants to merge 3 commits into
QuantumNous:mainfrom
daymade:feature/count-tokens-local-estimate
Open

feat(claude): support /v1/messages/count_tokens with local estimation#4441
daymade wants to merge 3 commits into
QuantumNous:mainfrom
daymade:feature/count-tokens-local-estimate

Conversation

@daymade

@daymade daymade commented Apr 24, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

📝 变更描述 / Description

实现 Anthropic /v1/messages/count_tokens 端点的本地估算,避免 Claude SDK / CLI 在该端点 404 时退化为发送 max_tokens=1 的伪请求。

真实事故场景:单个 Claude Code Desktop session 在一分钟内对同一上游 channel 发出 250 个伪 probe 请求(每个请求体 ~53KB,全是 tool schema 但 max_tokens=1),打爆上游 RPM 配额,导致同 channel 上其他用户在 ~2 分钟内收到 429。本地估算端点解决根因:SDK 拿到 200 + 一个合理的 input_tokens 数字就不会 fallback。

实现要点:

  • 路由直接挂在 relayV1Router 上而不是 httpRouter,跳过 middleware.Distribute()——这与 Anthropic 规范一致(count_tokens 不路由到上游、不消费配额)
  • 估算复用项目已有的 ClaudeRequest.GetTokenCountMeta() + EstimateTokenByModel(),与 /v1/messages 实际计费的 tokenizer 一致
  • 一个小 helper normalizeRequestTools() 修了 dto.ProcessTools 在收到 raw map[string]any tool 时静默丢弃的 latent bug——CLI probe 体里 80%+ 的字节都是 tool schema,不修这个 estimate 会严重偏低
  • 图片 token 暂不计入(需要 RelayInfo + http.Request context,本路由刻意没有);CLI probe 不带图片,不影响本 PR 解决的失败场景

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix)
  • ✨ 新功能 (New feature)
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

参考 #2384(已 close 未 merge)。本 PR 与之相比:不引入新 RelayFormat enum、不改 adaptor、不改 distributor、不引入 autoban 旁路。改动面只有一条新路由 + 一个 controller + 一个 estimator,对所有未触达 /v1/messages/count_tokens 的代码路径零影响。

✅ 提交前检查项 / Checklist

📸 运行证明 / Proof of Work

1. 单元测试 (12 cases passed)

$ go test ./service/ -run 'TestEstimateClaude|TestNormalizeRequestTools' -v
=== RUN   TestEstimateClaudeInputTokens
    --- PASS: empty messages (0.00s)                                  → 0
    --- PASS: single short user message (0.00s)                       → 4
    --- PASS: string system + user (0.00s)                            → 9
    --- PASS: CLI-probe shape: trivial message + bash tool schema     → 43
    --- PASS: system as array of text blocks (0.00s)                  → 12
    --- PASS: CJK content (0.00s)                                     → 20
    --- PASS: tool_use block on assistant turn (0.00s)                → 10
    --- PASS: tool_result block on user turn (0.00s)                  → 9
    --- PASS: web-search tool (distinguished by type field)           → 7
    --- PASS: tools array with already-typed entries                  → 18
=== RUN   TestEstimateClaudeInputTokens_NilSafety       — PASS (2 cases)
=== RUN   TestNormalizeRequestTools                     — PASS (3 cases)
PASS    ok  github.com/QuantumNous/new-api/service  0.6s

2. 端到端: 不带 PR 时 SDK 行为 (实测自我们生产环境的事故)

CLI 检测到 /v1/messages/count_tokens 返 404,退化为发送伪请求到 /v1/messages。截取一个真实的 53KB 伪请求体(已脱敏,源自我们的 OSS 归档):

{
  "model": "claude-haiku-4-5-20251001",
  "max_tokens": 1,
  "messages": [{"role": "user", "content": "count"}],
  "tools": [
    /* 18 个完整 tool schema,~52KB,含 Bash / AskUserQuestion / ... */
  ]
}

请求头 user-agent: claude-cli/2.1.111 (external, claude-desktop-3p, agent-sdk/0.2.111)。同一 device_id + session_id 在 60 秒内连发 250 个,149 个被上游返 429 rate limit reached for RPM

3. 带 PR 时端点行为 (实测自本地 build)

$ curl -sX POST http://127.0.0.1:3000/v1/messages/count_tokens \
    -H "Authorization: Bearer sk-..." \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d @cli-probe-body.json
{"input_tokens":43}

$ # SDK 接受 200,停止发送伪请求

错误路径 (malformed body):

$ curl -sX POST http://127.0.0.1:3000/v1/messages/count_tokens \
    -H "Authorization: Bearer sk-..." \
    -H "content-type: application/json" \
    -d 'not json'
{"type":"error","error":{"type":"invalid_request_error","message":"invalid JSON body: ..."}}

维护者可能的问题 / Anticipated reviewer questions

Q: 为什么不直接转发到上游?
A: 大部分 Claude 兼容上游(Qiniu QNAIGC / OpenRouter / 等)自己也没实现这个端点 (实测 Qiniu 返 404)。即使是 Anthropic 官方端点,虽免费但仍占 RPM 配额,pass-through 不解决我们这个 PR 要解的问题。

Q: 为什么不用真 tokenizer (tiktoken / anthropic-tokenizer)?
A: 项目已有 EstimateTokenByModel(model, text) 带 Claude-tuned multipliers (Word 1.13, CJK 1.21, MathSymbol 4.52, ...),用在 service/token_counter.go 等多处。本 PR 不引入新依赖,复用已有实现,估算与 /v1/messages 自身计费一致。

Q: normalizeRequestTools 是否应该改 dto.ProcessTools 而不是在 estimator 自己 normalize?
A: 改 ProcessTools 是更彻底的 fix,但会影响所有 caller(distributor / handler / 其他 future code paths)的行为,scope 太大、回归风险也大。本 PR 只改自己 reach 到的代码,把 latent bug 在 estimator 边界处兜住。如果维护者倾向把 normalize 提到 ProcessTools,欢迎反馈,我可以再开一个 PR 单独处理。

Q: 图片 token 不算合理吗?
A: Anthropic 规范说 count_tokens 应当包含图片。我们权衡的是:(1) getImageToken 需要 RelayInfo + http.Request,本路由刻意不走 channel pipeline;(2) 触发本 PR 的 CLI probe 失败模式不带图片;(3) 真要加图片可后续 PR,需要把 image-token 计算从 service/token_counter.go 中抽成 standalone helper。

Summary by CodeRabbit

  • New Features
    • Added POST /v1/messages/count_tokens to return an estimated input-token count for Claude-style messages.
    • Added a local token estimator that normalizes diverse message shapes and tool representations (including web-search variants) before estimating.
  • Tests
    • Added comprehensive tests for estimator accuracy, tool normalization, routing of web-search vs non-web-search tools, and nil-safety.

Review Change Stack

@coderabbitai

coderabbitai Bot commented Apr 24, 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

Adds a Claude-compatible POST /v1/messages/count_tokens endpoint with a Gin handler and relay route, a local estimator that normalizes request tools and computes input token estimates, and unit tests for estimator and normalization behavior.

Changes

Claude count_tokens

Layer / File(s) Summary
HTTP handler and route
controller/claude_count_tokens.go, router/relay-router.go
New Gin handler ClaudeCountTokens and relay route POST /v1/messages/count_tokens. Handler unmarshals into dto.ClaudeRequest, returns 400 with types.ClaudeError on invalid JSON, otherwise calls estimator and returns { "input_tokens": N }.
Estimator core
service/claude_token_estimator.go
Added EstimateClaudeInputTokens(req *dto.ClaudeRequest) int: nil-safe, obtains combined text via GetTokenCountMeta().CombineText, and delegates to EstimateTokenByModel.
Tool normalization
service/claude_token_estimator.go
normalizeRequestTools mutates req.Tools in-place, preserves already-typed tools, converts map[string]any entries into *dto.Tool or *dto.ClaudeWebSearchTool by marshal/unmarshal, filters malformed entries, and routes web_search_* types to web-search tool struct.
Tests
service/claude_token_estimator_test.go
Table-driven tests for various request shapes, nil-safety, and normalization edge cases (malformed, pre-typed tools, web-search routing).

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Handler as ClaudeCountTokens
    participant Service as EstimateClaudeInputTokens
    participant Estimator as EstimateTokenByModel

    Client->>Handler: POST /v1/messages/count_tokens with body
    Handler->>Handler: Unmarshal body -> dto.ClaudeRequest
    alt Unmarshal fails
        Handler-->>Client: 400 Bad Request (invalid_request_error)
    else Success
        Handler->>Service: EstimateClaudeInputTokens(&req)
        Service->>Service: normalizeRequestTools(&req)
        Service->>Service: meta := req.GetTokenCountMeta().CombineText
        Service->>Estimator: EstimateTokenByModel(req.Model, meta)
        Estimator-->>Service: token count
        Service-->>Handler: input_tokens
        Handler-->>Client: 200 OK { "input_tokens": N }
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested reviewers

  • seefs001

Poem

🐰 I hopped through requests with cheer,
Tools sorted tidy, tokens near,
I combined the words and gave a count—
Soft thumps, small math, no large amount.
I sniffed the bytes and danced in cheer.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% 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 accurately describes the main feature: adding support for the /v1/messages/count_tokens endpoint with local token estimation, which is the core change across the 4 modified files.
Linked Issues check ✅ Passed The PR fulfills all three linked issues: implements Claude-compatible /v1/messages/count_tokens endpoint [#1694, #2847] with local estimation avoiding upstream requests, and prevents 429 errors from SDK fallback probes [#1979].
Out of Scope Changes check ✅ Passed All changes are scoped to implementing the count_tokens endpoint: new controller handler, router registration, token estimation logic with tool normalization, and comprehensive tests. No unrelated alterations to adaptor/distributor/RelayFormat/autoban as confirmed.

✏️ 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.

@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: 6

🧹 Nitpick comments (6)
.github/workflows/ghcr-publish.yml (3)

10-12: Redundant branch pattern.

feature/** already matches feature/count-tokens-local-estimate, so line 11 is dead configuration. Safe to remove unless you specifically want a visible marker.

🧹 Proposed tidy-up
     branches:
-      - feature/count-tokens-local-estimate
       - feature/**
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ghcr-publish.yml around lines 10 - 12, The branches list
contains a redundant pattern: "feature/**" already covers
"feature/count-tokens-local-estimate"; remove the specific
"feature/count-tokens-local-estimate" entry (or remove "feature/**" if you
intended only the single branch) so only the necessary pattern ("feature/**" for
all feature branches) remains; update the branches array accordingly to
eliminate the dead configuration.

19-22: Consider adding a concurrency group to avoid racing on the mutable counttokens tag.

Two rapid pushes to any feature/** branch will run this job in parallel and race to overwrite ghcr.io/daymade/new-api:counttokens. The per-SHA tag is safe, but the floating tag can end up pointing at an older commit if the jobs finish out of order. A branch-scoped concurrency group (with cancel-in-progress: true) keeps the latest push authoritative and also saves CI minutes.

🛡️ Proposed addition
 jobs:
   build-and-push:
     runs-on: ubuntu-latest
+    concurrency:
+      group: ghcr-publish-${{ github.ref }}
+      cancel-in-progress: true
     steps:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ghcr-publish.yml around lines 19 - 22, The workflow's
build-and-push job can race when two pushes to the same feature branch write the
mutable ghcr tag "counttokens"; add a concurrency block on the job
"build-and-push" using a branch-scoped group (e.g. use github.ref or
github.ref_name with the feature/** pattern) and set cancel-in-progress: true so
only the latest push wins and parallel runs are cancelled; update the job
definition to include this concurrency stanza to prevent tag overwrite races.

57-59: Remove redundant TARGETOS and TARGETARCH build args.

When platforms: linux/amd64 is set, BuildKit automatically provides TARGETOS and TARGETARCH as predefined global build args to every stage that declares them (which the Dockerfile does at lines 14-15). Explicitly passing them here is redundant and risks silently breaking cross-builds if you later expand platforms to multi-architecture builds, since the hardcoded amd64 would override the per-platform value.

♻️ Proposed simplification
           provenance: false
           sbom: false
-          build-args: |
-            TARGETOS=linux
-            TARGETARCH=amd64
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ghcr-publish.yml around lines 57 - 59, The build workflow
is passing redundant hardcoded build args TARGETOS and TARGETARCH which override
BuildKit's per-platform values; remove the TARGETOS=linux and TARGETARCH=amd64
entries from the build-args block so that the platforms: linux/amd64 setting can
allow BuildKit to inject the correct TARGETOS/TARGETARCH automatically (look for
the build-args block containing TARGETOS and TARGETARCH in the ghcr-publish
workflow).
service/claude_token_estimator_test.go (1)

60-72: Consider exercising the handler path, not just the estimator.

These tests validate the estimator in isolation, but the more interesting risk surface — the HTTP handler's body parsing and response shape — is not covered. A small httptest-based test that posts raw JSON to ClaudeCountTokens and asserts the {"input_tokens": N} envelope (plus the 400 error shape on malformed JSON) would catch regressions in the wire contract that the Anthropic SDK depends on.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator_test.go` around lines 60 - 72, Add an
httptest-based integration test that exercises the HTTP handler
ClaudeCountTokens rather than only calling EstimateClaudeInputTokens: construct
raw JSON bodies (valid and malformed), POST them to the ClaudeCountTokens
handler using httptest.NewRequest/httptest.NewRecorder, decode the JSON response
and assert the envelope contains {"input_tokens": N} for valid input (N within
the expected range from EstimateClaudeInputTokens) and that malformed JSON
returns the 400 error shape; reuse dto.ClaudeRequest for building valid bodies
and call EstimateClaudeInputTokens in the test to compute the expected token
range to assert against the handler response.
service/claude_token_estimator.go (2)

28-50: Estimator is non-deterministic across requests due to Go map ordering.

claudeToolsChars relies on json.Marshal(tools). When tools contains nested map[string]any (which is what any-typed fields produce after generic unmarshaling — see dto.ClaudeRequest.Tools any), encoding/json sorts map keys, so this happens to be stable. However, ToolChoice, system as []any, and tool input_schema.properties all decode to map[string]any too — the "stable estimate" promise in the doc comment (lines 24–25) holds only because of that Go stdlib guarantee. Worth a one-line comment acknowledging the dependency so a future switch to a faster/non-sorting JSON encoder doesn't silently break the invariant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator.go` around lines 28 - 50, The token estimator
relies on json.Marshal's deterministic key-sorting to keep estimates stable; add
a one-line comment in EstimateClaudeInputTokens (or directly above
claudeToolsChars) noting that the stable-estimate guarantee depends on
encoding/json's map key sorting for any map[string]any structures (e.g.,
dto.ClaudeRequest.Tools, ToolChoice, system as []any, and tool
input_schema.properties), so switching to a non-sorting JSON encoder would break
determinism.

9-27: Nice doc comment; minor: nothing prevents the "fast" claim from regressing.

The header promises "fast, free local estimate" and calls out the 250 RPM fallback scenario. Given a sufficiently adversarial tool schema (deeply nested, MB-scale), json.Marshal on tools can still be CPU-visible. Consider capping the marshaled byte count you actually consume (e.g. stop at N KB) so the handler's latency is bounded even for pathological inputs — this aligns with the route's purpose of shedding traffic cheaply.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator.go` around lines 9 - 27, The comment warns
that EstimateClaudeInputTokens currently json.Marshal(s) the full tools payload
which can regress the "fast, free" promise for pathological inputs; modify the
EstimateClaudeInputTokens implementation (the json.Marshal of tools / any full
marshaling paths) to cap how many bytes are actually processed (e.g. stop after
a configurable N KB like 64KB) by using a streaming/limited marshal approach
(io.LimitReader/LimitWriter or write to a buffer and bail when length exceeds
the cap) and then compute the chars/3 estimate from the truncated output
(ensuring you still round up) so the handler's latency is bounded for large/deep
tool schemas.
🤖 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/claude_count_tokens.go`:
- Line 33: The handler uses io.ReadAll(c.Request.Body) without any size limit,
allowing attackers to send arbitrarily large payloads; replace that call by
wrapping c.Request.Body with a size-limiting reader (e.g., http.MaxBytesReader
or io.LimitReader) using the project's configured max for Claude requests and
then read from that limited reader; update the code paths referencing body and
error handling in the claude_count_tokens handler to reject requests that exceed
the limit (return appropriate error) and ensure TokenAuth-protected endpoints
use the same cap.
- Line 4: Replace direct use of the standard library JSON package in
controller/claude_count_tokens.go with the project's JSON wrapper: remove the
"encoding/json" import and change all calls that currently use
json.Marshal/json.Unmarshal/json.NewDecoder or similar (e.g., in the functions
handling payload encoding/decoding around lines 46-55) to the corresponding
common package helpers such as common.Marshal(), common.Unmarshal(),
common.DecodeJson() or common.UnmarshalJsonStr() as appropriate, preserving the
same error handling and types; ensure imports include the common package and
update any variable names if needed to match the wrapper return signatures.
- Around line 32-43: Replace the manual body read/unmarshal in ClaudeCountTokens
with the project's helper: remove io and encoding/json imports, declare var req
dto.ClaudeRequest and call common.UnmarshalBodyReusable(c, &req); on error
return the same HTTP 400 JSON error payload but use the error message "invalid
JSON body: "+err.Error(); keep all other logic intact and reference the
ClaudeCountTokens handler and dto.ClaudeRequest when making the change.

In `@router/relay-router.go`:
- Around line 92-101: The ClaudeCountTokens route is registered on httpRouter
and therefore inherits relayV1Router middlewares (ModelRequestRateLimit,
Distribute) contrary to its docstring; create a sibling router group off
relayV1Router that only applies TokenAuth (and optionally RouteTag) and register
controller.ClaudeCountTokens on that group instead of httpRouter so it bypasses
ModelRequestRateLimit and Distribute; additionally, in
controller/claude_count_tokens.go replace the direct encoding/json.Unmarshal
call with common.Unmarshal to conform to project JSON helpers.

In `@service/claude_token_estimator.go`:
- Around line 81-89: claudeMessageChars currently uses
dto.ClaudeMessage.GetStringContent() which ignores non-text blocks (tool_result,
tool_use, image) and undercounts; update claudeMessageChars to fall back when
GetStringContent() returns empty but msg.Content != nil by computing the byte
length of json.Marshal(msg.Content) (or by explicitly extracting
tool_result.content and tool_use.input if you prefer) and return that length so
non-text blocks are accounted for; ensure you still return 0 for a nil msg and
preserve the existing behavior when GetStringContent() yields non-empty text.
- Line 4: Replace direct usage of the standard library JSON package with the
project's JSON wrapper: remove the "encoding/json" import and import the common
JSON wrapper (package common), then replace all json.Marshal and json.Unmarshal
calls in this file (the import at top and the calls around the areas flagged,
e.g., the json.Unmarshal call near line 74 and the json.Marshal call near line
95) with common.Unmarshal(...) and common.Marshal(...). Ensure the error
handling and variable types remain the same when switching to
common.Marshal/common.Unmarshal.

---

Nitpick comments:
In @.github/workflows/ghcr-publish.yml:
- Around line 10-12: The branches list contains a redundant pattern:
"feature/**" already covers "feature/count-tokens-local-estimate"; remove the
specific "feature/count-tokens-local-estimate" entry (or remove "feature/**" if
you intended only the single branch) so only the necessary pattern ("feature/**"
for all feature branches) remains; update the branches array accordingly to
eliminate the dead configuration.
- Around line 19-22: The workflow's build-and-push job can race when two pushes
to the same feature branch write the mutable ghcr tag "counttokens"; add a
concurrency block on the job "build-and-push" using a branch-scoped group (e.g.
use github.ref or github.ref_name with the feature/** pattern) and set
cancel-in-progress: true so only the latest push wins and parallel runs are
cancelled; update the job definition to include this concurrency stanza to
prevent tag overwrite races.
- Around line 57-59: The build workflow is passing redundant hardcoded build
args TARGETOS and TARGETARCH which override BuildKit's per-platform values;
remove the TARGETOS=linux and TARGETARCH=amd64 entries from the build-args block
so that the platforms: linux/amd64 setting can allow BuildKit to inject the
correct TARGETOS/TARGETARCH automatically (look for the build-args block
containing TARGETOS and TARGETARCH in the ghcr-publish workflow).

In `@service/claude_token_estimator_test.go`:
- Around line 60-72: Add an httptest-based integration test that exercises the
HTTP handler ClaudeCountTokens rather than only calling
EstimateClaudeInputTokens: construct raw JSON bodies (valid and malformed), POST
them to the ClaudeCountTokens handler using
httptest.NewRequest/httptest.NewRecorder, decode the JSON response and assert
the envelope contains {"input_tokens": N} for valid input (N within the expected
range from EstimateClaudeInputTokens) and that malformed JSON returns the 400
error shape; reuse dto.ClaudeRequest for building valid bodies and call
EstimateClaudeInputTokens in the test to compute the expected token range to
assert against the handler response.

In `@service/claude_token_estimator.go`:
- Around line 28-50: The token estimator relies on json.Marshal's deterministic
key-sorting to keep estimates stable; add a one-line comment in
EstimateClaudeInputTokens (or directly above claudeToolsChars) noting that the
stable-estimate guarantee depends on encoding/json's map key sorting for any
map[string]any structures (e.g., dto.ClaudeRequest.Tools, ToolChoice, system as
[]any, and tool input_schema.properties), so switching to a non-sorting JSON
encoder would break determinism.
- Around line 9-27: The comment warns that EstimateClaudeInputTokens currently
json.Marshal(s) the full tools payload which can regress the "fast, free"
promise for pathological inputs; modify the EstimateClaudeInputTokens
implementation (the json.Marshal of tools / any full marshaling paths) to cap
how many bytes are actually processed (e.g. stop after a configurable N KB like
64KB) by using a streaming/limited marshal approach (io.LimitReader/LimitWriter
or write to a buffer and bail when length exceeds the cap) and then compute the
chars/3 estimate from the truncated output (ensuring you still round up) so the
handler's latency is bounded for large/deep tool schemas.
🪄 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: f42381a1-157d-4c99-a5af-d170ba35439e

📥 Commits

Reviewing files that changed from the base of the PR and between 8993386 and f91a002.

📒 Files selected for processing (5)
  • .github/workflows/ghcr-publish.yml
  • controller/claude_count_tokens.go
  • router/relay-router.go
  • service/claude_token_estimator.go
  • service/claude_token_estimator_test.go

Comment thread controller/claude_count_tokens.go Outdated
Comment thread controller/claude_count_tokens.go Outdated
Comment thread controller/claude_count_tokens.go Outdated
Comment thread router/relay-router.go Outdated
Comment on lines +92 to +101
// Anthropic's /v1/messages/count_tokens — local estimate, does not
// hit any upstream channel. The Claude CLI / Anthropic SDK polls
// this endpoint frequently; serving a fast 200 here prevents the
// SDK from falling back to fake max_tokens=1 probes that flood
// upstream RPM quotas. See controller.ClaudeCountTokens for the
// rationale and the security boundary (TokenAuth still applies via
// the parent group; Distribute / PreConsume are intentionally
// skipped because no channel is selected).
httpRouter.POST("/messages/count_tokens", controller.ClaudeCountTokens)

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
# Confirm what Distribute() and ModelRequestRateLimit() do, to assess the impact.
rg -nP --type=go -C3 '\bfunc\s+Distribute\s*\('
rg -nP --type=go -C3 '\bfunc\s+ModelRequestRateLimit\s*\('
# Also verify no other route registrations bypass Distribute by using a sibling group under relayV1Router.
rg -nP --type=go -C2 'relayV1Router\.Group\b'

Repository: QuantumNous/new-api

Length of output: 1452


🏁 Script executed:

sed -n '65,105p' router/relay-router.go

Repository: QuantumNous/new-api

Length of output: 1640


🏁 Script executed:

# Check what ModelRequestRateLimit does and if it applies to count_tokens
sed -n '167,185p' middleware/model-rate-limit.go

# Check what Distribute does with channel selection
sed -n '30,50p' middleware/distributor.go

# Check the controller implementation
sed -n '1,50p' controller/claude.go | grep -A 30 "ClaudeCountTokens"

Repository: QuantumNous/new-api

Length of output: 1548


🏁 Script executed:

# Find the ClaudeCountTokens controller
find . -type f -name "*.go" -exec grep -l "func ClaudeCountTokens" {} \;

# Search for ClaudeCountTokens definition
rg -n "func.*ClaudeCountTokens" --type=go

Repository: QuantumNous/new-api

Length of output: 177


🏁 Script executed:

# Read the ClaudeCountTokens controller implementation
cat -n controller/claude_count_tokens.go

Repository: QuantumNous/new-api

Length of output: 2246


Route placement contradicts the handler's documented design: move to a dedicated local-only group.

The ClaudeCountTokens handler's docstring (controller/claude_count_tokens.go:29–31) explicitly states it "is wired up under a dedicated router group that runs only TokenAuth," but the route is registered on httpRouter (router/relay-router.go:100), which inherits ModelRequestRateLimit from the parent relayV1Router (line 73) and applies Distribute() (line 85). This causes every request to:

  1. Hit ModelRequestRateLimit, subjecting a local token estimate to per-model RPM limits,
  2. Hit Distribute, attempting channel selection for an endpoint that intentionally bypasses the channel pipeline.

Create a sibling group under relayV1Router with only TokenAuth middleware (and optionally RouteTag for observability):

🔧 Proposed fix
 	relayV1Router := router.Group("/v1")
 	relayV1Router.Use(middleware.RouteTag("relay"))
 	relayV1Router.Use(middleware.SystemPerformanceCheck())
 	relayV1Router.Use(middleware.TokenAuth())
 	relayV1Router.Use(middleware.ModelRequestRateLimit())
 	{
 		// WebSocket 路由(统一到 Relay)
 		wsRouter := relayV1Router.Group("")
 		wsRouter.Use(middleware.Distribute())
 		wsRouter.GET("/realtime", func(c *gin.Context) {
 			controller.Relay(c, types.RelayFormatOpenAIRealtime)
 		})
 	}
+	{
+		// Local-only endpoints: no Distribute, no rate limit.
+		localRouter := relayV1Router.Group("")
+		localRouter.POST("/messages/count_tokens", controller.ClaudeCountTokens)
+	}
 	{
 		//http router
 		httpRouter := relayV1Router.Group("")
 		httpRouter.Use(middleware.Distribute())
 		httpRouter.POST("/messages", func(c *gin.Context) {
 			controller.Relay(c, types.RelayFormatClaude)
 		})
-		// Anthropic's /v1/messages/count_tokens — …
-		httpRouter.POST("/messages/count_tokens", controller.ClaudeCountTokens)

Also, replace the direct encoding/json.Unmarshal() call at controller/claude_count_tokens.go:46 with common.Unmarshal() per Go coding guidelines.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@router/relay-router.go` around lines 92 - 101, The ClaudeCountTokens route is
registered on httpRouter and therefore inherits relayV1Router middlewares
(ModelRequestRateLimit, Distribute) contrary to its docstring; create a sibling
router group off relayV1Router that only applies TokenAuth (and optionally
RouteTag) and register controller.ClaudeCountTokens on that group instead of
httpRouter so it bypasses ModelRequestRateLimit and Distribute; additionally, in
controller/claude_count_tokens.go replace the direct encoding/json.Unmarshal
call with common.Unmarshal to conform to project JSON helpers.

Comment thread service/claude_token_estimator.go Outdated
Comment thread service/claude_token_estimator.go Outdated
@daymade
daymade force-pushed the feature/count-tokens-local-estimate branch from f91a002 to 40cc2c4 Compare April 24, 2026 12:34

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

♻️ Duplicate comments (1)
service/claude_token_estimator.go (1)

3-7: ⚠️ Potential issue | 🟠 Major

Replace encoding/json with the project's wrapper.

Per coding guidelines, all JSON marshal/unmarshal operations must use common.Marshal() / common.Unmarshal()encoding/json must not be imported in business code. This was flagged in the prior review and remains unaddressed in this revision.

🔧 Proposed fix
 import (
-	"encoding/json"
-
+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/dto"
 )
@@
-		b, err := json.Marshal(m)
+		b, err := common.Marshal(m)
 		if err != nil {
 			continue
 		}
 		if _, isWebSearch := m["type"]; isWebSearch {
 			var ws dto.ClaudeWebSearchTool
-			if err := json.Unmarshal(b, &ws); err == nil && ws.Type != "" {
+			if err := common.Unmarshal(b, &ws); err == nil && ws.Type != "" {
 				normalized = append(normalized, &ws)
 			}
 			continue
 		}
 		var tool dto.Tool
-		if err := json.Unmarshal(b, &tool); err == nil && tool.Name != "" {
+		if err := common.Unmarshal(b, &tool); err == nil && tool.Name != "" {
 			normalized = append(normalized, &tool)
 		}

As per coding guidelines: "All JSON marshal/unmarshal operations MUST use wrapper functions from common/json.go: common.Marshal(), common.Unmarshal(), common.UnmarshalJsonStr(), common.DecodeJson(), common.GetJsonType(). Do NOT directly import or call encoding/json in business code."

Also applies to: 70-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator.go` around lines 3 - 7, The import of the
standard "encoding/json" package must be removed and any JSON operations in this
file (including the code referenced around lines 70-83) should be rewritten to
use the project's JSON wrapper functions in the common package (e.g.,
common.Marshal(), common.Unmarshal(), common.UnmarshalJsonStr(),
common.DecodeJson(), or common.GetJsonType()); update imports to include the
common package instead of "encoding/json" and replace direct calls to
json.Marshal/json.Unmarshal (or similar) in functions such as those handling
DTOs (referenced by the dto usage) with the appropriate common.* wrapper so the
file no longer imports or calls encoding/json directly.
🧹 Nitpick comments (1)
service/claude_token_estimator_test.go (1)

3-8: Use common.Unmarshal in tests for consistency with the project JSON policy.

The JSON-wrapper rule applies to all *.go files, not just production code. Please swap encoding/json for the project wrapper here as well so this file does not become a precedent for re-introducing direct encoding/json usage.

🔧 Proposed fix
 import (
-	"encoding/json"
 	"testing"

+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/dto"
 )
@@
-				if err := json.Unmarshal([]byte(tc.body), &req); err != nil {
+				if err := common.Unmarshal([]byte(tc.body), &req); err != nil {
 					t.Fatalf("unmarshal: %v", err)
 				}

As per coding guidelines: "All JSON marshal/unmarshal operations MUST use wrapper functions from common/json.go ... Do NOT directly import or call encoding/json in business code."

Also applies to: 70-76

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator_test.go` around lines 3 - 8, The test imports
encoding/json directly and calls json.Unmarshal (lines ~70-76); replace that
import with the project JSON wrapper (import common from
"github.com/QuantumNous/new-api/common") and use common.Unmarshal in place of
json.Unmarshal in claude_token_estimator_test.go so tests follow the project's
JSON wrapper policy and remove the direct encoding/json usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@service/claude_token_estimator.go`:
- Around line 3-7: The import of the standard "encoding/json" package must be
removed and any JSON operations in this file (including the code referenced
around lines 70-83) should be rewritten to use the project's JSON wrapper
functions in the common package (e.g., common.Marshal(), common.Unmarshal(),
common.UnmarshalJsonStr(), common.DecodeJson(), or common.GetJsonType()); update
imports to include the common package instead of "encoding/json" and replace
direct calls to json.Marshal/json.Unmarshal (or similar) in functions such as
those handling DTOs (referenced by the dto usage) with the appropriate common.*
wrapper so the file no longer imports or calls encoding/json directly.

---

Nitpick comments:
In `@service/claude_token_estimator_test.go`:
- Around line 3-8: The test imports encoding/json directly and calls
json.Unmarshal (lines ~70-76); replace that import with the project JSON wrapper
(import common from "github.com/QuantumNous/new-api/common") and use
common.Unmarshal in place of json.Unmarshal in claude_token_estimator_test.go so
tests follow the project's JSON wrapper policy and remove the direct
encoding/json usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8b982c07-be9d-4131-978b-6af54f4927a6

📥 Commits

Reviewing files that changed from the base of the PR and between f91a002 and 40cc2c4.

📒 Files selected for processing (4)
  • controller/claude_count_tokens.go
  • router/relay-router.go
  • service/claude_token_estimator.go
  • service/claude_token_estimator_test.go

@6639835 6639835 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

感觉不是最优解

@seefs001 seefs001 self-assigned this Apr 24, 2026
@daymade

daymade commented Apr 25, 2026

Copy link
Copy Markdown
Author

Round 3 已处理 CodeRabbit Round 2 的 encoding/json duplicate(commit 5dfd360):

  • service/claude_token_estimator.go:3 处 json.Marshal / json.Unmarshalcommon.Marshal / common.Unmarshal
  • service/claude_token_estimator_test.go:1 处同步

Round 2 已修的几条复核(相关 reviewer 可略过):

  • Router 挂在 relayV1Router 上(router/relay-router.go:82),bypass Distribute(),注释明说
  • Controller 用 common.UnmarshalBodyReusable,body 可重读给下游 + 隐式 size 防御
  • tool_use / tool_result 的 char 抽取复用 ClaudeRequest.GetTokenCountMeta()dto/claude.go:306-318),通过 common.Marshal(media.Input) / common.Marshal(media.Content) 展开,CLI 事故场景(53KB tool schema body)不会 undercount

所有 15 个单测仍然全过:

```
go test ./service/ -run 'TestEstimateClaude|TestNormalizeRequestTools' -v
```

Image token 精算 + docstring coverage 如需要可另开 follow-up issue。

@daymade

daymade commented Apr 25, 2026

Copy link
Copy Markdown
Author

@6639835 谢谢反馈。评估过 7 种替代方案,列一下结论供参考:

方案 为什么不优于本 PR
A. 透传上游官方 count_tokens 第三方代理(Qiniu / StepFun / Kimi / OpenRouter)99% 自己不实现,即便透传也要占上游 RPM
B. Middleware 拦截 max_tokens=1 伪请求 污染 /v1/messages 语义,合法 max_tokens=1(canary probe / token 价格校验)会被误杀;SDK 版本变化时脆弱
C. 真 tokenizer 库(tiktoken-go / claude-tokenizer-go) Anthropic 2026 起不再公开 tokenizer,社区逆向库精度 80-95%,license 存疑;count_tokens 本就是 estimate,没必要为 ±5% 引入维护债
D. 返 501 Not Implemented 实测 SDK 对 404/501 同一 fallback,无效
E. 限制 SDK retry 次数 治标不治本,首轮 250 个伪请求仍会发
F. Two-tier pass-through + fallback 需要 channel 声明 capability 字段;目前 0 channel 支持,纯 dead code
G. 请求指纹缓存(SHA256(system+tools) + 5s TTL) 与本 PR 正交的性能增量,可作 follow-up issue,不是替代

count_tokens 按 Anthropic spec 本就是 estimate,SDK 拿它决定是否触发 context 压缩,不是精确计费,±20-30% 偏差是业内共识。

至于 "SDK 会不会 cache 上游不支持 endpoint、永远走 max_tokens=1 fallback" 这种担忧——我验证过 Claude Code CLI 每次调用 count_tokens 都是独立的,失败只返回 null,不设任何全局状态。所以本 PR 的本地估算覆盖 100% 事故场景,已退化 session 也会重新 probe。

如果你有具体的其他方向,欢迎补充——尤其是:(1) 精度提升对哪个实际调用链有影响?(2) 外部 tokenizer 跟版本漂移怎么维护?

@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

🤖 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/claude_token_estimator.go`:
- Around line 73-83: The current heuristic treats any tool JSON with a top-level
"type" key as a ClaudeWebSearchTool which misclassifies many Anthropic tools;
update the detection in the block that reads m["type"] (in
claude_token_estimator.go) to read the type value as a string and only route to
ClaudeWebSearchTool (ws) when the type value indicates web-search (e.g.,
strings.HasPrefix(typeVal, "web_search") or exact "web_search"); otherwise allow
the payload to fall through to the dto.Tool path (tool) so ProcessTools /
CombineText see the full tool structure (Description, InputSchema, etc.).
Optionally mention in a comment near this logic that a whitelist of web-search
prefixes or checking for web-search-specific fields like UserLocation is
preferred for future robustness.
🪄 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: 069ae5fc-6432-42b6-9af5-868ccdae9b1e

📥 Commits

Reviewing files that changed from the base of the PR and between 40cc2c4 and 5dfd360.

📒 Files selected for processing (2)
  • service/claude_token_estimator.go
  • service/claude_token_estimator_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • service/claude_token_estimator_test.go

Comment thread service/claude_token_estimator.go Outdated

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

🧹 Nitpick comments (1)
service/claude_token_estimator.go (1)

87-99: Minor: malformed web_search_* tools are silently dropped.

When a tool entry has type starting with "web_search" but common.Unmarshal into ClaudeWebSearchTool fails (or produces an empty Type), the continue on line 93 prevents fallthrough to the dto.Tool path. That means a payload that looks like a web-search tool but is malformed contributes 0 chars to the estimate, while a non-web-search server tool with a similar issue would also be skipped (via the empty-Name guard at line 101). This is consistent and fine for an estimate endpoint, but worth a one-line comment so future readers don't try to "fix" the unconditional continue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/claude_token_estimator.go` around lines 87 - 99, The block that
checks m["type"] and unmarshals into dto.ClaudeWebSearchTool (using
common.Unmarshal into ws, then normalized = append(...), and the unconditional
continue) can silently drop malformed web_search_* entries; add a one-line
comment immediately above the continue explaining that failing/untyped
web_search_* tools are intentionally not falling through to the dto.Tool path
(so they contribute 0 chars) to prevent future maintainers from removing the
continue thinking it's a bug—reference the variables/type checks
(typeVal/typeStr), the common.Unmarshal into ws, the continue, and the fallback
dto.Tool/ProcessTools behavior in the comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@service/claude_token_estimator.go`:
- Around line 87-99: The block that checks m["type"] and unmarshals into
dto.ClaudeWebSearchTool (using common.Unmarshal into ws, then normalized =
append(...), and the unconditional continue) can silently drop malformed
web_search_* entries; add a one-line comment immediately above the continue
explaining that failing/untyped web_search_* tools are intentionally not falling
through to the dto.Tool path (so they contribute 0 chars) to prevent future
maintainers from removing the continue thinking it's a bug—reference the
variables/type checks (typeVal/typeStr), the common.Unmarshal into ws, the
continue, and the fallback dto.Tool/ProcessTools behavior in the comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d79ac81-3f79-40b3-a29a-3b0b5beb7719

📥 Commits

Reviewing files that changed from the base of the PR and between 5dfd360 and 5a9421f.

📒 Files selected for processing (2)
  • service/claude_token_estimator.go
  • service/claude_token_estimator_test.go

@daymade
daymade force-pushed the feature/count-tokens-local-estimate branch from 5a9421f to 7a65744 Compare May 12, 2026 23:28
@B1F030

B1F030 commented May 14, 2026

Copy link
Copy Markdown

非常好 PR!我们也急需这个功能,希望可以尽快合并😊

@SilentFlower

Copy link
Copy Markdown

现在是不是 /v1/messages/count_tokens现在都不支持?
然后我发现了用cc有个降级逻辑,没有count_tokens就导致降级成了60多个非流请求。然后打爆上游的接口429了。

@B1F030

B1F030 commented Jun 15, 2026

Copy link
Copy Markdown

现在是不是 /v1/messages/count_tokens现在都不支持? 然后我发现了用cc有个降级逻辑,没有count_tokens就导致降级成了60多个非流请求。然后打爆上游的接口429了。

我的分支 已经实现了这个接口,不过目前仅限 AWS bedrock 渠道,你可以试试,直接基于这份代码构建 docker 镜像就行了

@SilentFlower

Copy link
Copy Markdown

现在是不是 /v1/messages/count_tokens现在都不支持? 然后我发现了用cc有个降级逻辑,没有count_tokens就导致降级成了60多个非流请求。然后打爆上游的接口429了。

我的分支 已经实现了这个接口,不过目前仅限 AWS bedrock 渠道,你可以试试,直接基于这份代码构建 docker 镜像就行了

我也写了,不过是透传上游。最近一直在排查相关非流请求循环的BUG,最后发现问题在这

daymade and others added 3 commits July 17, 2026 04:31
Implements Anthropic's POST /v1/messages/count_tokens endpoint
(https://docs.claude.com/en/api/messages-count-tokens) by estimating
input_tokens locally instead of forwarding to any upstream channel.

Why
---
The Anthropic JS SDK and Claude CLI poll this endpoint before each
chat to size the context window. When new-api returns 404 (which it
does today, since the route is unregistered), the SDK falls back to
sending fake max_tokens=1 messages with the entire tool schema
attached. We observed bursts of 250 RPM of these probes from a single
Claude Code Desktop session, exhausting the upstream provider's RPM
quota and starving real traffic on the same channel for ~2 minutes.

Returning a fast 200 here from new-api itself fixes the failure mode
at the source: the SDK is satisfied, no upstream RPM is consumed,
no billing entry is created.

Closes QuantumNous#1694
Closes QuantumNous#2847
Closes QuantumNous#1979

Approach
--------
- Mounted on relayV1Router directly, NOT under httpRouter — the
  former gives us TokenAuth + RouteTag + SystemPerformanceCheck +
  ModelRequestRateLimit; the latter would also pull in
  middleware.Distribute() which selects a channel and starts the
  PreConsume flow. Anthropic defines count_tokens as token-counting
  only with no quota impact, so the bypass is intentional.

- Estimation reuses the project's existing pieces:
  * ClaudeRequest.GetTokenCountMeta() for the canonical flattening
    of system / messages (text, tool_use, tool_result) / tools
  * EstimateTokenByModel() for the Claude-tuned tokenizer that
    /v1/messages itself bills against
  This keeps the count consistent with what /v1/messages would
  report on the same body, so callers can reason about both numbers
  together.

- One small helper, normalizeRequestTools(), converts raw
  map[string]any tool entries (what json.Unmarshal produces when the
  field is `any`) into the typed *dto.Tool / *dto.ClaudeWebSearchTool
  values that dto.ProcessTools requires. Without this every tool
  entry on a count_tokens request would be silently dropped on the
  `default: continue` arm of ProcessTools (dto/claude.go:439-442) and
  the tools text — usually 80%+ of a CLI probe body — would not be
  counted.

- Body parsing uses common.UnmarshalBodyReusable so the request body
  remains available to any logging/observability that runs after
  this handler.

- Image tokens are intentionally not added: getImageToken() needs a
  RelayInfo + http.Request context, which this route doesn't have.
  The CLI probe (the failure mode this PR exists to mitigate) never
  carries images. Documented in the estimator's godoc.

How vs. PR QuantumNous#2384
----------------
QuantumNous#2384 took the deeper path: a new RelayFormat enum value + adaptor
changes + middleware.distributor changes + autoban bypass. That PR
was closed without merge. This PR keeps the surface area small —
one new route, one controller, one estimator, no enum/adaptor
changes, no impact on any code path that doesn't hit the new route.

Files
-----
- router/relay-router.go              + 9 lines  (one route registration)
- controller/claude_count_tokens.go   new       (~35 lines incl. doc)
- service/claude_token_estimator.go   new       (~90 lines)
- service/claude_token_estimator_test.go  new   (12 cases)

Tests
-----
$ go test ./service/ -run 'TestEstimateClaude|TestNormalizeRequestTools' -v
=== RUN   TestEstimateClaudeInputTokens
--- PASS  (10 cases: empty / short user / system+user / CLI probe /
          system as array / CJK / tool_use / tool_result / web-search /
          pre-typed tools)
=== RUN   TestEstimateClaudeInputTokens_NilSafety
--- PASS  (2 cases: nil request / zero-value request)
=== RUN   TestNormalizeRequestTools
--- PASS  (3 cases: nil / non-array / malformed dropped)

Manual verification of the failure mode (sample CLI probe body —
trivial message + Bash tool schema, ~200 bytes):
  EstimateClaudeInputTokens = 43 input tokens
  Endpoint returns: 200 OK {"input_tokens": 43}
  Claude CLI accepts and stops sending fake max_tokens=1 fallback.
…ideline

CodeRabbit round 2 flagged service/claude_token_estimator.go +
claude_token_estimator_test.go as still importing encoding/json after
round 1. Replaces all three json.Marshal/Unmarshal calls in
normalizeRequestTools + one json.Unmarshal call in the table-driven
test body with the project's common.Marshal / common.Unmarshal wrappers.

All 15 estimator/normalize test cases still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit round 3 flagged that normalizeRequestTools was routing any
tool JSON with a top-level "type" field into ClaudeWebSearchTool. But
Anthropic's other built-in server tools (computer_*, bash_*,
text_editor_*, code_execution_*, mcp_*) also carry a top-level "type"
— their shape is different from ClaudeWebSearchTool, so computer_use
and friends were being unmarshalled into the web-search struct. After
that, ProcessTools only saw Name + UserLocation and dropped the rest
of the tool payload from the estimate.

Fix: narrow the web-search branch to type values whose string begins
with "web_search" (matches current web_search_20250305 and any future
version suffix). Unknown "type" values fall through to the generic
dto.Tool path, so ProcessTools still sees Name + Description +
InputSchema.

Caveat: dto.Tool does not model every server-tool-specific field
(display_width_px on computer_use, etc.), so exotic server tools can
still be undercounted. That is an upstream schema gap outside this
PR's scope — this fix just stops the regression from misrouting them
all into ClaudeWebSearchTool.

Two new test cases lock in the behavior:
- computer_20250124 routes to *dto.Tool (not *ClaudeWebSearchTool)
- web_search_20250604 (future version suffix) still routes to
  *ClaudeWebSearchTool via the prefix rule

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@daymade
daymade force-pushed the feature/count-tokens-local-estimate branch from 7a65744 to cb1d4c5 Compare July 18, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants