Skip to content

feat(usage): estimate tokens locally when the upstream reports no usage#794

Merged
jarvis9443 merged 5 commits into
mainfrom
feat/1074-usage-estimation
Jul 21, 2026
Merged

feat(usage): estimate tokens locally when the upstream reports no usage#794
jarvis9443 merged 5 commits into
mainfrom
feat/1074-usage-estimation

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

When an upstream response carries no usage block, every token counter on the usage event records a silent zero — the request is invisible to billing, the budget ledger, TPM/TPD accounting, and the dashboard. /v1/completions went further and dropped the event entirely. The gateway already injects stream_options.include_usage on the upstream leg (#790), which narrows the surface to: OpenAI-compatible relays that ignore it, clients that set stream_options without include_usage, client disconnects before the terminal usage chunk, mid-stream upstream errors, and protocols/backends that simply don't report usage.

What this PR does

Local token-estimation fallback (crates/aisix-proxy/src/token_estimate.rs, tiktoken-rs, BPE ranks bundled — no runtime network):

  • Prompt counted from the captured request per the de-facto OpenAI message counting scheme (openai-cookbook "How to count tokens"): 3 tokens per message + role/content/name text, +3 reply priming, tool definitions rendered to the TypeScript-namespace form (+9, −4 with a system message), tool_choice rules included.
  • Completion counted from the accumulated generated output (content + reasoning + tool-call name/arguments) as plain text, no overhead.
  • Per-field or semantics: an upstream value wins whenever non-zero; estimation only fills zeros. Partial usage (e.g. Anthropic message_start input followed by a disconnect) estimates only the missing side.
  • Encoding selection mirrors tiktoken's model map (gpt-4o/o1/… → o200k_base; gpt-4/gpt-3.5cl100k_base), with cl100k_base as the fallback for unknown/non-OpenAI models. Counts for models with proprietary tokenizers (Claude, Gemini, …) are approximations by design — hence the flag below.
  • UsageEvent.usage_estimated: bool (additive, skip_serializing_if false): set whenever any counter was locally filled. Flows to cp-api /dp/telemetry and to every exporter via the SinkRecord flatten. Paired CP PR surfaces it as an "estimated" badge on the Logs page.

Estimation feeds telemetry, TPM post-stream accounting, and metrics ONLY. The client-visible response body and SSE stream are never rewritten — no fabricated usage chunk, no synthesised usage object (pinned by e2e).

Whole handler family, streaming + non-streaming (per the lockstep rule):

surface wired paths
/v1/chat/completions build_sse_stream Drop guard (both call sites incl. ensemble judge), non-streaming, cache-hit replay, billed-then-blocked UpstreamCharge
/v1/messages native passthrough Drop guard, bridged stream Drop guard, native + bridged non-streaming
/v1/responses verbatim passthrough closure, buffered (hold-back) branch, bridged stream Drop guard, direct + bridged non-streaming
/v1/completions non-streaming (a usage-less 200 previously emitted no event at all; now emits an estimated one)
/v1/embeddings prompt falls back to total_tokens first (upstream-authoritative, fixes the total-only-relay case), then to a local count

Deliberately not wired: audio / images / rerank — their billing units (seconds, tiles, search units) are not text-tokenizable; a text estimate would be wrong rather than approximate.

Interplay preserved:

  • The feat(messages): route /v1/messages/count_tokens to Anthropic upstream (#418) #419 zero-delivered gate still wins: a disconnect before any chunk crossed the wire bills no completion tokens (estimated or not); the prompt still fills ("prompts always billed").
  • The Anthropic message_start output_tokens placeholder floor is treated as missing when no message_delta arrived, so an aborted stream estimates from the delivered text instead of recording the placeholder (same normalization the reference proxy applies to its lone-cursor case).
  • Streaming output accumulates into a new always-on, bounded (1 MiB) buffer only where no suitable buffer existed; the /v1/responses SseTextCapture and the messages-passthrough guardrail buffer are reused.

Reference implementations (design comparison)

  • LiteLLM (baseline, per repo rule): on stream rebuild, prompt = provider or token_counter(messages), completion = provider or token_counter(text=joined_content) — the exact or-semantics and counting scheme this PR ports (litellm_core_utils/streaming_chunk_builder_utils.py::calculate_usage, token_counter.py). Divergences, both deliberate: (1) LiteLLM has no estimated-vs-reported flagusage_estimated exists because the acceptance criteria require marking estimated data; (2) for Claude models LiteLLM inconsistently mixes a bundled legacy tokenizer (claude-2/4) with cl100k_base (claude-3); we use its offline-mode behavior (cl100k_base for all non-OpenAI models) uniformly.
  • A major API-gateway vendor's AI plugin estimates with coarse heuristics (word-count × 1.8 for prompts, chars÷4 for completions) and does not mark estimates.
  • Two CNCF-ecosystem AI gateways inject include_usage (as we already do, feat: Tool call support for ensemble models #790) but record nothing when usage is absent, and lose the record entirely on client disconnect.
  • No surveyed implementation covers the disconnect case or carries a provenance flag — both are required by the acceptance criteria here (计量记录不丢失 / 标识估算值), so this PR goes beyond the surveyed baselines on those two axes.

Behavior changes

  • Usage events that previously carried zeros (or were skipped, on /v1/completions and usage-less /v1/responses bodies) now carry estimated counts + usage_estimated: true. Wire-additive: cp-api ignores unknown fields until the paired CP PR lands (a brief window per the accepted cross-plane policy).
  • TPM/TPD accounting now sees estimated totals for usage-less upstreams (previously silently uncounted).
  • Four Rust tests pinning the old "skip the event / record zero" contract were rewritten to pin the new estimated-event contract (the old behavior is exactly what #1074 mandates changing).

Tests

  • Rust unit (all 647 pass): counting rules incl. the cookbook overhead scheme, tool-definition rendering (byte-exact vs the baseline's renderer), or-semantics fill, encoding selection, Drop-guard fill × feat(messages): route /v1/messages/count_tokens to Anthropic upstream (#418) #419 gate interplay, upstream-wins.
  • E2E (vitest, new usage-estimation-1074-e2e.test.ts, 5 scenarios, all pass): chat SSE without usage (exact metric deltas + SLS usage_estimated + no fabricated client usage chunk), non-streaming without usage (client body untouched), client abort mid-stream, /v1/messages with no usage anywhere, /v1/responses aborted before the terminal event. Plus a 10-file regression sweep over the existing usage/TPM/streaming suites (12 tests, green).
  • cargo clippy --workspace --all-targets and cargo fmt clean.

30s-SSE note: the 30-second acceptance scenario is time-, not logic-different from the paced-stream e2e (the Drop-guard emission is duration-independent); the <5% accuracy criterion holds by construction for cl100k/o200k-family models (same BPE as the provider) and is an approximation for proprietary tokenizers — which is what the flag communicates. PoC-scale measurement on the customer's real models stays tracked on the issue.

Fixes api7/AISIX-Cloud#1074

Summary by CodeRabbit

  • New Features

    • Added a usage_estimated telemetry flag and local token-usage estimation fallback for Chat Completions, Messages, Responses, and Embeddings when upstream usage is missing or zero.
    • Supports streaming, non-streaming, cached, and partially delivered/aborted requests while keeping client-visible response bodies unchanged.
  • Bug Fixes

    • Improved quota reservation and token metering consistency by using estimated prompt/completion totals only when upstream counters are unusable.
  • Tests

    • Added end-to-end coverage for AISIX-Cloud#1074 across the affected APIs.

When an upstream response - streaming or not - carries no usage block
(an OpenAI-compatible relay that ignores the injected
stream_options.include_usage, a client disconnect before the terminal
usage chunk, an upstream error mid-stream), the token counters were
recorded as silent zeros: invisible to billing, TPM accounting, and the
dashboard ledger.

Add a local token-estimation fallback (crates/aisix-proxy/src/
token_estimate.rs, tiktoken-rs): prompt counted from the captured
request per the OpenAI cookbook message scheme, completion from the
accumulated output text as plain text. Upstream values always win when
non-zero (per-field or-semantics); estimation fills telemetry, TPM
post-stream accounting, and metrics ONLY - the client-visible body and
SSE stream are never rewritten. Every event with an estimated component
sets the new additive UsageEvent.usage_estimated flag so consumers can
tell locally-counted numbers from provider-billed ones.

Wired across the whole handler family, streaming and non-streaming:
/v1/chat/completions (incl. cache-hit replay, billed-then-blocked
charge, ensemble judge), /v1/messages (native passthrough + bridged),
/v1/responses (verbatim, buffered, + bridged), /v1/completions (which
previously dropped the event entirely on a usage-less 200), and
/v1/embeddings (prompt falls back to total_tokens first - upstream-
authoritative - then to a local count). The chat #419 zero-delivered
gate still wins: nothing delivered means no estimated completion
tokens, while the prompt stays billed. The Anthropic message_start
output_tokens placeholder floor is treated as missing so an aborted
stream estimates from the delivered text instead of recording the
placeholder.

audio/images/rerank are intentionally not wired: their billing units
(image tiles, audio seconds, search units) are not text-tokenizable, so
a text estimate would be wrong rather than approximate.

Fixes api7/AISIX-Cloud#1074
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e4dd437-5e36-463c-9c2d-21b640ff5e8c

📥 Commits

Reviewing files that changed from the base of the PR and between ece3094 and 7462659.

📒 Files selected for processing (4)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/responses_bridge.rs
  • crates/aisix-proxy/src/token_estimate.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/aisix-proxy/src/responses_bridge.rs
  • crates/aisix-proxy/src/token_estimate.rs
  • crates/aisix-proxy/src/chat.rs

📝 Walkthrough

Walkthrough

The proxy adds local token estimation for missing upstream usage across chat, completions, embeddings, messages, and responses. Estimated counters are used for telemetry and quota accounting while response bodies remain unchanged, with usage_estimated identifying locally filled values.

Changes

Usage estimation fallback

Layer / File(s) Summary
Estimator and telemetry contract
Cargo.toml, crates/aisix-obs/src/usage.rs, crates/aisix-proxy/src/token_estimate.rs, crates/aisix-proxy/src/lib.rs
Adds tokenizer-backed counting, bounded encoding, protocol-specific request handling, missing-counter filling, and the serialized usage_estimated field.
Chat and completions fallback
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs
Estimates missing usage for cached, streaming, blocked, ensemble, and legacy completion paths while preserving upstream counters.
Embedding usage derivation
crates/aisix-proxy/src/embeddings.rs
Uses upstream prompt or total counters when available and estimates request tokens only when upstream usage is absent.
Messages streaming and bridge fallback
crates/aisix-proxy/src/messages.rs
Adds estimation for Anthropic passthrough and cross-provider flows, including bounded output accumulation and stream-drop handling.
Responses passthrough and bridge fallback
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Adds estimation to buffered, live-forward, non-streaming, and bridged response flows.
End-to-end telemetry validation
tests/e2e/src/cases/usage-estimation-1074-e2e.test.ts
Tests missing usage across streaming, non-streaming, aborted requests, messages, and responses while checking passthrough, metrics, and SLS flags.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Proxy
  participant Upstream
  participant Estimator
  participant Telemetry
  Client->>Proxy: send protocol request
  Proxy->>Upstream: forward request
  Upstream-->>Proxy: response or stream with missing usage
  Proxy->>Estimator: count request and accumulated output
  Estimator-->>Proxy: fill missing token counters
  Proxy->>Telemetry: emit UsageEvent with usage_estimated
  Proxy-->>Client: return original response or stream
Loading

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Two chat E2E cases warm readiness with an identical probe but never baseline output tokens, so a completion-token regression could still pass. Capture output_before in those chat tests (like the messages test does) and assert deltas; add timeout/upstream-error estimation coverage if intended.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: local token estimation when upstream usage is missing.
Linked Issues check ✅ Passed The PR implements SSE and missing-usage token estimation, preserves upstream values, and marks estimated records as required by #1074.
Out of Scope Changes check ✅ Passed The changes stay focused on token estimation, telemetry, and supporting tests without introducing unrelated scope.
Security Check ✅ Passed No new secret exposure, auth bypass, or ownership issues found; usage_estimated only affects telemetry and isn’t surfaced in client responses.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1074-usage-estimation

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

…messages est buffer (audit)

Independent-audit findings on #794, all fixed:

HIGH-1: the tokenizer's regex engine fails (and its vendored wrapper
panics) on ~1 MiB unbroken runs, and is superlinear on long single
pieces - reachable by any caller via a large body + stream abort, and
a panic inside CompleteOnDrop during an unwind aborts the process.
enc() now encodes in 64 KiB slices under catch_unwind with a
~4-bytes/token fallback, and caps exact counting at 1 MiB per call
(tail extrapolates) so an adversarial body cannot pin a worker inside
a Drop guard. Regression tests included (panic repro + budget tail).

MEDIUM-1: the /v1/messages passthrough estimator read the guardrail
scan buffer, which (a) is mem::take'n by the end-of-stream scan when a
guardrail is attached without content capture - estimation silently
read "" - and (b) pads every frame with a newline, inflating
per-frame counts 10-30%, and (c) missed thinking deltas. Dedicated
est_output_text accumulator (raw concatenation, thinking included,
capped), matching every other streaming surface; the messages e2e
assertion tightens to the exact expected count.

MEDIUM-2: test gaps - added AnthropicStreamGuard floor-normalization
three-state tests (estimate supersedes floor / empty estimate keeps
floor / message_delta wins unflagged), fill_missing_anthropic_metrics
+ anthropic_estimation_output_text unit tests, and two embeddings
wiremock tests (total-only fallback unflagged; absent usage estimated
+ flagged).

LOW: cache-hit estimation now uses estimation_output_text (tool
calls/reasoning included); the abort e2e reads until the first content
delta and pins the estimate to [1, full] instead of a vacuous >= 0.
Non-streaming ensemble sub-call wiring is deferred to #796 (needs
ensemble.rs to expose the judge request + member texts).
@jarvis9443

Copy link
Copy Markdown
Contributor Author

Independent audit ran per the repo's merge gate; all findings resolved in ece3094:

  • HIGH-1 (fixed) — the tokenizer's regex engine fails on ~1 MiB unbroken runs (vendored wrapper panics; reproduced) and is superlinear on long single pieces, reachable via a large body + stream abort — and a panic inside CompleteOnDrop during an unwind would abort the process. enc() now slices at 64 KiB under catch_unwind with a ~4 B/token fallback and caps exact counting at 1 MiB/call (tail extrapolates). Regression tests: the panic repro + budget-tail growth.
  • MEDIUM-1 (fixed) — the /v1/messages passthrough estimator read the guardrail scan buffer: mem::take'n by the end-of-stream scan (estimation read "" whenever a guardrail was attached without content capture) and newline-padded per frame (+10-30% inflation), thinking deltas missed. Now a dedicated est_output_text accumulator, same pattern as every other surface; the messages e2e assertion tightened to the exact count.
  • MEDIUM-2 (fixed) — added the floor-normalization three-state guard tests, non-streaming messages fill/extractor unit tests, and two embeddings wiremock tests (total-only fallback → unflagged; absent usage → estimated + flagged).
  • LOW — cache-hit estimation now reads estimation_output_text (tool calls/reasoning included); the abort e2e now reads until the first content delta and pins the estimate to [1, full] (was a vacuous >= 0); the non-streaming ensemble sub-call wiring is explicitly deferred to usage estimation: wire the non-streaming ensemble sub-call events (panel + judge) #796 (needs ensemble.rs to expose the judge request + member texts); the per-stream request clone noted as an acceptable bounded cost (an Arc-ing of PromptInput is follow-up material).

Post-fix: 653 unit tests, the 5-scenario estimation e2e, and the anthropic/messages/SLS regression suites all green locally; fmt + clippy clean.

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

Actionable comments posted: 1

🤖 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 `@crates/aisix-proxy/src/responses_bridge.rs`:
- Around line 1042-1071: Update the accumulator block in the streaming response
handling to enforce OUTPUT_ACCUMULATION_CAP before every individual append,
matching SseTextCapture::observe in responses.rs. Re-check the remaining
capacity before pushing content, reasoning_content, tool-call names, and
arguments so each append is bounded and oversized chunks cannot make
comp.est_output_text exceed the cap.
🪄 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: 52dba35d-b4b1-428b-8930-641c3f9d9e7a

📥 Commits

Reviewing files that changed from the base of the PR and between 9e2d9fa and ece3094.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • Cargo.toml
  • crates/aisix-obs/src/usage.rs
  • crates/aisix-proxy/Cargo.toml
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/responses_bridge.rs
  • crates/aisix-proxy/src/token_estimate.rs
  • tests/e2e/src/cases/usage-estimation-1074-e2e.test.ts

Comment thread crates/aisix-proxy/src/responses_bridge.rs
…iew)

The per-chunk cap check let a single oversized chunk overshoot
OUTPUT_ACCUMULATION_CAP by the sum of its fields (bounded by the frame
size, but still past the documented cap). Replace the check-once-push-
many pattern at all four accumulation sites (chat, bridged messages,
messages passthrough, bridged responses) with a shared push_capped
helper that enforces the cap per push and truncates the final fragment
at a char boundary. Unit test pins the hard-cap + boundary behavior.
@jarvis9443
jarvis9443 merged commit d14c454 into main Jul 21, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the feat/1074-usage-estimation branch July 21, 2026 13:10
nxtreaming pushed a commit to nxtreaming/aisix that referenced this pull request Jul 23, 2026
…-usage backends (api7#806)

Follow-up to api7#794 (AISIX-Cloud#1074). Ensemble sub-call usage events (each panel member + the judge) bypassed the #1074 token-estimation fallback and recorded silent zeros on a no-usage backend; api7#794 had wired only the streaming judge.

Carry the per-member answer text and the judge's synthesis request out of ensemble.rs, and route every sub-call emit through one shared helper (estimate_subcall_tokens) keyed off each sub-call's resolved upstream model — api7#794 or-semantics, telemetry-only. Closes the streaming panel-member sibling gap too.

Fixes api7#796
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