Skip to content

fix(token_counter): release the GIL for HuggingFace counts and cap exact counting per string - #40186

Merged
mateo-berri merged 12 commits into
litellm_internal_stagingfrom
litellm_lit_5546_count_tokens_offload
Sep 10, 2026
Merged

mateo-berri merged 12 commits into
litellm_internal_stagingfrom
litellm_lit_5546_count_tokens_offload

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • A 600k-token local count on the Claude tokenizer stalls the gateway for up to 0.7 s: the count already ran on a worker thread, but the tokenizer held the GIL for the whole encode, so the event loop starved anyway
  • Counting a failed request's usage for its spend record, a project ITPM reservation, and a deployment itpm reservation all ran inline on the event loop, on any tokenizer
  • The prompt caching pre-call check counted the whole prompt inline twice per request, and /utils/token_counter loaded a deployment's custom HuggingFace tokenizer on the loop with a keyword tokenizers rejects, so every custom count logged an error and loaded the tokenizer twice
  • Every other request and the liveliness probe wait behind those counts
  • Nothing bounds how long one string can take to count

How it solves it:

  • Count HuggingFace tokens through the batch call that releases the GIL
  • Run the counts moved off the loop under their own thread limiter of TOKEN_COUNTER_MAX_CONCURRENT_COUNTS slots per worker process (default 4, settable from 1 to 256 in the proxy's environment) instead of the 40-thread pool every other threaded call shares, so a burst of large counts is bounded per worker and never starves the shared pool
  • Create that limiter on first use inside whichever event loop is running, one per loop, so a count started from a second loop in the same process (a sync completion() with fallbacks, Celery, gunicorn) finishes instead of waiting forever on a wake-up sent to another loop, and the import works on anyio releases before 4.2
  • Run the failure-hook, project ITPM, and deployment ITPM counts in a worker thread
  • Run the prompt caching check's counts in a worker thread, and load a deployment's custom tokenizer on the shared pool rather than in a count slot, passing the Hub token under the keyword tokenizers accepts
  • Tokenize at most TOKEN_COUNTER_MAX_EXACT_CHARS characters per string, sampled evenly, scaled by length
  • Tests fail when a count stalls the loop, ignores the cap, borrows from the shared thread pool, runs more than the configured number of counts at once, never finishes in a second event loop, or ignores the environment override

User Flow

Before: one whole-conversation count freezes the gateway for every other developer while it runs

  1. A platform team runs Claude Code and other Anthropic SDK apps through the gateway. Its Claude deployments point at Anthropic directly (anthropic/claude-* models, which a local count tokenizes with the Claude tokenizer); the team also has Bedrock deployments with itpm limits and optional_pre_call_checks: [enforce_model_rate_limits]
  2. An app counts a 600k-token conversation with POST https://litellm-domain/v1/messages/count_tokens (the Anthropic SDK's messages.count_tokens), sending the system prompt, tools, and messages; the provider's count fails, the gateway counts locally, and the app gets 200 {"input_tokens": 618344} back after about 0.7 s
  3. During those 0.7 s the platform's GET https://litellm-domain/health/liveliness probe, which normally answers in about 1 ms, takes up to 80 ms, and every Claude Code developer's POST https://litellm-domain/v1/messages waits just as long before the gateway even starts on it
  4. An operator posting one 2.4 MB prompt string to POST https://litellm-domain/utils/token_counter gets 200 {"total_tokens": 614612} after about 0.7 s and holds the probe for up to 660 ms
  5. A request the provider rejects (POST https://litellm-domain/v1/chat/completions with the 600k-token prompt through a deployment whose provider key is bad) comes back 401 from Anthropic or 403 from Bedrock after about 1 s, and the probe stalls for up to 690 ms (Anthropic) or 260 ms (Bedrock) while the gateway counts that failed request's tokens for its spend record
  6. A Claude Code developer whose 600k-token session Anthropic rejects (a revoked key here; a 429 or 529 takes the same path) watches Claude Code retry POST https://litellm-domain/v1/messages seven times in 45 s, and the probe stalls for up to 690 ms on every attempt while that failed request's tokens are counted
  7. Every POST https://litellm-domain/v1/chat/completions through the itpm-limited Bedrock deployment holds the probe for up to 220 ms while the gateway counts the prompt to reserve it against the limit, before the provider is even called
  8. With optional_pre_call_checks: [prompt_caching] on, every POST https://litellm-domain/v1/chat/completions through a Claude deployment holds the probe for two whole-prompt counts, one while picking the deployment and one after the response
  9. POST https://litellm-domain/utils/token_counter for a deployment with model_info.custom_tokenizer loads the tokenizer from the Hub cache on the loop on every call, about 0.1 s each, and logs Error creating pretrained tokenizer before loading it a second time
  10. A transcript twice the size freezes the gateway twice as long, with nothing to cap it
  11. Eight operators posting 2 MB prompt strings to POST https://litellm-domain/utils/token_counter at the same moment are tokenized one after another under the GIL, and the probe stalls behind each of them

After: the same counts run alongside the other traffic, which keeps answering in about 1 ms

  1. Same platform team, same deployments
  2. The app counts the 600k-token conversation with POST https://litellm-domain/v1/messages/count_tokens and gets the same 200 {"input_tokens": 618344} back after about 0.7 s
  3. During those 0.7 s the platform's GET https://litellm-domain/health/liveliness probe keeps answering in about 1 ms, and every Claude Code developer's POST https://litellm-domain/v1/messages starts right away
  4. The operator's 2.4 MB prompt string to POST https://litellm-domain/utils/token_counter gets the same 200 {"total_tokens": 614612} with the probe flat at about 1 ms
  5. The rejected POST https://litellm-domain/v1/chat/completions still comes back 401 or 403 after about 1 s, and the probe stays at about 1 ms while the failed request's tokens are counted
  6. The rejected Claude Code session is still retried and still gets 401 on every attempt, with the probe at about 1 ms while each attempt's tokens are counted
  7. POST https://litellm-domain/v1/chat/completions through the itpm-limited deployment is still reserved and still rejected with 429 ITPM limit=10, current usage=541553 when the limit is too small, with the probe at about 1 ms while the prompt is counted
  8. The prompt caching check's two counts run in a worker thread with the probe at about 1 ms (covered by tests; this rig does not enable that check)
  9. The custom tokenizer loads once per call on the shared worker pool, without the error and without taking a count slot (keeping it loaded across calls is LIT-7192)
  10. Any single string longer than 4,000,000 characters (roughly a million tokens) is estimated from 16 evenly spaced samples totaling 4,000,000 characters, scaled by its length, a ceiling the operator can move with TOKEN_COUNTER_MAX_EXACT_CHARS in the proxy's environment
  11. The eight simultaneous 2 MB prompt strings all get 200 {"total_tokens": 501831} within 1.0 to 1.6 s, each worker counting at most four at a time (TOKEN_COUNTER_MAX_CONCURRENT_COUNTS in the proxy's environment moves that number); with two workers eight still run at once and the proxy grows by about the same 1.4 GB as before, while sixteen at once peak at 1.6 GB instead of twice that because the second eight wait for a slot, and the probe stays answered (worst 124 ms) instead of stalling

Relevant issues

No GitHub issue. Reported by a customer through support (Pylon #7623 and #7411). Pylon #7314 was the tiktoken side of the same stall and was fixed in #37697; tiktoken releases the GIL, so a Bedrock model id (which selects tiktoken) never stalled the count_tokens routes, which is why the Bedrock cases below are controls. Docs: BerriAI/litellm-docs#1259. Remaining inline counts outside these paths (the context-management editors, the streaming chunk builder's no-usage fallback, the compression interception hook, the Anthropic passthrough's interrupted-stream re-tokenize, the A2A usage counter, and the SDK-only acount_tokens and encode) are tracked in LIT-7190; keeping a custom tokenizer loaded across /utils/token_counter calls is LIT-7192

Linear ticket

Resolves LIT-5546

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Every leg below ran twice: Before at 21ae29b, the PR's merge base with origin/litellm_internal_staging, and After at c1ca963, the tip of this branch. The proxy for each side is started from a checkout of that commit with PYTHONPATH pointed at it, and the script asserts the imported litellm.__file__ sits inside that tree before booting (the loaded: line at the top of each side). Same config, same payloads, same order on both sides. The box carried a load average of 16 to 30 from other jobs throughout, which the baseline legs (the probe loop with nothing in flight) put on record for each side

The rig runs twice per side: once with one uvicorn worker, where a blocked event loop is visible on every leg because the only worker is the one counting, and once with two workers (--num_workers 2), the multi-pod shape customers run, where a stalled worker is hidden from single-request probes by the free one and shows up when both workers are busy (the burst leg and the over-cap strings)

export PYTHONPATH=$TREE LITELLM_MASTER_KEY=sk-1234
python -c "import litellm,os,sys; print('loaded:', litellm.__file__); sys.exit(0 if litellm.__file__.startswith(os.getcwd()) else 1)" || exit 1
python litellm/proxy/proxy_cli.py --config config.yaml --port 41681 --num_workers 2   # or 1

Config:

model_list:
  - model_name: bedrock-claude
    litellm_params:
      model: bedrock/us.anthropic.claude-opus-5
      aws_region_name: us-east-1
  - model_name: anthropic-claude
    litellm_params:
      model: anthropic/claude-fable-5
      api_key: os.environ/ANTHROPIC_API_KEY
  - model_name: anthropic-badkey
    litellm_params:
      model: anthropic/claude-fable-5
      api_key: sk-ant-bogus-key-for-fallback-leg
  - model_name: bedrock-badcred
    litellm_params:
      model: bedrock/us.anthropic.claude-opus-5
      aws_region_name: us-east-1
      api_key: bogus-bedrock-bearer-token-for-the-failure-leg
  - model_name: bedrock-itpm
    litellm_params:
      model: bedrock/us.anthropic.claude-opus-5
      aws_region_name: us-east-1
      api_key: bogus-bedrock-bearer-token-for-the-failure-leg
      itpm: 1000000000
  - model_name: bedrock-itpm-tiny
    litellm_params:
      model: bedrock/us.anthropic.claude-opus-5
      aws_region_name: us-east-1
      api_key: bogus-bedrock-bearer-token-for-the-failure-leg
      itpm: 10
  - model_name: custom-tok
    litellm_params:
      model: openai/gpt-4.1-mini
      api_key: os.environ/OPENAI_API_KEY
    model_info:
      custom_tokenizer:
        identifier: Xenova/llama-3-tokenizer
        revision: main
        auth_token: null
  - model_name: custom-tok-bad
    litellm_params:
      model: openai/gpt-4.1-mini
      api_key: os.environ/OPENAI_API_KEY
    model_info:
      custom_tokenizer:
        identifier: berriai-does-not-exist/no-such-tokenizer-lit5546
        revision: main
        auth_token: null
  - model_name: anthropic-pc
    litellm_params:
      model: anthropic/claude-fable-5
      api_key: os.environ/ANTHROPIC_API_KEY
    model_info:
      id: pc-a
  - model_name: anthropic-pc
    litellm_params:
      model: anthropic/claude-fable-5
      api_key: os.environ/ANTHROPIC_API_KEY
    model_info:
      id: pc-b

router_settings:
  optional_pre_call_checks: [enforce_model_rate_limits, prompt_caching]
  disable_cooldowns: true

general_settings:
  master_key: sk-1234

bedrock-claude is a tiktoken control: this AWS account gets 403 from Bedrock CountTokens, so /utils/token_counter falls back to local counting, and a Bedrock model id selects tiktoken. bedrock-badcred and bedrock-itpm carry a bogus Bedrock API key (a per-deployment api_key is sent as the bearer token) so the same fallback runs and a chat completion fails upstream with a real 403; bedrock-itpm adds an itpm limit far above the prompt so the deployment reservation counts the prompt before the call, and bedrock-itpm-tiny (itpm: 10) shows that check is live by rejecting the prompt. anthropic-badkey makes the Anthropic count fail so /v1/messages/count_tokens takes the local fallback on the Claude tokenizer, and makes a chat completion fail upstream with a real 401 so the gateway counts the failed request's tokens itself. custom-tok is a deployment with a custom_tokenizer pulled from the HuggingFace hub with auth_token: null (the create_pretrained_tokenizer(token=) path), custom-tok-bad names a repository that does not exist. The two anthropic-pc deployments with prompt_caching in optional_pre_call_checks exercise the check whose two whole-prompt counts this PR moves to a worker thread. disable_cooldowns: true keeps the bad-credential deployments in rotation after their first rejection

Payloads are 2.5 MB JSON transcripts built from repository source files: 323 alternating user and assistant messages, 2,403,245 characters, about 615k tokens on the Claude tokenizer. anthropic_utils.json and bedrock_utils.json carry them as {"model": ..., "messages": [...]} for /utils/token_counter; badkey_anthropic.json is the Anthropic Messages shape {"model": "anthropic-badkey", "system": <6 KB>, "tools": [<6 tools>], "messages": [...]} for /v1/messages/count_tokens; badkey_chat.json is {"model": "anthropic-badkey", "messages": [...], "max_tokens": 16} for /v1/chat/completions; prompt_anthropic_utils.json is the same text as one prompt string; bedrock_badcred_utils.json, bedrock_badcred_chat.json, bedrock_itpm_chat.json, and bedrock_itpm_tiny_chat.json are the same payloads with the model swapped. small_hf_utils.json is a 2,000,000-character prompt for the burst, custom_utils.json a 300,000-character prompt, custom_bad_utils.json an 11-character one, pc_chat.json a chat completion with a 3,600-token system prompt marked cache_control: {"type": "ephemeral"} and Reply with just the word OK as the user turn

Before every side the tokenizers were loaded once and the failure path warmed once so no leg times a first load: for m in anthropic-claude bedrock-claude bedrock-badcred custom-tok anthropic-claude anthropic-claude anthropic-badkey; do curl -s -o /dev/null -X POST http://127.0.0.1:41681/utils/token_counter -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d "{\"model\": \"$m\", \"prompt\": \"warm up the tokenizer\"}"; done and for m in anthropic-badkey bedrock-badcred bedrock-itpm; do curl -s -o /dev/null -X POST http://127.0.0.1:41681/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d "{\"model\": \"$m\", \"messages\": [{\"role\": \"user\", \"content\": \"warm up the failure path\"}], \"max_tokens\": 5}"; done

Each API leg runs leg, which posts the payload once and hits /health/liveliness every 20 ms for as long as the request is in flight, then prints the request's status and time (the label reads count_tokens: for every timed request, the chat completion legs included) and the p50/p90/p99/max of the probes taken during it. burst fires n copies of a request at once, samples the proxy process tree's RSS every 100 ms while they run, and probes the same way. scenario is a single timed curl

H1='Authorization: Bearer sk-1234'; H2='Content-Type: application/json'
probe_loop () { local D=$1 CPID=$2; local i=0; while kill -0 $CPID 2>/dev/null; do curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 60 http://127.0.0.1:41681/health/liveliness > $D/$i & i=$((i+1)); sleep 0.02; done; wait $CPID; sleep 0.5 }
leg () {
  local name=$1 route=$2 payload=$3; local D=runs/$name; mkdir -p $D
  echo "#### leg $name $route $payload"
  if [ "$route" = baseline ]; then ( sleep 4 ) & else
    ( sleep 0.5; curl -s -o $D/count_response.json -w '%{http_code} %{time_total}\n' --max-time 180 -X POST http://127.0.0.1:41681$route -H $H1 -H $H2 --data-binary @$payload > $D/count_status.txt ) &
  fi
  probe_loop $D $!
  [ -f $D/count_status.txt ] && echo "count_tokens: http_code time_total = $(cat $D/count_status.txt)" && echo "response: $(head -c 300 $D/count_response.json)"
  echo -n "liveliness during $name: "; python3 stats.py $D
}
burst () {
  local name=$1 payload=$2 n=$3; local D=runs/$name; mkdir -p $D
  echo "#### burst $name $payload x$n"
  local before=$(tree_rss_mb); echo "$name: proxy tree rss before = ${before} MB"
  ( sleep 0.3; for j in $(seq 1 $n); do curl -s -o $D/resp_$j.json -w '%{http_code} %{time_total}\n' -X POST http://127.0.0.1:41681/utils/token_counter -H $H1 -H $H2 --data-binary @$payload > $D/status_$j.txt & done; wait ) &
  local CPID=$!; local i=0; local peak=$before
  while kill -0 $CPID 2>/dev/null; do
    curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://127.0.0.1:41681/health/liveliness > $D/$i &
    if (( i % 5 == 0 )); then local now=$(tree_rss_mb); (( now > peak )) && peak=$now; fi
    i=$((i+1)); sleep 0.02
  done
  wait $CPID; sleep 0.5
  echo "$name: $n concurrent requests: $(cat $D/status_*.txt | sort | uniq -c | tr -s ' ' | tr '\n' ';') counts: $(for j in $(seq 1 $n); do python3 -c "import json;print(json.load(open('$D/resp_$j.json'))['total_tokens'])"; done | sort | uniq -c | tr -s ' ' | tr '\n' ';')"
  echo "$name: proxy tree rss peak during burst = ${peak} MB (+$((peak - before)) MB), after = $(tree_rss_mb) MB"
  echo -n "liveliness during $name: "; python3 stats.py $D
}

tree_rss_mb sums ps -o rss= over the proxy pid and its children, scenario is curl -s -o body -w 'status=%{http_code} total=%{time_total}s' on the route plus the first 400 bytes of the body, and stats.py reads the probe files and prints probes= ok= p50 p90 p99 max mean. The prompt caching leg posts pc_chat.json twice and prints the content plus the usage cache fields of each answer

The Claude Code legs drive the real client interactively. Claude Code 2.1.267 runs in tmux behind a small recorder (python3 cc/recorder.py 44741 cc/recorder.log 41681, which forwards to the proxy and logs METHOD path body_bytes=N per request) as env -u ANTHROPIC_API_KEY ANTHROPIC_BASE_URL=http://127.0.0.1:44741 ANTHROPIC_AUTH_TOKEN=sk-1234 ANTHROPIC_MODEL='bedrock-claude[1m]' ANTHROPIC_SMALL_FAST_MODEL='bedrock-claude[1m]' CLAUDE_CONFIG_DIR=cc/config claude. The first leg types Reply with just the word OK, waits for the answer, then types /context, which makes Claude Code count the pieces of its context with 32 small count_tokens requests (the largest 74 KB) while the script probes /health/liveliness every 20 ms for 25 s, then prints the /context pane and takes a screenshot. The second leg starts a fresh session on anthropic-badkey (a revoked key), pastes cc/paste_full.txt (2,405,645 bytes, the same text as the 2.4 MB curl legs) as one message and presses Enter: Claude Code sends POST /v1/messages with a 2,515,689-byte body, Anthropic answers 401, Claude Code retries with backoff, and the gateway counts each failed attempt's prompt on the Claude tokenizer for its spend record, so the probe loop (45 s) sees whether those counts stall the loop. Claude Code never sends a whole-transcript count_tokens on this build, so this is the route by which a Claude Code user's oversized conversation reaches local counting

tmux new-session -d -s cc -x 180 -y 45 "env -u ANTHROPIC_API_KEY ANTHROPIC_BASE_URL=http://127.0.0.1:44741 ANTHROPIC_AUTH_TOKEN=sk-1234 ANTHROPIC_MODEL='bedrock-claude[1m]' ANTHROPIC_SMALL_FAST_MODEL='bedrock-claude[1m]' CLAUDE_CONFIG_DIR=cc/config claude"
tmux send-keys -t cc -l 'Reply with just the word OK'; tmux send-keys -t cc Enter; sleep 25
before=$(grep -c 'POST /v1/messages/count_tokens' cc/recorder.log)
( sleep 1; tmux send-keys -t cc -l '/context'; sleep 0.5; tmux send-keys -t cc Enter; sleep 25 ) &
probe_loop runs/cc_context $!
echo "count_tokens requests during /context: $(( $(grep -c 'POST /v1/messages/count_tokens' cc/recorder.log) - before ))"
echo -n "liveliness during cc_context: "; python3 stats.py runs/cc_context
tmux capture-pane -p -S -80 -t cc | grep -E 'tokens \(|System prompt|System tools|Skills:|Free space'
# fresh session with ANTHROPIC_MODEL='anthropic-badkey', then:
tmux load-buffer -b paste cc/paste_full.txt
( tmux paste-buffer -b paste -p -t cc; sleep 6; tmux send-keys -t cc Enter; sleep 45 ) &
probe_loop runs/cc_fail $!
echo "/v1/messages requests Claude Code sent meanwhile: ..."; echo -n "liveliness during cc_fail: "; python3 stats.py runs/cc_fail

One worker, Before (21ae29b) and After (c1ca963)

loaded: basetree/deep/base_21ae29b7/litellm/__init__.py sha: 21ae29b759-1w
proxy up after 6s (pid 72756), workers: 1, port 41681, tip 21ae29b759-1w, 2026-09-10T02:02:33Z
### Legs at 21ae29b759-1w, one worker, 2026-09-10T02:02:36Z load= 21.32 20.76 18.69
loaded: /Users/mateo/Development/litellm-wt-lit5546/litellm/__init__.py sha: c1ca963d75-1w
proxy up after 10s (pid 91021), workers: 1, port 41681, tip c1ca963d75-1w, 2026-09-10T02:06:09Z
### Legs at c1ca963d75-1w, one worker, 2026-09-10T02:06:12Z load= 20.71 20.57 19.05

baseline, no request in flight (4 s of probes)

Before (21ae29b):

liveliness during baseline: probes=122 ok=122 p50=1.1ms p90=1.7ms p99=7.3ms max=54.4ms mean=1.7ms

After (c1ca963):

liveliness during baseline: probes=99 ok=99 p50=1.6ms p90=3.0ms p99=5.3ms max=7.5ms mean=1.9ms

/utils/token_counter, Claude tokenizer, 323 messages (2.5 MB)

Before (21ae29b):

count_tokens: http_code time_total = 200 0.491384
response: {"total_tokens":615912,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_anthropic: probes=28 ok=28 p50=13.3ms p90=51.8ms p99=61.6ms max=61.6ms mean=20.1ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.701556
response: {"total_tokens":615912,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_anthropic: probes=33 ok=33 p50=1.6ms p90=5.5ms p99=13.1ms max=13.1ms mean=2.7ms

/utils/token_counter, Claude tokenizer, one 2,403,245-character prompt string

Before (21ae29b):

count_tokens: http_code time_total = 200 0.576188
response: {"total_tokens":614612,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_prompt_anthropic: probes=30 ok=30 p50=1.1ms p90=444.6ms p99=551.3ms max=551.3ms mean=147.2ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.992059
response: {"total_tokens":614612,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_prompt_anthropic: probes=38 ok=38 p50=1.7ms p90=11.6ms p99=97.7ms max=97.7ms mean=8.7ms

/v1/messages/count_tokens, provider count fails, local Claude tokenizer fallback

Before (21ae29b):

count_tokens: http_code time_total = 200 0.666333
response: {"input_tokens":618344}
liveliness during msgs_anthropic_badkey: probes=33 ok=33 p50=1.0ms p90=54.1ms p99=66.4ms max=66.4ms mean=16.5ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.958828
response: {"input_tokens":618344}
liveliness during msgs_anthropic_badkey: probes=36 ok=36 p50=2.0ms p90=9.1ms p99=57.9ms max=57.9ms mean=5.7ms

/utils/token_counter, Bedrock model id, tiktoken fallback (control)

Before (21ae29b):

count_tokens: http_code time_total = 200 0.141524
response: {"total_tokens":541553,"request_model":"bedrock-claude","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respon ...
liveliness during utils_bedrock: probes=19 ok=19 p50=1.0ms p90=1.2ms p99=1.6ms max=1.6ms mean=1.0ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.212575
response: {"total_tokens":541553,"request_model":"bedrock-claude","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respon ...
liveliness during utils_bedrock: probes=22 ok=22 p50=1.2ms p90=1.6ms p99=10.5ms max=10.5ms mean=1.9ms

/utils/token_counter, Bedrock model id with a bad credential, tiktoken fallback (control)

Before (21ae29b):

count_tokens: http_code time_total = 200 0.127354
response: {"total_tokens":541553,"request_model":"bedrock-badcred","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respo ...
liveliness during utils_bedrock_badcred: probes=18 ok=18 p50=1.0ms p90=1.0ms p99=1.1ms max=1.1ms mean=1.0ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.216579
response: {"total_tokens":541553,"request_model":"bedrock-badcred","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respo ...
liveliness during utils_bedrock_badcred: probes=21 ok=21 p50=1.4ms p90=2.3ms p99=3.2ms max=3.2ms mean=1.6ms

/v1/chat/completions rejected by Anthropic, failed request's tokens counted for its spend record

Before (21ae29b):

count_tokens: http_code time_total = 401 0.688394
response: {"error":{"message":"litellm.AuthenticationError: AnthropicException - {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\" ...
Available Model Group Fallbacks=None","type":"authentication_error
liveliness during chat_badkey: probes=32 ok=32 p50=1.3ms p90=282.8ms p99=398.3ms max=398.3ms mean=76.9ms

After (c1ca963):

count_tokens: http_code time_total = 401 1.096559
response: {"error":{"message":"litellm.AuthenticationError: AnthropicException - {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\" ...
Available Model Group Fallbacks=None","type":"authentication_error
liveliness during chat_badkey: probes=44 ok=44 p50=1.5ms p90=4.4ms p99=42.6ms max=42.6ms mean=2.9ms

/v1/chat/completions rejected by Bedrock, failed request's tokens counted for its spend record

Before (21ae29b):

count_tokens: http_code time_total = 403 0.880031
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_badcred: probes=41 ok=41 p50=1.0ms p90=68.9ms p99=158.7ms max=158.7ms mean=17.5ms

After (c1ca963):

count_tokens: http_code time_total = 403 1.423061
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_badcred: probes=42 ok=42 p50=1.8ms p90=12.3ms p99=23.1ms max=23.1ms mean=4.3ms

/v1/chat/completions through a Bedrock deployment with an itpm limit, prompt counted for the reservation

Before (21ae29b):

count_tokens: http_code time_total = 403 0.725482
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_itpm: probes=33 ok=33 p50=3.3ms p90=238.0ms p99=338.4ms max=338.4ms mean=69.6ms

After (c1ca963):

count_tokens: http_code time_total = 403 1.463969
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_itpm: probes=30 ok=30 p50=1.9ms p90=22.6ms p99=143.7ms max=143.7ms mean=10.2ms

the itpm check is live on this rig (a 10-token limit rejects the same prompt)

Before (21ae29b):

{"error":{"message":"litellm.RateLimitError: Model rate limit exceeded. ITPM limit=10, current usage=541553. Received Model Group=bedrock-itpm-tiny\nAvailable Model Group Fallbacks=None","type":"throttling_error","param":null,"code":"429"}} [429 0.312432s]

After (c1ca963):

{"error":{"message":"litellm.RateLimitError: Model rate limit exceeded. ITPM limit=10, current usage=541553. Received Model Group=bedrock-itpm-tiny\nAvailable Model Group Fallbacks=None","type":"throttling_error","param":null,"code":"429"}} [429 0.719276s]

eight 2 MB /utils/token_counter requests at once, Claude tokenizer, memory sampled every 100 ms

Before (21ae29b):

burst8_small_hf: proxy tree rss before = 162 MB (pids: 72756 72881 )
burst8_small_hf: 8 concurrent requests:  1 200 0.838596; 1 200 1.517130; 1 200 4.877377; 1 200 4.897080; 1 200 9.528232; 1 200 9.549320; 1 200 9.555999; 1 200 9.565520; counts:  8 501831;
burst8_small_hf: proxy tree rss peak during burst = 723 MB (+561 MB), after = 31 MB
liveliness during burst8_small_hf: probes=116 ok=116 p50=4090.9ms p90=7861.0ms p99=8333.3ms max=8338.6ms mean=4215.0ms

After (c1ca963):

burst8_small_hf: proxy tree rss before = 129 MB (pids: 91021 91310 )
burst8_small_hf: 8 concurrent requests:  1 200 1.329428; 1 200 1.330044; 1 200 1.338821; 1 200 1.346604; 1 200 1.851583; 1 200 1.861859; 1 200 1.887407; 1 200 1.943193; counts:  8 501831;
burst8_small_hf: proxy tree rss peak during burst = 1003 MB (+874 MB), after = 67 MB
liveliness during burst8_small_hf: probes=26 ok=26 p50=1.8ms p90=13.6ms p99=138.8ms max=138.8ms mean=10.7ms

/utils/token_counter, a deployment's custom HuggingFace tokenizer (300 KB prompt)

Before (21ae29b):

status=200 total=1.160360s
{"total_tokens":70540,"request_model":"custom-tok","model_used":"gpt-4.1-mini","tokenizer_type":"huggingface_tokenizer","original_response":null,"error":false,"error_message":null,"status_code":null}

After (c1ca963):

status=200 total=0.692782s
{"total_tokens":70540,"request_model":"custom-tok","model_used":"gpt-4.1-mini","tokenizer_type":"huggingface_tokenizer","original_response":null,"error":false,"error_message":null,"status_code":null}

/utils/token_counter, a custom tokenizer identifier that does not exist

Before (21ae29b):

status=500 total=0.300326s
{"error":{"message":"Internal server error","type":"internal_server_error"}}

After (c1ca963):

status=500 total=0.126768s
{"error":{"message":"Internal server error","type":"internal_server_error"}}

prompt caching pre-call check, the same cached prompt twice

Before (21ae29b):

pc_1 status=200 total=3.547012s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 3604 cache_read 0
pc_2 status=200 total=3.122116s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604

After (c1ca963):

pc_1 status=200 total=3.416505s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604
pc_2 status=200 total=3.175814s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604

baseline again at the end of the side

Before (21ae29b):

liveliness during baseline_end: probes=104 ok=104 p50=1.5ms p90=5.2ms p99=160.7ms max=182.9ms mean=7.6ms

After (c1ca963):

liveliness during baseline_end: probes=79 ok=79 p50=2.0ms p90=5.5ms p99=10.5ms max=16.8ms mean=2.8ms

Claude Code, /context in an interactive session

Before (21ae29b):

count_tokens requests during /context: 32
liveliness during cc_context: probes=721 ok=721 p50=1.0ms p90=1.6ms p99=4.9ms max=41.7ms mean=1.3ms
--- /context pane ---
  31.8k/1m tokens (3%)
  System prompt: 1.4k tokens (0.1%)
  System tools: 35.1k tokens (3.5%)
  Skills: 1.9k tokens (0.2%)
  Free space: 961.5k (96.2%)
count_tokens sizes: n=32 min=82 median=1634 max=73624

cc_context_21ae29b759-1w.png

After (c1ca963):

count_tokens requests during /context: 32
liveliness during cc_context: probes=624 ok=624 p50=1.7ms p90=3.9ms p99=19.9ms max=86.9ms mean=2.7ms
--- /context pane ---
  31.8k/1m tokens (3%)
  System prompt: 1.4k tokens (0.1%)
  System tools: 35.1k tokens (3.5%)
  Skills: 1.9k tokens (0.2%)
  Free space: 961.5k (96.2%)
count_tokens sizes: n=32 min=82 median=1634 max=73624

cc_context_c1ca963d75-1w.png

Claude Code, a 2.4 MB conversation Anthropic rejects, retried

Before (21ae29b):

paste: 2405645 bytes
/v1/messages requests Claude Code sent meanwhile: 7
liveliness during cc_fail: probes=1386 ok=1386 p50=1.2ms p90=85.9ms p99=950.1ms max=1530.3ms mean=51.5ms
--- pane ---
⎿  Context limit reached · /compact or /clear to continue
--- proxy log: auth_token/pretrained errors: 5; 401 lines: 11

cc_fail_21ae29b759-1w.png

After (c1ca963):

paste: 2405645 bytes
/v1/messages requests Claude Code sent meanwhile: 7
liveliness during cc_fail: probes=1290 ok=1290 p50=1.5ms p90=3.0ms p99=31.9ms max=86.4ms mean=2.6ms
--- pane ---
⎿  Context limit reached · /compact or /clear to continue
--- proxy log: auth_token/pretrained errors: 1; 401 lines: 11

cc_fail_c1ca963d75-1w.png

Two workers, Before (21ae29b) and After (c1ca963)

Same legs with --num_workers 2. Two workers hide a single request's stall from the probes (the free worker answers them), so the API legs read close on both sides and the difference shows where both workers are counting: the burst leg, where the Before side's probes reach 6.6 s (p90 1.8 s) while the After side's stay under 70 ms, and the over-cap strings below

loaded: basetree/deep/base_21ae29b7/litellm/__init__.py sha: 21ae29b759
proxy up after 7s (pid 22057), workers: 2, port 41681, tip 21ae29b759, 2026-09-10T02:09:33Z
### After legs at 21ae29b759, two workers, 2026-09-10T02:09:37Z load= 17.37 21.56 20.00
loaded: /Users/mateo/Development/litellm-wt-lit5546/litellm/__init__.py sha: c1ca963d75
proxy up after 9s (pid 49446), workers: 2, port 41681, tip c1ca963d75, 2026-09-10T01:29:27Z
### After legs at c1ca963d75, two workers, 2026-09-10T01:29:29Z load= 22.83 21.81 19.83

baseline, no request in flight (4 s of probes)

Before (21ae29b):

liveliness during baseline: probes=61 ok=61 p50=2.1ms p90=5.3ms p99=80.8ms max=137.5ms mean=7.0ms

After (c1ca963):

liveliness during baseline: probes=94 ok=94 p50=1.6ms p90=5.1ms p99=21.7ms max=37.1ms mean=2.9ms

/utils/token_counter, Claude tokenizer, 323 messages (2.5 MB)

Before (21ae29b):

count_tokens: http_code time_total = 200 2.133847
response: {"total_tokens":615912,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_anthropic: probes=50 ok=50 p50=2.9ms p90=13.9ms p99=242.8ms max=242.8ms mean=12.1ms

After (c1ca963):

count_tokens: http_code time_total = 200 1.529974
response: {"total_tokens":615912,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_anthropic: probes=42 ok=42 p50=2.4ms p90=6.9ms p99=13.8ms max=13.8ms mean=3.4ms

/utils/token_counter, Claude tokenizer, one 2,403,245-character prompt string

Before (21ae29b):

count_tokens: http_code time_total = 200 1.750826
response: {"total_tokens":614612,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_prompt_anthropic: probes=53 ok=53 p50=2.2ms p90=8.1ms p99=37.7ms max=1548.0ms mean=33.2ms

After (c1ca963):

count_tokens: http_code time_total = 200 1.243131
response: {"total_tokens":614612,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":n ...
liveliness during utils_prompt_anthropic: probes=44 ok=44 p50=2.1ms p90=7.7ms p99=31.7ms max=31.7ms mean=4.2ms

/v1/messages/count_tokens, provider count fails, local Claude tokenizer fallback

Before (21ae29b):

count_tokens: http_code time_total = 200 1.192087
response: {"input_tokens":618344}
liveliness during msgs_anthropic_badkey: probes=47 ok=47 p50=2.2ms p90=21.9ms p99=27.4ms max=27.4ms mean=7.1ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.888440
response: {"input_tokens":618344}
liveliness during msgs_anthropic_badkey: probes=41 ok=41 p50=1.4ms p90=2.7ms p99=9.0ms max=9.0ms mean=1.8ms

/utils/token_counter, Bedrock model id, tiktoken fallback (control)

Before (21ae29b):

count_tokens: http_code time_total = 200 0.253036
response: {"total_tokens":541553,"request_model":"bedrock-claude","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respon ...
liveliness during utils_bedrock: probes=21 ok=21 p50=1.5ms p90=2.8ms p99=20.5ms max=20.5ms mean=2.6ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.262168
response: {"total_tokens":541553,"request_model":"bedrock-claude","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respon ...
liveliness during utils_bedrock: probes=24 ok=24 p50=1.5ms p90=2.5ms p99=2.9ms max=2.9ms mean=1.7ms

/utils/token_counter, Bedrock model id with a bad credential, tiktoken fallback (control)

Before (21ae29b):

count_tokens: http_code time_total = 200 0.333289
response: {"total_tokens":541553,"request_model":"bedrock-badcred","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respo ...
liveliness during utils_bedrock_badcred: probes=24 ok=24 p50=2.2ms p90=5.0ms p99=36.7ms max=36.7ms mean=4.0ms

After (c1ca963):

count_tokens: http_code time_total = 200 0.204884
response: {"total_tokens":541553,"request_model":"bedrock-badcred","model_used":"us.anthropic.claude-opus-5","tokenizer_type":"openai_tokenizer","original_respo ...
liveliness during utils_bedrock_badcred: probes=22 ok=22 p50=1.4ms p90=2.1ms p99=17.8ms max=17.8ms mean=2.2ms

/v1/chat/completions rejected by Anthropic, failed request's tokens counted for its spend record

Before (21ae29b):

count_tokens: http_code time_total = 401 1.762622
response: {"error":{"message":"litellm.AuthenticationError: AnthropicException - {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\" ...
Available Model Group Fallbacks=None","type":"authentication_error
liveliness during chat_badkey: probes=27 ok=27 p50=2.6ms p90=6.3ms p99=38.0ms max=38.0ms mean=5.5ms

After (c1ca963):

count_tokens: http_code time_total = 401 1.392456
response: {"error":{"message":"litellm.AuthenticationError: AnthropicException - {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\" ...
Available Model Group Fallbacks=None","type":"authentication_error
liveliness during chat_badkey: probes=56 ok=56 p50=1.5ms p90=2.8ms p99=4.6ms max=4.6ms mean=1.8ms

/v1/chat/completions rejected by Bedrock, failed request's tokens counted for its spend record

Before (21ae29b):

count_tokens: http_code time_total = 403 1.647683
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_badcred: probes=37 ok=37 p50=1.8ms p90=5.7ms p99=84.4ms max=84.4ms mean=6.0ms

After (c1ca963):

count_tokens: http_code time_total = 403 1.202184
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_badcred: probes=50 ok=50 p50=1.4ms p90=4.3ms p99=29.5ms max=29.5ms mean=2.7ms

/v1/chat/completions through a Bedrock deployment with an itpm limit, prompt counted for the reservation

Before (21ae29b):

count_tokens: http_code time_total = 403 1.764457
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_itpm: probes=43 ok=43 p50=2.2ms p90=10.2ms p99=44.3ms max=44.3ms mean=5.0ms

After (c1ca963):

count_tokens: http_code time_total = 403 0.875802
response: {"error":{"message":"litellm.PermissionDeniedError: BedrockException - {\"Message\":\"Invalid API Key format: Must start with pre-defined prefix\"}. R ...
Available Model Group Fallbacks=None","type":"permission_error","param":null,"code":"403"}}
liveliness during chat_bedrock_itpm: probes=41 ok=41 p50=1.5ms p90=3.1ms p99=39.5ms max=39.5ms mean=2.7ms

the itpm check is live on this rig (a 10-token limit rejects the same prompt)

Before (21ae29b):

{"error":{"message":"litellm.RateLimitError: Model rate limit exceeded. ITPM limit=10, current usage=541553. Received Model Group=bedrock-itpm-tiny\nAvailable Model Group Fallbacks=None","type":"throttling_error","param":null,"code":"429"}} [429 0.651658s]

After (c1ca963):

{"error":{"message":"litellm.RateLimitError: Model rate limit exceeded. ITPM limit=10, current usage=541553. Received Model Group=bedrock-itpm-tiny\nAvailable Model Group Fallbacks=None","type":"throttling_error","param":null,"code":"429"}} [429 0.402582s]

eight 2 MB /utils/token_counter requests at once, Claude tokenizer, memory sampled every 100 ms

Before (21ae29b):

burst8_small_hf: proxy tree rss before = 126 MB (pids: 22057 22311 22312 22598 22313 22604 )
burst8_small_hf: 8 concurrent requests:  1 200 11.504472; 1 200 11.507726; 1 200 2.482147; 1 200 4.784280; 1 200 8.585055; 1 200 8.604263; 1 200 8.627428; 1 200 8.633232; counts:  8 501831;
burst8_small_hf: proxy tree rss peak during burst = 682 MB (+556 MB), after = 128 MB
liveliness during burst8_small_hf: probes=66 ok=66 p50=18.9ms p90=1792.7ms p99=6388.6ms max=6556.8ms mean=663.8ms

After (c1ca963):

burst8_small_hf: proxy tree rss before = 229 MB (pids: 49446 49638 49639 49822 49640 49821 )
burst8_small_hf: 8 concurrent requests:  1 200 0.942210; 1 200 1.047814; 1 200 1.211459; 1 200 1.247814; 1 200 1.257923; 1 200 1.392800; 1 200 1.734050; 1 200 1.791935; counts:  8 501831;
burst8_small_hf: proxy tree rss peak during burst = 1304 MB (+1075 MB), after = 315 MB
liveliness during burst8_small_hf: probes=16 ok=16 p50=2.6ms p90=32.8ms p99=68.6ms max=68.6ms mean=9.3ms

/utils/token_counter, a deployment's custom HuggingFace tokenizer (300 KB prompt)

Before (21ae29b):

status=200 total=1.598537s
{"total_tokens":70540,"request_model":"custom-tok","model_used":"gpt-4.1-mini","tokenizer_type":"huggingface_tokenizer","original_response":null,"error":false,"error_message":null,"status_code":null}

After (c1ca963):

status=200 total=0.486495s
{"total_tokens":70540,"request_model":"custom-tok","model_used":"gpt-4.1-mini","tokenizer_type":"huggingface_tokenizer","original_response":null,"error":false,"error_message":null,"status_code":null}

/utils/token_counter, a custom tokenizer identifier that does not exist

Before (21ae29b):

status=500 total=0.371789s
{"error":{"message":"Internal server error","type":"internal_server_error"}}

After (c1ca963):

status=500 total=0.124222s
{"error":{"message":"Internal server error","type":"internal_server_error"}}

prompt caching pre-call check, the same cached prompt twice

Before (21ae29b):

pc_1 status=200 total=3.640124s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604
pc_2 status=200 total=3.603503s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604

After (c1ca963):

pc_1 status=200 total=3.939382s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 3604 cache_read 0
pc_2 status=200 total=3.856991s
content: OK | prompt_tokens 3617 completion_tokens 4 cache_creation 0 cache_read 3604

baseline again at the end of the side

Before (21ae29b):

liveliness during baseline_end: probes=107 ok=107 p50=1.6ms p90=2.9ms p99=36.2ms max=67.7ms mean=3.2ms

After (c1ca963):

liveliness during baseline_end: probes=114 ok=114 p50=1.2ms p90=1.7ms p99=6.8ms max=17.2ms mean=1.5ms

Claude Code, /context in an interactive session

Before (21ae29b):

count_tokens requests during /context: 32
liveliness during cc_context: probes=645 ok=645 p50=1.7ms p90=4.0ms p99=34.0ms max=65.3ms mean=3.2ms
--- /context pane ---
  31.8k/1m tokens (3%)
  System prompt: 1.4k tokens (0.1%)
  System tools: 35.1k tokens (3.5%)
  Skills: 1.9k tokens (0.2%)
  Free space: 961.5k (96.2%)
count_tokens sizes: n=44 min=82 median=1303 max=73624

cc_context_21ae29b759.png

After (c1ca963):

count_tokens requests during /context: 32
liveliness during cc_context: probes=642 ok=642 p50=1.6ms p90=4.2ms p99=37.8ms max=195.4ms mean=3.5ms
--- /context pane ---
  31.8k/1m tokens (3%)
  System prompt: 1.4k tokens (0.1%)
  System tools: 35.1k tokens (3.5%)
  Skills: 1.9k tokens (0.2%)
  Free space: 961.5k (96.2%)
count_tokens sizes: n=32 min=82 median=1634 max=73624

cc_context_c1ca963d75.png

Claude Code, a 2.4 MB conversation Anthropic rejects, retried

Before (21ae29b):

paste: 2405645 bytes
/v1/messages requests Claude Code sent meanwhile: 6
liveliness during cc_fail: probes=1228 ok=1228 p50=1.7ms p90=4.4ms p99=36.8ms max=158.1ms mean=3.6ms
--- pane ---
⎿  Context limit reached · /compact or /clear to continue
--- proxy log: auth_token/pretrained errors: 5; 401 lines: 10

cc_fail_21ae29b759.png

After (c1ca963):

paste: 2405645 bytes
/v1/messages requests Claude Code sent meanwhile: 7
liveliness during cc_fail: probes=1240 ok=1240 p50=1.6ms p90=4.3ms p99=48.6ms max=218.3ms mean=3.9ms
--- pane ---
⎿  Context limit reached · /compact or /clear to continue
--- proxy log: auth_token/pretrained errors: 1; 401 lines: 11

cc_fail_c1ca963d75.png

Over-cap strings, two workers

The payload is a 4,600,000-character prompt built from repository source files, above TOKEN_COUNTER_MAX_EXACT_CHARS (4,000,000), as big_hf_utils.json ({"model": "anthropic-claude", "prompt": ...}) and big_hf_messages.json (the same text as one user message for /v1/messages/count_tokens on anthropic-badkey, so the provider count fails and the local Claude tokenizer counts it). Before counts the whole string exactly and stalls both workers for 2.7 s and 3.0 s; After tokenizes 16 evenly spaced samples totalling 4,000,000 characters and scales, landing 0.2% under the exact count, with no probe above 6 ms

Before (21ae29b):

loaded: basetree/deep/base_21ae29b7/litellm/__init__.py sha: 21ae29b759
proxy up after 22s (pid 51101), workers: 2, port 41681, tip 21ae29b759, 2026-09-10T01:59:07Z
### Over-cap legs at 21ae29b759, two workers, 2026-09-10T01:59:10Z load= 29.60 21.78 18.47
liveliness during baseline: probes=112 ok=112 p50=1.5ms p90=3.0ms p99=26.5ms max=29.9ms mean=2.5ms
count_tokens: http_code time_total = 200 2.798849
response: {"total_tokens":1136476,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response": ...
liveliness during big_hf_utils: probes=90 ok=90 p50=1.6ms p90=2.3ms p99=8.0ms max=2703.4ms mean=31.8ms
count_tokens: http_code time_total = 200 3.563344
response: {"input_tokens":1136483}
liveliness during big_hf_messages: probes=116 ok=116 p50=1.6ms p90=2.6ms p99=8.5ms max=3049.8ms mean=28.1ms
liveliness during baseline_end: probes=115 ok=115 p50=1.4ms p90=2.0ms p99=10.3ms max=12.4ms mean=1.7ms

After (c1ca963):

loaded: /Users/mateo/Development/litellm-wt-lit5546/litellm/__init__.py sha: c1ca963d75
proxy up after 7s (pid 66279), workers: 2, port 41681, tip c1ca963d75, 2026-09-10T01:34:24Z
### Over-cap legs at c1ca963d75, two workers, 2026-09-10T01:34:27Z load= 19.41 21.69 20.49
liveliness during baseline: probes=118 ok=118 p50=1.2ms p90=1.9ms p99=10.5ms max=57.4ms mean=1.9ms
count_tokens: http_code time_total = 200 0.726289
response: {"total_tokens":1134174,"request_model":"anthropic-claude","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response": ...
liveliness during big_hf_utils: probes=40 ok=40 p50=1.2ms p90=1.5ms p99=2.1ms max=2.1ms mean=1.2ms
count_tokens: http_code time_total = 200 0.870591
response: {"input_tokens":1134181}
liveliness during big_hf_messages: probes=43 ok=43 p50=1.0ms p90=1.2ms p99=5.7ms max=5.7ms mean=1.2ms
liveliness during baseline_end: probes=121 ok=121 p50=1.0ms p90=1.2ms p99=1.8ms max=2.2ms mean=1.1ms

Burst, two workers, eight then sixteen then eight at once

Once HuggingFace encodes release the GIL they run in parallel, so a burst of large counts multiplies the tokenizer's working memory by however many run at once. c1ca963 runs every offloaded count under a per-event-loop anyio.CapacityLimiter(TOKEN_COUNTER_MAX_CONCURRENT_COUNTS) (4 slots per worker process by default), separate from the 40-thread limiter every other threaded call shares. The eight-at-once burst on the merge base is in the two-worker legs above (probes p50 in the seconds while the loop counts them one after another, requests answered in 1.5 s to 17 s); this is the same burst on the tip, plus sixteen at once and eight again, on the same two-worker proxy

loaded: /Users/mateo/Development/litellm-wt-lit5546/litellm/__init__.py sha: c1ca963d75
proxy up after 9s (pid 68524), workers: 2, port 41681, tip c1ca963d75, 2026-09-10T01:35:00Z
### Burst re-run at c1ca963d75, two workers, 2026-09-10T01:35:04Z load= 18.77 21.11 20.32
liveliness during baseline: probes=115 ok=115 p50=1.5ms p90=3.0ms p99=14.0ms max=14.1ms mean=2.0ms
burst8_small_hf: proxy tree rss before = 109 MB (pids: 68524 68857 68858 69035 68859 69036 )
burst8_small_hf: 8 concurrent requests:  1 200 1.188341; 1 200 1.216679; 1 200 1.526790; 1 200 1.544348; 1 200 1.544351; 1 200 1.559112; 1 200 2.054647; 1 200 2.064313; counts:  8 501831;
burst8_small_hf: proxy tree rss peak during burst = 1281 MB (+1172 MB), after = 204 MB
liveliness during burst8_small_hf: probes=16 ok=16 p50=2.2ms p90=38.2ms p99=43.4ms max=43.4ms mean=8.6ms
burst16_small_hf: proxy tree rss before = 93 MB (pids: 68524 68857 68858 69035 68859 69036 )
burst16_small_hf: 16 concurrent requests:  1 200 2.998540; 1 200 3.057706; 1 200 3.192300; 1 200 3.268447; 1 200 3.317537; 1 200 3.654467; 1 200 3.751555; 1 200 3.876097; 1 200 6.460501; 1 200 6.686890; 1 200 6.871119; 1 200 6.872732; 1 200 6.877423; 1 200 6.917335; 1 200 7.703628; 1 200 7.764119; counts:  16 501831;
burst16_small_hf: proxy tree rss peak during burst = 1213 MB (+1120 MB), after = 134 MB
liveliness during burst16_small_hf: probes=31 ok=31 p50=5.5ms p90=93.6ms p99=183.4ms max=183.4ms mean=33.3ms
burst8_small_hf_again: proxy tree rss before = 91 MB (pids: 68524 68857 68858 69035 68859 69036 )
burst8_small_hf_again: 8 concurrent requests:  1 200 1.679350; 1 200 1.688438; 1 200 2.189176; 1 200 2.298222; 1 200 2.300397; 1 200 2.324945; 1 200 2.943603; 1 200 2.946448; counts:  8 501831;
burst8_small_hf_again: proxy tree rss peak during burst = 1324 MB (+1233 MB), after = 945 MB
liveliness during burst8_small_hf_again: probes=21 ok=21 p50=1.9ms p90=34.0ms p99=48.0ms max=48.0ms mean=10.8ms
liveliness during baseline_end: probes=123 ok=123 p50=1.2ms p90=1.7ms p99=4.5ms max=24.7ms mean=1.5ms

For comparison, the first offloaded build of this branch (d202885, counts on the shared 40-thread pool with nothing else bounding them) took the eight-at-once burst to proxy tree rss peak during burst = 1753 MB (+1584 MB), after = 1591 MB with the requests all answered inside 1.3 s; the limiter trades some of that parallelism (eight requests now finish in 1.2 s to 2.1 s, sixteen in 3.0 s to 7.8 s) for a bounded peak

Reading the two sides

  • One worker: every Before leg stalls the loop, every After leg stays flat
  • Two workers: single requests hide behind the free worker, bursts and over-cap strings do not
  • Claude Code /context and the 2.4 MB retry loop stay live on the tip
  • Counts, statuses, and cache routing are identical on both sides
  • Over-cap strings answer 0.2% under the exact count

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • Neither cited reporter's path is touched by the Claude tokenizer fix: a Bedrock Claude id selects tiktoken, which fix(token_counter): stop large token counts from blocking the proxy event loop #37697 (v1.99.0) already moved off the loop, so for a Bedrock-only gateway the count_tokens stall is fixed by upgrading past v1.99.0; what this PR adds on Bedrock deployments is the failed-request and itpm counts. The Claude tokenizer fix applies to deployments whose model is a bare Claude id after the provider prefix (Anthropic direct, Vertex, custom tokenizers)
  • Strings above the cap are approximated silently, with nothing in the response saying so, and the same estimate feeds spend logs and rate-limit reservations (0.2% low on the 4.6 MB prompt above). The cap is per string, so a payload of many sub-cap strings still counts in full, in worker threads. Raising TOKEN_COUNTER_MAX_EXACT_CHARS trades the estimate back for worker time
  • Counts that used to run one at a time under the GIL now run up to four at a time per worker, so a burst of large counts peaks higher in memory: eight 2 MB prompt strings at once peaked the proxy tree at +874 MB on one worker and +1075 MB to +1172 MB on two workers here, against +561 MB and +444 MB to +556 MB at the merge base (the burst legs above). TOKEN_COUNTER_MAX_CONCURRENT_COUNTS=1 brings back the serial memory profile while keeping the loop free
  • Sync SDK callers (litellm.token_counter, litellm.completion's own pre-call and failure counts) get the GIL-free encode with no limiter: eight threads counting 2.4 MB each now run on all cores (9.6 s of CPU in 2.4 s of wall time against 5.4 s in 5.8 s at the merge base) and peaked at +1200 MB against +145 MB. Only the proxy's offloaded paths are bounded
  • The project ITPM count and the prompt caching check's two counts now off the loop are covered by their lag tests, not by a liveliness leg: the prompt caching check ran live on both sides (the cache writes then reads, above) but with no probe during its counts, and the project ITPM path needs a DB-backed project this rig does not have

Low

  • The count limiter is per worker process (and per event loop within it), so a deployment's memory ceiling for a burst of large counts scales with its worker count: eight 2 MB counts on two workers still all run at once (+1.1 GB to +1.2 GB above), and only a burst beyond TOKEN_COUNTER_MAX_CONCURRENT_COUNTS per worker queues
  • A custom HuggingFace tokenizer object without encode_batch_fast fails the count, and create_pretrained_tokenizer's identifier fallback for such objects is gone with it
  • The first HuggingFace encode in a process starts the tokenizer's own thread pool, about 20 idle OS threads per worker that stay for the life of the process (the sync SDK probe went from 1 thread to 21)
  • The lag tests assert max(lags) < took / 4, a timing bound a very slow CI runner could trip; it passed on three consecutive runs here with the merge base failing 8 of 9 unambiguously
  • The streaming chunk builder still counts inline when a stream carries no usage (LIT-7190)
  • The context-management editors, the compression interception hook, the Anthropic passthrough's interrupted-stream re-tokenize, the A2A usage counter, and the SDK-only litellm.acount_tokens and litellm.utils.encode still count inline, and encode is uncapped (LIT-7190)
  • The new tests import offload_token_count, so at the merge base they fail by ImportError rather than by the assertion; the lag assertions themselves fail there when the import is stubbed (8 of 9 legs)
  • Three CircleCI jobs are red at the tip on tests this PR does not touch (test_completion_novita_ai, test_azure_gpt_4o_with_tool_call_and_response_format, test_openai_max_retries_0, test_openai_o_series_max_retries_0, each a provider answering with no choices), and the same tests are red on litellm_internal_staging's own pipeline; none of them is a required check
  • osv-scan is red on smol-toml 1.6.1 in ui/litellm-dashboard/package-lock.json (GHSA-7w5x-hrqm-74c2, a dev dependency), which this PR does not touch; it is red on staging's own runs for the same reason and is not a required check

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes rate-limit reservations, spend on failures, and token estimates for oversized prompts; very long inputs become sampled approximations without signaling in responses.

Overview
Stops large HuggingFace and proxy-side token counts from blocking the asyncio event loop, and bounds how much text is fully tokenized per string.

Offloading: Adds offload_token_count, which runs sync counters on worker threads behind a per-event-loop anyio.CapacityLimiter (TOKEN_COUNTER_MAX_CONCURRENT_COUNTS, default 4). Proxy/router paths that used to count inline or on the shared thread pool now use this helper—including /utils/token_counter, project/deployment ITPM reservations, failure-hook usage lifting, prompt-caching checks, and router pre-call token checks. HuggingFace counting switches to encode_batch_fast so encoding can release the GIL; custom Hub tokenizers load with token= instead of the rejected auth_token keyword.

Caps: Strings longer than TOKEN_COUNTER_MAX_EXACT_CHARS (default 4M chars) are estimated from 16 evenly spaced samples and scaled by length. New tests assert the loop stays responsive, concurrency/env limits are honored, and extrapolation stays within ~1% of exact counts.

Reviewed by Cursor Bugbot for commit c1ca963. Bugbot is set up for automated code reviews on this repo. Configure here.

…act counting per string

Both proxy token counting endpoints already count in a worker thread, but the
HuggingFace tokenizer's encode holds the GIL for the whole call, so a 600k-token
count on a Claude model still froze the event loop for up to 0.8 s and every
other request with it. Count through encode_batch_fast, which releases the GIL,
and tokenize at most TOKEN_COUNTER_MAX_EXACT_CHARS characters of any one string
(default 4,000,000), scaling the exact count of that prefix by the string's
length above it so the largest payloads stay bounded.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@codspeed

codspeed Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit_5546_count_tokens_offload (c1ca963) with litellm_internal_staging (b22ca7a)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents large token counts from blocking proxy event loops. It moves relevant proxy counting paths onto a dedicated, bounded worker-thread limiter, uses the HuggingFace batch encoder, caps exact counting through evenly distributed sampling, and fixes custom tokenizer loading. The latest change scopes the limiter to each event loop so processes using several loops do not share incompatible AnyIO synchronization state

Confidence Score: 5/5

The PR appears safe to merge, with the previous blocking findings fixed or withdrawn and no new actionable issue found

The latest limiter change gives every event loop a compatible dedicated limiter while retaining bounded concurrency among proxy requests on that loop. Tests cover shared-pool isolation, concurrency bounds, multi-loop completion, sampling, and event-loop responsiveness. The prefix-sampling, worker-pool starvation, parameter typing, and coarse test type findings are fixed; the synchronous SDK finding was withdrawn after the proxy boundary was clarified

Important Files Changed

Filename Overview
litellm/litellm_core_utils/token_counter.py Adds capped token-count extrapolation, GIL-friendly HuggingFace encoding, and a dedicated per-event-loop offload limiter
litellm/proxy/proxy_server.py Offloads custom tokenizer loading and endpoint token counting from the event loop
litellm/proxy/utils.py Moves failure-path usage counting onto the bounded token-count worker pool
litellm/router.py Offloads router pre-call token estimation through the dedicated limiter
litellm/router_utils/pre_call_checks/io_token_rate_limit_check.py Moves deployment ITPM input estimation off the event loop
litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py Offloads prompt-caching eligibility counts while preserving routing behavior
tests/test_litellm/litellm_core_utils/test_token_counter.py Covers event-loop responsiveness, sampling bounds, limiter isolation, configuration, and multiple event loops

Reviews (10): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread litellm/litellm_core_utils/token_counter.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Devin e2e QA

Tested at: 486af36
Session: https://app.devin.ai/sessions/df7c428ee36c4ff4a5fc00c5ee0bf140

Shared setup. One proxy per leg, started from a checkout of the commit named in the heading with .venv/bin/python litellm/proxy/proxy_cli.py --config config.yaml --port 28283 (port confirmed free beforehand, no debug flags), with this config:

model_list:
  - model_name: anthropic-badkey
    litellm_params:
      model: anthropic/claude-fable-5
      api_key: sk-ant-bogus-key-for-fallback-leg

general_settings:
  master_key: sk-1234

The bogus key makes the Anthropic CountTokens call fail, so /v1/messages/count_tokens falls back to the local Claude tokenizer, the same path the customer's gateway takes when the provider cannot count for it. /utils/token_counter counts locally on the same tokenizer

Payloads were built from repository source files: badkey_utils.json is {"model": "anthropic-badkey", "messages": [...]} with 55 alternating user and assistant messages (2.4 MB); badkey_messages.json is the Anthropic Messages shape with a 6 KB system prompt, 6 tools, and the same messages for /v1/messages/count_tokens; prompt_badkey_utils.json is the same text as one 2,405,521-character prompt string

Each case runs leg, which posts the payload once and hits /health/liveliness every 20 ms for as long as the count is in flight, then prints the count's status and time and the p50/p90/p99/max of the probes taken during it. baseline is the probe loop alone for 4 s

leg() {
  NAME=$1; URL=$2; PAYLOAD=$3; OUT=runs/$NAME; rm -rf $OUT; mkdir -p $OUT
  ( sleep 0.5; curl -s -o $OUT/count_response.json -w '%{http_code} %{time_total}\n' -X POST http://127.0.0.1:28283$URL \
      -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' --data-binary @$PAYLOAD > $OUT/count_status.txt ) &
  CPID=$!; i=0
  while kill -0 $CPID 2>/dev/null; do
    curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://127.0.0.1:28283/health/liveliness > $OUT/$i &
    i=$((i+1)); sleep 0.02
  done
  wait
  echo "count_tokens: http_code time_total = $(cat $OUT/count_status.txt)"
  echo "response: $(head -c 160 $OUT/count_response.json)"
  echo -n "liveliness during $NAME: "; python3 stats.py $OUT
}

The last case restarts the proxy with TOKEN_COUNTER_MAX_EXACT_CHARS=100000 in its environment and posts the same 2,405,521-character prompt

Before (7da6fe5)

Captured at the merge base with litellm_internal_staging

baseline

  1. leg baseline
  2. Output:
    liveliness during baseline: probes=189 ok=189 p50=1.2ms p90=1.4ms p99=1.8ms max=2.6ms mean=1.2ms
    

/utils/token_counter, Claude tokenizer, messages

  1. leg utils_badkey /utils/token_counter badkey_utils.json
  2. Output:
    count_tokens: http_code time_total = 200 0.871681
    response: {"total_tokens":595761,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during utils_badkey: probes=66 ok=66 p50=73.3ms p90=201.0ms p99=240.4ms max=244.2ms mean=87.2ms
    

/utils/token_counter, Claude tokenizer, one 2.4M-character prompt string, default cap

  1. leg utils_prompt_badkey /utils/token_counter prompt_badkey_utils.json
  2. Output:
    count_tokens: http_code time_total = 200 1.036068
    response: {"total_tokens":596532,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during utils_prompt_badkey: probes=73 ok=73 p50=263.0ms p90=862.9ms p99=987.9ms max=1007.8ms mean=342.3ms
    

/v1/messages/count_tokens, provider count fails, local Claude tokenizer fallback

  1. leg msgs_badkey /v1/messages/count_tokens badkey_messages.json
  2. Output:
    count_tokens: http_code time_total = 200 0.965916
    response: {"input_tokens":597054}
    liveliness during msgs_badkey: probes=70 ok=70 p50=15.2ms p90=220.1ms p99=314.0ms max=321.4ms mean=73.3ms
    

/utils/token_counter, same prompt string, TOKEN_COUNTER_MAX_EXACT_CHARS=100000

  1. TOKEN_COUNTER_MAX_EXACT_CHARS=100000 .venv/bin/python litellm/proxy/proxy_cli.py --config config.yaml --port 28283
  2. leg cap_100k_prompt_badkey /utils/token_counter prompt_badkey_utils.json
  3. Output (the variable does not exist at this commit, so the whole string is still counted exactly and the freeze is unchanged):
    count_tokens: http_code time_total = 200 1.197380
    response: {"total_tokens":596532,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during cap_100k_prompt_badkey: probes=81 ok=81 p50=283.3ms p90=943.1ms p99=1085.8ms max=1106.4ms mean=376.3ms
    

After (486af36)

baseline

  1. leg baseline
  2. Output:
    liveliness during baseline: probes=189 ok=189 p50=1.1ms p90=1.2ms p99=1.4ms max=1.8ms mean=1.1ms
    

/utils/token_counter, Claude tokenizer, messages

  1. leg utils_badkey /utils/token_counter badkey_utils.json
  2. Output:
    count_tokens: http_code time_total = 200 0.648919
    response: {"total_tokens":595761,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during utils_badkey: probes=55 ok=55 p50=1.1ms p90=1.6ms p99=36.6ms max=56.3ms mean=4.0ms
    

/utils/token_counter, Claude tokenizer, one 2.4M-character prompt string, default cap

  1. leg utils_prompt_badkey /utils/token_counter prompt_badkey_utils.json
  2. Output (2,405,521 characters is under the 4,000,000 default, so the count is exact and matches Before):
    count_tokens: http_code time_total = 200 0.736016
    response: {"total_tokens":596532,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during utils_prompt_badkey: probes=60 ok=60 p50=1.1ms p90=1.4ms p99=7.3ms max=14.8ms mean=1.6ms
    

/v1/messages/count_tokens, provider count fails, local Claude tokenizer fallback

  1. leg msgs_badkey /v1/messages/count_tokens badkey_messages.json
  2. Output:
    count_tokens: http_code time_total = 200 0.691286
    response: {"input_tokens":597054}
    liveliness during msgs_badkey: probes=57 ok=57 p50=1.2ms p90=1.6ms p99=3.8ms max=5.1ms mean=1.4ms
    

/utils/token_counter, same prompt string, TOKEN_COUNTER_MAX_EXACT_CHARS=100000

  1. TOKEN_COUNTER_MAX_EXACT_CHARS=100000 .venv/bin/python litellm/proxy/proxy_cli.py --config config.yaml --port 28283
  2. leg cap_100k_prompt_badkey /utils/token_counter prompt_badkey_utils.json
  3. Output (the first 100,000 characters count to 28,070 tokens; 28070 * 2405521 / 100000 = 675230, so the string was counted from its prefix and scaled by its length):
    count_tokens: http_code time_total = 200 0.158204
    response: {"total_tokens":675230,"request_model":"anthropic-badkey","model_used":"claude-fable-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error
    liveliness during cap_100k_prompt_badkey: probes=32 ok=32 p50=1.2ms p90=21.1ms p99=60.4ms max=60.4ms mean=6.8ms
    

@codecov

codecov Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…les instead of the prefix

A string above TOKEN_COUNTER_MAX_EXACT_CHARS was counted from its first cap characters and scaled, so a string whose start tokenizes unlike its end got a skewed count, and that count reaches fallback billing when the provider sends no usage. The estimate now tokenizes 16 evenly spaced samples that together total the cap and scales their sum by the string's length, keeping the same bound on work while tracking the whole string
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread tests/test_litellm/litellm_core_utils/test_token_counter.py Outdated
@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 8, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 8, 2026
The failure hook's usage estimate and the project ITPM reservation both
called litellm.token_counter inline on the event loop, so a large request
that failed or hit the limiter stalled the gateway the same way the
count_tokens endpoints did. Both now run through asyncify. The loop-lag
probe the existing tests used moves into a shared helper that warms the
tokenizer first, and two new tests fail when either count runs inline
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 8, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 8, 2026
@mateo-berri mateo-berri removed the run-ci label Sep 8, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

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

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/litellm_core_utils/token_counter.py Outdated

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

Stale Bugbot comment from a previous run.

…acity limiter

Replace the thread semaphore around HuggingFace encodes with an anyio CapacityLimiter
applied at every offloaded count site through offload_token_count, so waiting counts no
longer hold slots in the shared 40-thread pool and inline counts on the event loop never
block on the bound. Rename TOKEN_COUNTER_MAX_CONCURRENT_HF_ENCODES to
TOKEN_COUNTER_MAX_CONCURRENT_COUNTS
@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment on lines 602 to +603
def count_tokens(text: str) -> int:
enc: Final = tokenizer_json["tokenizer"].encode(text)
return len(enc.ids)
return len(tokenizer.encode_batch_fast([text])[0])

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.

P1 Synchronous Counts Bypass Limit

Synchronous HuggingFace token-counter callers now bypass concurrency control, so concurrent SDK counts can again multiply tokenizer memory usage

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

tiktoken, the default path, already releases the GIL and runs uncapped from SDK threads; proxy counts are bounded by the limiter and per-string sampling

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.

You’re right—I conflated the synchronous SDK API with the proxy paths this PR is changing. The proxy call sites use offload_token_count, so HuggingFace counts are bounded by the per-worker limiter; the default synchronous tiktoken path is already GIL-free and intentionally remains uncapped for SDK callers. I’m withdrawing this as a blocking finding.

Comment thread tests/test_litellm/proxy/hooks/test_tpm_concurrent.py Outdated

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

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

A single process-wide anyio.CapacityLimiter wakes waiters with the
asyncio.Event of whichever loop created it, so a fifth loop in another
thread (asyncio.run per request under Celery, gunicorn sync workers, or
run_async_function from function_with_fallbacks) waited forever once four
counts were in flight, and building it at import time raised
AsyncLibraryNotFoundError on anyio below 4.2. The limiter now lives in a
RunVar and is created on first use inside the running loop.

TOKEN_COUNTER_MAX_CONCURRENT_COUNTS reads from the environment like
TOKEN_COUNTER_MAX_EXACT_CHARS, and the proxy's tokenizer lookup runs on the
shared thread pool so a Hub download no longer holds a count slot.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 10, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c1ca963. Configure here.

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant