Skip to content

feat(messages): route /v1/messages/count_tokens to Anthropic upstream (#418)#419

Merged
moonming merged 3 commits into
mainfrom
fix/issue-418-count-tokens-route
May 27, 2026
Merged

feat(messages): route /v1/messages/count_tokens to Anthropic upstream (#418)#419
moonming merged 3 commits into
mainfrom
fix/issue-418-count-tokens-route

Conversation

@janiussyafiq

@janiussyafiq janiussyafiq commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

/v1/messages/count_tokens returned a bare 404 through the DP because the route was never registered — count_tokens appeared nowhere in the codebase. The Anthropic SDK's messages.countTokens(...) (used by Claude Code and most Anthropic-SDK apps to size a prompt before a paid /v1/messages call) threw 404 (no body). Fixes #418.

This registers /v1/messages/count_tokens as a passthrough to the Anthropic upstream, the sibling of the already-working /v1/messages route.

What changed

  • count_tokens.rs (new) — handler mirroring the rerank.rs passthrough structure. Reuses the existing dispatch helpers (resolve_provider_key, require_secret, require_upstream_model, resolve_base_url, build_v1_url), sends the x-api-key + anthropic-version auth shape, rewrites the model alias to the upstream id, and forwards the {"input_tokens": <int>} body verbatim.
  • lib.rs — registers the route; adds the path to inbound_protocol_for_endpoint (anthropic label) and is_anthropic_path (so body-limit 413s render the Anthropic envelope).
  • error.rs — extracts proxy_error_from_json_rejection (malformed-JSON vs 413-cap vs transport-error discrimination) into a shared helper.
  • messages.rs — rewired to that helper (removing a ~50-line inline duplicate); ANTHROPIC_VERSION made pub(crate) and reused.

Scope decision

Anthropic-backed models only — the issue's scope and what the Anthropic SDK targets. A non-Anthropic model returns a clean 400 (count_tokens has no OpenAI/Gemini/DeepSeek upstream equivalent) rather than a misleading 404, and without fabricating a local estimate. Errors use the Anthropic-shape envelope (#336).

Reference implementations / upstream spec (per CLAUDE.md §7)

Testing

  • 5 unit tests (count_tokens.rs): 401 (Anthropic envelope), 404 unknown model, 403 forbidden, 400 non-Anthropic provider, happy-path passthrough asserting upstream URL / model rewrite / auth headers.
  • 2 e2e tests (anthropic-count-tokens-e2e.test.ts) against the real backend + etcd: 200 with input_tokens, correct upstream sub-route (/v1/messages/count_tokens, not /v1/messages), model rewrite, x-api-key/anthropic-version forwarded (no Authorization); plus unknown-model 404 with the Anthropic error envelope.
  • Full proxy suite 313 passed; sibling /v1/messages, tools-cross-provider, and body-edges e2e (the refactored paths) 6 passed; cargo fmt --check + clippy clean.

The original repro no longer reproduces: the route now returns 200 instead of a bare 404.

Summary by CodeRabbit

  • New Features

    • Added POST /v1/messages/count_tokens (Anthropic-compatible) with authentication, access control, quota/rate limits, metrics, and access logs; forwards to upstream with model rewriting and Anthropic headers.
  • Bug Fixes

    • Unified JSON/body-limit error handling and Anthropic-shaped error envelopes; safe truncation of oversized/non‑UTF8 upstream error bodies; prevent operator headers from overwriting gateway auth header.
  • Tests

    • New end-to-end tests covering success, auth, unknown/forbidden models, provider mapping, header behavior, and error truncation.

Review Change Stack

…#418)

The Anthropic Messages count_tokens sub-route was unregistered, so the
DP returned a bare 404 before any auth/bridge logic ran. The Anthropic
SDK's `messages.countTokens(...)` — used by Claude Code and most
Anthropic-SDK apps to size prompts before a paid /v1/messages call —
threw `404 (no body)` through the gateway.

Register `/v1/messages/count_tokens` as a passthrough to the Anthropic
upstream's `…/v1/messages/count_tokens`, reusing the /v1/messages
dispatch helpers (model-alias resolution, x-api-key + anthropic-version
auth, build_v1_url) and forwarding the `{input_tokens}` body verbatim.
Non-Anthropic models are rejected with a 400 (no upstream equivalent)
rather than a misleading 404. Errors use the Anthropic-shape envelope
(#336).

Shared the JSON-rejection discrimination (malformed JSON vs 413 cap vs
transport error) into error::proxy_error_from_json_rejection so the two
Anthropic-protocol handlers stay in lockstep, and reused
messages::ANTHROPIC_VERSION across both.

Refs: Anthropic Count Message Tokens API; LiteLLM exposes the same
route and had the identical missing-route bug (BerriAI/litellm#15006).
@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: d99e90b2-849d-4bc0-ab62-8ee052b21ae6

📥 Commits

Reviewing files that changed from the base of the PR and between ca8ea83 and 2dfb9b9.

📒 Files selected for processing (1)
  • crates/aisix-proxy/src/count_tokens.rs

📝 Walkthrough

Walkthrough

Adds a new POST /v1/messages/count_tokens endpoint that acts as an Anthropic-only passthrough. Extracts shared JSON error handling into a reusable helper, updates the existing messages handler to use it, wires the endpoint into the router with Anthropic protocol classification, implements the full handler and dispatch with upstream forwarding and access logging, and includes unit and e2e tests.

Changes

count_tokens endpoint implementation

Layer / File(s) Summary
Shared Anthropic error handling
crates/aisix-proxy/src/error.rs, crates/aisix-proxy/src/messages.rs
Extracts proxy_error_from_json_rejection helper to classify axum JsonRejection failures into ProxyError variants. Updates messages() handler to use this shared helper, ensuring both /v1/messages and /v1/messages/count_tokens handle malformed JSON, request body read errors, and oversized payloads consistently.
Route registration and protocol wiring
crates/aisix-proxy/src/lib.rs
Declares the count_tokens module, mounts POST /v1/messages/count_tokens to the router, classifies it as Anthropic inbound protocol, and extends the request body limit enforcement so this endpoint receives Anthropic-shaped 413 error envelopes.
count_tokens handler and dispatch logic
crates/aisix-proxy/src/count_tokens.rs
Implements endpoint handler with request documentation, imports, authentication/body extraction, model extraction, and dispatch. Dispatch loads configured model, enforces access and quota/rate limits, rejects non-Anthropic providers with Anthropic-shaped 400 error, resolves provider secret and upstream model ID, rewrites request model field, applies provider request.* overrides, forwards to upstream /messages/count_tokens with Anthropic bridge headers, handles upstream responses/errors (including UTF-8-safe truncation), updates cooldown/health, and emits access logs.
Unit and integration tests
crates/aisix-proxy/src/count_tokens.rs
Tests covering unauthenticated 401, unknown model 404, forbidden model 403, non-Anthropic provider 400, UTF-8-safe truncation on oversized upstream error bodies, provider override behaviors, and happy-path forwarding that returns {"input_tokens": ...} verbatim.
End-to-end integration tests
tests/e2e/src/cases/anthropic-count-tokens-e2e.test.ts
E2E test suite: starts mock Anthropic upstream, registers provider key and model alias, creates restricted API key, sends raw fetch request with Anthropic headers to /v1/messages/count_tokens, asserts 200 and input_tokens response, validates upstream forwarding (correct sub-route, model rewrite, header forwarding), and tests unknown model error shape.

🎯 3 (Moderate) | ⏱️ ~25 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread crates/aisix-proxy/src/count_tokens.rs Outdated
Comment thread crates/aisix-proxy/src/count_tokens.rs Outdated
…unt_tokens

Address review feedback on #418:

- count_tokens truncated upstream error bodies with `&message[..1024]`,
  which panics when byte 1024 splits a multibyte codepoint (non-ASCII
  upstream error body). Truncate on a char boundary instead. Regression
  test exercises a 3-byte char straddling byte 1024.
- Apply the ProviderKey `request.*` override pipeline (param_renames /
  param_constraints / default_body_fields / default_headers) on
  count_tokens, identically to /v1/messages — the two routes share the
  same Anthropic ProviderKey, so operator overrides (notably
  `anthropic-beta` via default_headers) must reach both. Headers are
  built via a HeaderMap so the #337 reserved-header blacklist protects
  x-api-key from operator override. Tests cover default_headers,
  param_renames, and the x-api-key protection.
@janiussyafiq

Copy link
Copy Markdown
Collaborator Author

Follow-up filed for the pre-existing copies of the UTF-8 truncation pattern in messages.rs / rerank.rs (out of scope here, fixed in this PR for count_tokens): #420

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/lib.rs:147

  • inbound_protocol_for_endpoint is fed from request.uri().path() (see record_in_flight_request), but it only treats the exact string /v1/messages as Anthropic. The codebase already accounts for /v1/messages/ being routed to the Anthropic handler (see the trailing-slash audit note + test around enforce_request_body_limit), so in-flight metrics for /v1/messages/ will be mislabeled as openai. Consider including the trailing-slash variant here (and keeping this in sync with the Anthropic-path detection used elsewhere).
fn inbound_protocol_for_endpoint(endpoint: &str) -> &'static str {
    if endpoint == "/v1/messages" || endpoint == "/v1/messages/count_tokens" {
        "anthropic"
    } else {
        "openai"
    }

Comment thread crates/aisix-proxy/src/count_tokens.rs Outdated
Match the /v1/messages handler: insert the response request-id header
only when it parses, instead of falling back to an empty value. The
request_id is internally generated (always valid ASCII), so this is a
consistency/defensiveness fix, not a live bug.
@moonming
moonming merged commit adbccf3 into main May 27, 2026
8 checks passed
moonming added a commit that referenced this pull request May 29, 2026
PR #436 audit raised MEDIUM-2 + LOW-1 (both real) plus a HIGH-1 that
was based on a factually incorrect premise (see PR comment). Acting
on the valid findings:

MEDIUM-2 — Unbounded frame buffer. The byte-level SSE parser buffers
chunks into a Vec<u8> and only drains on a blank-line terminator.
A malformed / hostile upstream that streams bytes WITHOUT a
terminator would grow the buffer unboundedly (per-request memory
exhaustion). chat.rs doesn't have this risk because it operates on
pre-framed ChatChunks, not raw bytes — so this is a genuine new
risk the byte-parser introduces. Added a 1 MiB ceiling
(MAX_SSE_FRAME_BUF_BYTES); real Anthropic frames are a few KB, so
the cap only trips on a non-conformant stream, where we drop the
buffer (skipping usage parse for that frame) rather than OOM. The
bytes still forward to the client verbatim.

LOW-1 — A `message_delta` with a `usage` object but a non-numeric
`output_tokens` silently left completion_tokens at the
message_start floor (often 1). Added a `tracing::debug!` so a
wire-shape drift is visible to operators.

Test (audit angle 8c): `sse_frame_parser_tolerates_streams_without_usage`
— an error-style stream (ping + error frames, no usage anywhere)
drains cleanly leaving the accumulator at zeros, no panic.

E2E (DP-level, honours the e2e-first discipline): new
`anthropic-streaming-usage-e2e.test.ts` drives a real streaming
/v1/messages through the DP binary against a mock Anthropic
streaming upstream (input_tokens=37 in message_start,
output_tokens=52 in message_delta), then scrapes the DP's /metrics
and asserts `aisix_llm_input_tokens_total` /
`aisix_llm_output_tokens_total` for /v1/messages are non-zero.
Pre-#245 those counters stayed at 0, so this fails red on a
regression. Verified locally against the aisix-e2e stack (passes
in 2.9s).

HIGH-1 (NOT actioned — incorrect premise): the audit claimed
`DeliveryCounter` / #419 don't exist and that the PR diverges from
chat.rs. Verified false: `grep -rn "DeliveryCounter" crates/`
returns chat.rs:2312 (the struct), :2296 (construction), and the
#419 cost-leak gate lives at chat.rs:2039-2045 with the
`delivered: Arc<AtomicU32>` field at :2010. The PR's "mirrors
chat.rs DeliveryCounter / #419 parity" framing is accurate;
removing the delivery gate would BREAK parity, not restore it.
@jarvis9443
jarvis9443 deleted the fix/issue-418-count-tokens-route branch June 25, 2026 06:26
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.

bug: /v1/messages/count_tokens returns 404 (route unregistered) through the DP (Anthropic SDK countTokens)

3 participants