fix(proxy): return provider auth errors from /v1/messages/count_tokens instead of masking or 500 - #38902
fix(proxy): return provider auth errors from /v1/messages/count_tokens instead of masking or 500#38902mateo-berri wants to merge 6 commits into
Conversation
…s instead of masking or 500
Greptile SummaryThis PR surfaces provider token-count authentication failures through the Anthropic count-tokens endpoint while retaining local fallback for other provider failures
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/proxy/proxy_server.py | Changes provider token-count handling so 401 results abort rather than entering the local tokenizer fallback |
| litellm/proxy/anthropic_endpoints/endpoints.py | Maps exceptions carrying valid HTTP statuses into Anthropic error envelopes |
| tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py | Adds endpoint regression coverage for returned and raised authentication failures |
| tests/test_litellm/proxy/test_proxy_server.py | Adds unit coverage distinguishing provider 401 failures from fallback-eligible statuses |
| tests/proxy_unit_tests/test_proxy_token_counter.py | Makes the existing provider-detection test deterministic by replacing its external Anthropic request |
Reviews (5): Last reviewed commit: "fix(proxy): only a provider 401 aborts t..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it? I read the description against our contribution rubric. Here's how it lined up: What you got right:
What's still missing:
If the description isn't updated in the next 2 hours, I'll auto-close this PR. That's not us saying we don't care about the change; we want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later," not a rejection. Take your time; everything below still works after the close. During the grace period: just update the PR description with the missing pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close if it now passes. See what counts as QA proof for the full rubric (a linked issue alone isn't enough; it covers context, not proof). If the PR does get auto-closed in 2 hours, you still have easy recovery paths:
Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer. (I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a maintainer; they'll override me.) |
|
🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it? I read the description against our contribution rubric. Here's how it lined up: What you got right:
What's still missing:
Closing this PR isn't a rejection of the change. We want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later"; your work is still here, the diff is still here, and getting it reopened is one comment away. Take your time. To bring this PR back:
What "end-to-end QA proof" means, since it's the most common gap: at least one of a short before/after screen recording / video (the bug reproducing, then the fix working; for a brand-new feature, a recording of it working end-to-end), a screenshot (or before/after screenshots) of it working, or the exact commands you ran paired with their real output against the real system. Running Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer. (I'm an LLM, so I'm not infallible. If you think I got this wrong, comment |
|
bugbot run |
There was a problem hiding this comment.
✅ 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 c79776d. Configure here.
…itellm_fix_count_tokens_auth_500
|
bugbot run |
There was a problem hiding this comment.
✅ 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 70cb373. Configure here.
…/litellm into litellm_fix_count_tokens_auth_500 # Conflicts: # tests/test_litellm/proxy/test_proxy_server.py
…local fallback A Bedrock API key that can invoke models but lacks bedrock:CountTokens answers the count call with 403. Raising there turned every count-tokens surface into a 403 while sending kept working, so a 403 now falls back to the local estimate like every other non-auth failure.
|
bugbot run |
There was a problem hiding this comment.
✅ 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 2d96db3. Configure here.
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer whose Anthropic-SDK app counts tokens through the proxy sees the provider's refusal hidden, as a silent 200 on a revoked Anthropic key and a bare 500 when the refusal raises
{"model": "claude-haiku-4-5", "messages": [{"role": "user", "content": "count these tokens please"}]}{"input_tokens": 11}: a locally estimated count with nothing marking it as such{"type":"error","error":{"type":"authentication_error",...}}, so counting and sending disagree about whether the credential works{"detail":{"error":"Internal server error: litellm.APIError: Google Gen AI Studio API error: 400 - ... API key not valid ..."}}, and their SDK reports a proxy-side internal error rather than the provider's refusalAfter: the same requests answer with the provider's real status and the Anthropic error envelope, matching /v1/messages, so the app surfaces the refusal
{"model": "claude-haiku-4-5", "messages": [{"role": "user", "content": "count these tokens please"}]}{"detail":{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."}}}, so the SDK raises its normalAuthenticationErrorinvalid_request_errorenvelope carrying Google's "API key not valid" text instead of a 500Relevant issues
Linear ticket
Resolves LIT-6507
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
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@greptileaito 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
All runs below are live proxies hitting the real provider APIs with real spend, no mocks. Each side is its own git worktree booting
python litellm/proxy/proxy_cli.py --config <config> --num_workers 2(one process, two uvicorn workers, no database). The client is the Anthropic Python SDK 1.1.0 plus raw curl; SDK and curl agreed at every step, so each step shows whichever is clearer. Shared payloadMSG = [{"role": "user", "content": "count these tokens please"}], curl body{"model":"<model>","messages":[{"role":"user","content":"count these tokens please"}]}with-H 'Authorization: Bearer <master key>' -H 'content-type: application/json'The main config defines
claude-bad-key(real model, revoked Anthropic key),claude-good(real key), andclaude-typo-model(anthropic/claude-nonexistent-model-lit6507on the real key). Before runs at the merge base 8c58b93 on port 58248, After at 2d96db3 on port 35458. The federation case uses a second proxy pair on the workload-identity-federation branch (PR #38818): Before is that branch at c0739de (port 54636), After is local merge ee3a23cedc of this PR's tip with that same sha (port 22204; only test files conflicted, every runtime file auto-merged). Its config definesclaude-wif-fed, whose federation token exchange Anthropic rejectsBefore (8c58b93)
count_tokens with a revoked Anthropic key
curl -s -i -X POST http://127.0.0.1:58248/v1/messages/count_tokens ... -d '{"model":"claude-bad-key",...}'HTTP/1.1 200 OK{"input_tokens":11}: a silent local estimate, nothing marks the dead credentialclient.messages.count_tokens(model="claude-bad-key", messages=MSG)returnsMessageTokensCount(input_tokens=11)count_tokens controls: good key and typo model
count_tokens(model="claude-good")returnsMessageTokensCount(input_tokens=11)(real provider count)claude-typo-model(Anthropic answers 404):HTTP/1.1 200 OK{"input_tokens":11}/utils/token_counter with call_endpoint=true
curl -s -i -X POST 'http://127.0.0.1:58248/utils/token_counter?call_endpoint=true' ... -d '{"model":"claude-bad-key",...}'HTTP/1.1 200 OK{"total_tokens":11,"request_model":"claude-bad-key","model_used":"claude-haiku-4-5","tokenizer_type":"huggingface_tokenizer","original_response":null,"error":false,"error_message":null,"status_code":null}: auth failure reported aserror: falseclaude-good:HTTP/1.1 200 OK,"tokenizer_type":"anthropic_api","total_tokens":11Google countTokens route
curl -s -i -X POST 'http://127.0.0.1:58248/v1beta/models/claude-bad-key:countTokens' ... -d '{"contents":[{"role":"user","parts":[{"text":"count these tokens please"}]}]}'HTTP/1.1 200 OK{"totalTokens":11,"promptTokensDetails":[]}/v1/messages agreement and recount after the 401
client.messages.create(model="claude-bad-key", max_tokens=16, messages=MSG)raisesAuthenticationError401 (authentication_error, "API key is invalid."), so sending already surfaces the failure that counting hidesHTTP/1.1 200 OK{"input_tokens":11}Paid completion on the good key
create(model="claude-good", max_tokens=16, messages=MSG)returnsMessage(id='msg_011CefaNfPnVDknNUwkPzuAj', ..., usage=Usage(input_tokens=11, output_tokens=16))Workload identity federation deployment (#38818 at c0739de)
curl -s -i -X POST http://127.0.0.1:54636/v1/messages/count_tokens ... -d '{"model":"claude-wif-fed",...}'HTTP/1.1 200 OK{"input_tokens":11}(SDK:MessageTokensCount(input_tokens=11)): that branch's counter skips the provider call when it finds no static credential and hands back the local estimatecreate(model="claude-wif-fed")raisesAuthenticationError401 ("x-api-key header is required"), so sending and counting disagree here tooAfter (2d96db3)
count_tokens with a revoked Anthropic key
curl -s -i -X POST http://127.0.0.1:35458/v1/messages/count_tokens ... -d '{"model":"claude-bad-key",...}'HTTP/1.1 401 Unauthorized{"detail":{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null}}count_tokens(model="claude-bad-key")raisesAuthenticationError401 with the same envelopecount_tokens controls: good key and typo model
count_tokens(model="claude-good")returnsMessageTokensCount(input_tokens=11), unchangedclaude-typo-model:HTTP/1.1 200 OK{"input_tokens":11}, unchanged: the non-auth local fallback survives/utils/token_counter with call_endpoint=true
claude-bad-keyHTTP/1.1 401 Unauthorized{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"API key is invalid.\"},\"request_id\":null}","type":"token_counting_error","param":"model","code":"401"}}claude-good:HTTP/1.1 200 OK,"tokenizer_type":"anthropic_api","total_tokens":11, unchangedGoogle countTokens route
claude-bad-keyHTTP/1.1 401 Unauthorized{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"API key is invalid.\"},\"request_id\":null}","type":"token_counting_error","param":"model","code":"401"}}/v1/messages agreement and recount after the 401
create(model="claude-bad-key")raisesAuthenticationError401, unchangedHTTP/1.1 401 Unauthorizedwith the same envelope as the first case, so counting and sending agreePaid completion on the good key
create(model="claude-good", max_tokens=16, messages=MSG)returnsMessage(id='msg_011CefcPsK9tvF4EzntDfLgB', ..., usage=Usage(input_tokens=11, output_tokens=16))Workload identity federation deployment (#38818 merged with this tip, ee3a23cedc)
curl -s -i -X POST http://127.0.0.1:22204/v1/messages/count_tokens ... -d '{"model":"claude-wif-fed",...}'HTTP/1.1 200 OK{"input_tokens":11}(SDK:MessageTokensCount(input_tokens=11)), unchanged: the counter never consults the provider on that branch, so there is no 401 result for this PR to surfacecreate(model="claude-wif-fed")still raisesAuthenticationError401Blast radius on the sibling surfaces and other providers
Same two-worker no-database boot, base 8c58b93 on port 41917 vs head 2d96db3 on port 47263, curl body
{"model":"<model>","messages":[{"role":"user","content":"Hello Claude, count me"}]}on the two Anthropic-shaped routes and{"contents":[{"role":"user","parts":[{"text":"Hello Claude, count me"}]}]}on the Google one.claude-no-keyisanthropic/claude-haiku-4-5with no key at all,bedrock-api-keyis a real Bedrock API key (bearer token) that can invoke models but lacksbedrock:CountTokens,bedrock-bad-credsis bogus SigV4 keys,gemini-bad-keyandgemini-goodaregemini/gemini-3.8-flashwith a bogus and a real Google keyThe rows that differ, verbatim:
claude-bad-keycount_tokens, base:HTTP 200{"input_tokens":12}; head:HTTP 401{"detail":{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null}}claude-bad-keytoken_counter and countTokens, head:HTTP 401{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"API key is invalid.\"},\"request_id\":null}","type":"token_counting_error","param":"model","code":"401"}}gemini-bad-keycount_tokens, base:HTTP 500{"detail":{"error":"Internal server error: litellm.APIError: Google Gen AI Studio API error: 400 - {... \"message\": \"API key not valid. Please pass a valid API key.\", \"status\": \"INVALID_ARGUMENT\" ...}"}}; head:HTTP 400{"detail":{"type":"error","error":{"type":"invalid_request_error","message":"litellm.APIError: Google Gen AI Studio API error: 400 - {... \"API key not valid. Please pass a valid API key.\" ...}"}}}gemini-goodcount_tokens, base:HTTP 500and head:HTTP 400with the same envelope shapes around Google's"CountTokens requires generate_content_request or contents to be set."(a pre-existing counter bug on Anthropic-format input, see below)Bedrock answers a Bedrock API key without
bedrock:CountTokensand a dead SigV4 credential with the same 403, so both keep the local fallback on both sides. An earlier head of this PR raised on 403 too and turned the first case into a 403 on every count surface while sending kept working; 2d96db3 narrows the gate to 401Surprises observed while QA'ing, all pre-existing on both sides:
claude-good: 200 yet totalTokens 0detailType
🐛 Bug Fix
Caveats (if any)
Medium
Low
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
2d96db3 passes /live-pr-risk
Note
Medium Risk
Changes error semantics on count-tokens surfaces (including
/utils/token_counterwithcall_endpoint=true); auth failures surface correctly but brief post-401 router cooldown can still yield a masked 200 local count.Overview
Fixes LIT-6507 so Anthropic-style
count_tokensbehaves like/v1/messageswhen credentials fail: clients get a 401 with the Anthropic error envelope instead of a 200 with a silent local estimate or a generic 500.In
proxy_server, provider token-count failures that returnstatus_code == 401now raiseProxyException(same path as whendisable_token_counteris on), so auth errors are not swallowed by the local tokenizer fallback. 429, 403, and other non-auth provider errors still fall back locally.In
anthropic_endpointscount_tokens, uncaught exceptions that carry an HTTPstatus_code(4xx–5xx) are mapped throughAnthropicExceptionMappinginstead of always returning 500—covering cases likeAuthenticationErrorfrom federation token exchange.Tests were added/updated for endpoint mapping,
_try_provider_token_count, and Anthropic provider detection mocking.Reviewed by Cursor Bugbot for commit 2d96db3. Bugbot is set up for automated code reviews on this repo. Configure here.