Skip to content

feat(proxy)!: return Anthropic-shaped errors on /v1/messages - #30385

Open
songkuan-zheng wants to merge 9 commits into
BerriAI:litellm_internal_stagingfrom
GhishaDev:fix/anthropic-error-passthrough
Open

feat(proxy)!: return Anthropic-shaped errors on /v1/messages#30385
songkuan-zheng wants to merge 9 commits into
BerriAI:litellm_internal_stagingfrom
GhishaDev:fix/anthropic-error-passthrough

Conversation

@songkuan-zheng

@songkuan-zheng songkuan-zheng commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

No existing issue. This PR fixes the /v1/messages error envelope so Anthropic SDK clients can switch on error.error.type as documented in the Anthropic API spec.

Type

🐛 Bug Fix

Pre-Submission checklist

  • I have added a test for my change
  • I have updated relevant documentation (N/A — wire-protocol alignment)
  • My change passes make test locally
  • My change adheres to the existing code style

Description

The Anthropic-compatible /v1/messages endpoint returned OpenAI-shaped error bodies ({"error": {message, type, param, code}}) instead of Anthropic-shaped ({"type": "error", "error": {type, message}}), breaking Anthropic SDK clients that switch on error.error.type. The message also leaked stacked LiteLLM class-name prefixes and an escaped upstream JSON body (litellm.RateLimitError: AnthropicException - {...}).

Changes

1. AnthropicExceptionMapping

  • Add _strip_litellm_wrapper_prefixes() to peel stacked litellm.<Class>: and <Provider>Exception - prefixes, and call it inside transform_to_anthropic_error() so an embedded upstream Anthropic error body is detected and passed through unchanged (preserving the real error.type enum instead of deriving it from the HTTP status).
  • Use json.JSONDecoder.raw_decode as a fallback when safe_json_loads rejects the message. The Router appends debug suffixes after the upstream Anthropic JSON ({"type":"error",...}. Received Model Group=...) that break strict JSON parsing — raw_decode parses the leading object and ignores the trailing garbage.
  • Extract nested error.message from OpenAI-compat upstream bodies ({"error":{"code","message","type"}}, used by OpenAI, the new-api proxy, and many OpenAI-compat gateways) so the result is the inner human message instead of the full stringified JSON.

2. /v1/messages error path

Return JSONResponse with the Anthropic body instead of raising ProxyException. ProxyException routes through the global OpenAI-shaped handler; JSONResponse is required (not HTTPException, which wraps the dict in a spurious {"detail": ...}).

3. count_tokens

Same root-cause fix — its existing HTTPException(detail=...) was double-wrapping the Anthropic body in {"detail": ...}. Switch to JSONResponse. Framework-level 400s for missing model/messages stay as HTTPException.

x-litellm-* response headers are preserved on the error path.

Breaking change marker (!)

Clients that parsed error.param / error.code on this Anthropic-compat endpoint will no longer see those OpenAI-only fields. Existing fields (error.type, error.message) remain.

Test plan

  • Prefix-stripper + wrapped-passthrough unit tests
  • /v1/messages TestClient integration: asserts top-level shape, no detail wrapper, no param/code, headers present
  • count_tokens error-format tests updated to assert the JSONResponse body
  • Trailing-garbage JSON recovery via raw_decode
  • Nested OpenAI-compat error.message extraction

39 tests pass across:

  • tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py
  • tests/test_litellm/proxy/test_anthropic_error_passthrough.py
  • tests/proxy_unit_tests/test_proxy_token_counter.py

Co-authored-by: songkuan-zheng songkuan-zheng@users.noreply.github.com

@songkuan-zheng
songkuan-zheng requested a review from a team June 13, 2026 16:55
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.07692% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/anthropic_endpoints/endpoints.py 95.45% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the /v1/messages error envelope so it returns the Anthropic-specified shape ({"type":"error","error":{"type":…,"message":…}}) instead of the previous OpenAI-shaped ProxyException body, and strips the stacked litellm.<Class>: / <Provider>Exception - prefixes that leaked internal implementation details into client-visible messages.

  • AnthropicExceptionMapping: Adds _strip_litellm_wrapper_prefixes (anchored to a provider allowlist), a json.JSONDecoder.raw_decode fallback to recover Anthropic JSON that the Router has annotated with debug suffixes, and OpenAI-compat nested error.message extraction.
  • /v1/messages error path: Replaces raise ProxyException with return JSONResponse(Anthropic-body), extracting the HTTP code from ProxyException.code (string) in addition to .status_code (int), and guarding against message=None.
  • count_tokens: Converts all error exits (HTTPException, ProxyException, bare Exception, and early-return validation checks) to Anthropic-shaped JSONResponse, eliminating the FastAPI {"detail":…} wrapper on every exit path.

Confidence Score: 5/5

Safe to merge. All error exit paths on both /v1/messages and /v1/messages/count_tokens now return well-formed Anthropic-shaped responses, and every previously identified issue in the review thread has a matching fix and regression test in the current HEAD.

The error-handling logic in anthropic_response() correctly reads ProxyException.code (string) before falling back to .status_code (int), guards against message=None on both exception paths, and uses JSONResponse directly so the FastAPI {"detail":…} wrapper never appears. The count_tokens handler converts all three exception branches — HTTPException, ProxyException, and bare Exception — to Anthropic-shaped responses. The prefix-stripper regex is anchored to a curated provider allowlist rather than a generic \w+Exception pattern, so legitimate runtime error strings beginning with words like TimeoutException are not silently truncated. The 39-test suite covers passthrough, prefix stripping, trailing-garbage recovery, OpenAI-compat extraction, None-message coercion, and the ProxyException.code attribute path.

No files require special attention.

Important Files Changed

Filename Overview
litellm/anthropic_interface/exceptions/exception_mapping_utils.py Adds _strip_litellm_wrapper_prefixes (correctly anchored to a provider allowlist), a raw_decode fallback for Router-appended debug suffixes, and OpenAI-compat nested error.message extraction. Logic is well-tested and the provider list ordering (longer prefixes before shorter ones) is correct.
litellm/proxy/anthropic_endpoints/endpoints.py Replaces raise ProxyException with return JSONResponse on the /v1/messages error path, and converts all count_tokens error exits to Anthropic-shaped JSONResponse. All previously flagged concerns (ProxyException.code extraction, None-message guard, generic-Exception shape) are addressed in the current HEAD.
tests/test_litellm/proxy/test_anthropic_error_passthrough.py New integration test file covering all major error shapes via TestClient with monkeypatched auth and base_process_llm_request. No real network calls; all paths exercised with stubs.
tests/proxy_unit_tests/test_proxy_token_counter.py Updated existing tests from pytest.raises(HTTPException) to JSONResponse assertions; new tests added for HTTPException conversion, generic exception, ProxyException-with-None-message, and each missing-field path. Assertions are at least as strict as the originals.
tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py Adds unit tests for prefix stripping, trailing-garbage recovery via raw_decode, OpenAI-compat extraction, and upstream Anthropic passthrough. Coverage is thorough.
gateway/routes/allowlist.py Adds /api/event_logging/batch to the gateway allowlist so Claude Code telemetry requests are routed rather than rejected with 404. Trivial, low-risk change.

Reviews (9): Last reviewed commit: "chore: add Co-authored-by trailer for at..." | Re-trigger Greptile

Comment thread litellm/proxy/anthropic_endpoints/endpoints.py Outdated
Comment thread litellm/anthropic_interface/exceptions/exception_mapping_utils.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes /v1/messages to return Anthropic-shaped error bodies ({"type":"error","error":{"type":…,"message":…}}) instead of the OpenAI-shaped ProxyException envelope, and adds prefix-stripping + raw_decode fallback logic to AnthropicExceptionMapping so upstream Anthropic error envelopes are passed through with their original error.type preserved.

  • exception_mapping_utils.py gains _strip_litellm_wrapper_prefixes(), a raw_decode fallback for Router-appended debug suffixes, and nested error.message extraction for OpenAI-compat upstream bodies.
  • /v1/messages error path switches from raise ProxyException to return JSONResponse(anthropic_error, headers=headers), preserving x-litellm-* observability headers.
  • count_tokens's ProxyException handler switches from raise HTTPException(detail=…) to return JSONResponse(anthropic_error), but the trailing except Exception block still raises HTTPException with a non-Anthropic body.

Confidence Score: 3/5

The core fix is correct, but the change is unconditionally applied to an existing endpoint without an opt-out path, and the count_tokens endpoint still has an incomplete fix.

The /v1/messages endpoint now unconditionally returns a different error shape with no way for existing clients to retain the old behaviour. The count_tokens generic exception handler was missed, leaving that path still returning a FastAPI-wrapped non-Anthropic body. There is also a narrow but real crash path when e.message is None.

litellm/proxy/anthropic_endpoints/endpoints.py — both the missing feature-flag and the incomplete count_tokens fix are here.

Important Files Changed

Filename Overview
litellm/proxy/anthropic_endpoints/endpoints.py Switches /v1/messages and count_tokens error paths from ProxyException/HTTPException to JSONResponse with Anthropic-shaped bodies; generic Exception handler in count_tokens still returns non-Anthropic HTTPException, and message=None edge case can crash the new error handler.
litellm/anthropic_interface/exceptions/exception_mapping_utils.py Adds prefix-stripping, raw_decode fallback, and nested OpenAI-compat message extraction to transform_to_anthropic_error; logic is sound and well-tested.
tests/test_litellm/proxy/test_anthropic_error_passthrough.py New integration tests using TestClient validate Anthropic error shape, header preservation, and absence of OpenAI-only fields on the /v1/messages error path.
tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py New unit tests added for prefix stripping, raw_decode fallback, OpenAI-compat message extraction, and passthrough; comprehensive coverage of new logic.
tests/proxy_unit_tests/test_proxy_token_counter.py Tests updated to assert JSONResponse body instead of HTTPException.detail; assertions are strengthened (added 'detail' not in body check), not weakened.

Comments Outside Diff (2)

  1. litellm/proxy/anthropic_endpoints/endpoints.py, line 118-160 (link)

    P1 Breaking change without user-controlled opt-out flag

    The error response shape for /v1/messages is changed unconditionally. The team's rule requires a user-controlled flag for backwards-incompatible changes rather than a hard switch. Clients already deployed against this endpoint that parse error.param or error.code from the previous OpenAI-shaped response will start receiving null for those fields with no way to opt back in. A feature flag (e.g. litellm.anthropic_endpoint_return_anthropic_errors = True) would let existing integrations continue working while new ones opt in.

    Rule Used: What: avoid backwards-incompatible changes without... (source)

    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!

  2. litellm/proxy/anthropic_endpoints/endpoints.py, line 250-258 (link)

    P1 The generic except Exception handler in count_tokens still raises HTTPException with {"detail": {"error": "..."}}, which is a FastAPI-wrapped non-Anthropic shape. The PR only fixed the ProxyException path; unexpected internal errors on count_tokens will still return a different envelope than what the PR claims is fixed. This inconsistency means any non-ProxyException internal error (e.g. a token counter crash) bypasses the Anthropic formatting entirely.

Reviews (2): Last reviewed commit: "feat(proxy)!: return Anthropic-shaped er..." | Re-trigger Greptile

Comment thread litellm/proxy/anthropic_endpoints/endpoints.py Outdated
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite @krrish-berri-2 — heads up that the failing proxy-infra / Run tests job is a pre-existing upstream flake, not something this PR introduces. Asking to skip / dismiss the check for merge.

Evidence

The failing assertion is:

```
FAILED tests/test_litellm/proxy/test_component_allowlists.py::test_gateway_plus_backend_covers_full_app
AssertionError: 1 route(s) are not exposed on either component...
/api/event_logging/batch
assert not {'/api/event_logging/batch'}
```

  1. The route /api/event_logging/batch was added in commit d2bd029fa4 ([Fix] 404 Not Found on /api/event_logging/batch endpoint, PR [Fix] 404 Not Found on /api/event_logging/batch endpoint  #20504, ~4 months ago) — not by this PR. `gh pr view 30385 --json files` shows this PR touches only:

    • litellm/anthropic_interface/exceptions/exception_mapping_utils.py
    • litellm/proxy/anthropic_endpoints/endpoints.py (replaces raise HTTPException(detail=dict) with return JSONResponse(content=dict) to avoid the {detail: ...} envelope — no new routes)
    • 3 test files
  2. The route is not present in either gateway/routes/allowlist.py or backend/routes/allowlist.py on litellm_oss_branch HEAD (`ac7c2dc0d7`), so the assertion would fail on any PR that triggers the proxy-infra workflow today, regardless of content.

  3. Why upstream HEAD CI shows green: the Unit Tests: Proxy Infrastructure workflow (test-unit-proxy-infra.yml) has not run on litellm_oss_branch itself since 2026-04-27 — its trigger is PR-scoped, not branch-push-scoped, so no one notices the gap on main. Any PR touching the proxy surface area trips it.

This PR's base SHA equals litellm_oss_branch HEAD (ac7c2dc0d7) — rebasing won't help. The fix belongs in a separate one-line PR adding /api/event_logging/ to one of the allowlists; I can file it if helpful, but didn't want to bundle it here and create scope-creep.

Could the failing check be marked as not required for this PR, or the merge proceed once the other concerns are resolved? Happy to file the allowlist follow-up either way.

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the contribution! A couple of things to address before this is ready for merge:

  • Greptile's code review left 3 unresolved comment(s) that could use your attention — could you take a look and address them?

Once those are in, we'll take another look!

songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 16, 2026
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite — done in 45a34ff7b7. All 3 threads resolved:

P1 — ProxyException silently mapped to 500 (endpoints.py:160): the handler now reads .code first (string, ProxyException convention) before .status_code (int, litellm provider exceptions). Matches the count_tokens handler in the same module. A ProxyException(code="401", ...) now produces HTTP 401, not 500.

P2 — None .message crashes the strip-prefix regex (endpoints.py:151): getattr(e, "message", str(e)) returns None when .message is explicitly None; that None would crash re.sub downstream and yield a bare unhandled 500. Coerce None -> str(e) before passing to transform_to_anthropic_error.

P2 — _PROVIDER_EXCEPTION_PREFIX overly broad (exception_mapping_utils.py:22): switched from \w+Exception to an anchored allowlist of 47 known provider names (maintained from litellm/llms/** + common aliases). Generic TimeoutException - <real upstream body> / ConnectionException - <real upstream body> / RequestException - <real upstream body> are no longer silently stripped.

Tests added (test_anthropic_error_passthrough.py +3):

  • test_proxy_exception_code_attribute_is_honored
  • test_none_message_falls_back_to_str
  • test_provider_exception_prefix_does_not_strip_generic_timeout

42/42 pass locally.

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite @krrish-berri-2 — re-flagging the proxy-infra / Run tests failure on run 27594077988: it's a pre-existing route-allowlist test that fails on every recent run, not introduced by this PR.

The assertion:

E   AssertionError: 1 route(s) are not exposed on either component. Update gateway/routes/allowlist.py or backend/routes/allowlist.py to cover:
E       /api/event_logging/batch

Evidence this is unrelated:

  • The /api/event_logging/batch route is in litellm/proxy/anthropic_endpoints/endpoints.py:275 already on litellm_oss_branch HEAD — git diff upstream/litellm_oss_branch..HEAD -- litellm/proxy/anthropic_endpoints/endpoints.py returns nothing for that route. This PR (45a34ff7b7) only touches:
    • litellm/proxy/anthropic_endpoints/endpoints.py (the error-handler except Exception block — lines 145-160 — does not add any new routes)
    • litellm/anthropic_interface/exceptions/exception_mapping_utils.py (provider-prefix regex)
    • tests/test_litellm/proxy/test_anthropic_error_passthrough.py (3 new tests)
  • The allowlist that should cover this route lives in gateway/routes/allowlist.py / backend/routes/allowlist.py — those files are not in this PR's diff and not in the affected module.

Same pattern as the HF Hub flake on #29748 — please dismiss / re-run.

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for your contribution! A few things to get this ready:

  • Wrong base branch: This PR targets litellm_oss_branch but community PRs should target litellm_internal_staging. Could you rebase?
    git fetch origin
    git rebase --onto origin/litellm_internal_staging origin/litellm_oss_branch <your-branch>
    git push --force-with-lease
    
    Then update the base in GitHub's UI (Edit → Base: litellm_internal_staging).

We're also triggering a Greptile code review:

@greptileai

songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 17, 2026
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
@songkuan-zheng
songkuan-zheng force-pushed the fix/anthropic-error-passthrough branch from 45a34ff to 852ea9a Compare June 17, 2026 04:21
@songkuan-zheng
songkuan-zheng changed the base branch from litellm_oss_branch to litellm_internal_staging June 17, 2026 04:21
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite rebased onto upstream/litellm_internal_staging at 852ea9a; PR base updated. Clean rebase, no conflicts.

Short version of the fix: /v1/messages was returning OpenAI-shaped errors ({"error": {"message": ..., "type": ..., "code": ...}}) on auth failures, model-not-found, rate limits, etc. The native Anthropic SDK rejects that shape because Anthropic's spec is {"type": "error", "error": {"type": "...", "message": "..."}}. Calls that hit any error path crashed in the client without surfacing the real message. The fix translates exceptions at the /v1/messages boundary into the Anthropic shape, including a fallback for trailing-garbage JSON and a regex to strip LiteLLM wrapper prefixes (litellm.AuthenticationError: ... etc.) from passed-through provider messages.

I'll send curl-against-localhost:4000 proof-of-fix in a follow-up.

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

Pushed 40ecda47e8 as a small drive-by addition to fix the proxy-infra / Run tests failure on this PR's CI.

The failing assertion was test_gateway_plus_backend_covers_full_app: /api/event_logging/batch was registered on the proxy app but listed in neither gateway/routes/allowlist.py nor backend/routes/allowlist.py. Root cause is upstream-side: the route was added by commit d2bd029fa4 (PR #20504, "404 Not Found on /api/event_logging/batch endpoint") but the allowlists weren't updated. The test reliably fails on this PR (and would fail on every fork PR that rebases onto internal_staging) but passes locally because module-load order under xdist parallel differs from single-threaded local runs.

Added /api/event_logging/batch to GATEWAY_EXACT_PATHS. The route's own docstring describes it as a stub for Claude Code clients sending telemetry alongside their /v1/messages calls, so gateway (data-plane) is the right component; backend (UI / control plane) would never see this traffic.

This is unrelated to the Anthropic-shaped-errors core of this PR. I bundled it here because the failure surfaced on this CI run; happy to extract to a separate PR if you'd rather keep scope tight.

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the PR! A couple of things to get this over the finish line:

  • Could you add proof of the change working (screenshots, test output, or a sample request/response)? Even a quick curl before/after really speeds up the review.

Triggering Greptile for a code review in the meantime:

@greptileai

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite proof of fix below; both before and after curls against a local proxy on localhost:4000 driving a real Anthropic model deployment.

Setup (same proxy config in both runs):

model_list:
  - model_name: claude-haiku
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY
general_settings:
  master_key: sk-1234
python litellm/proxy/proxy_cli.py --config dev_config.yaml --port 4000

Same curl in both runs, asks for a model the proxy does not have, which routes through /v1/messages -> anthropic_response -> exception handler:

curl -sS -X POST http://localhost:4000/v1/messages \
  -H 'Authorization: Bearer sk-1234' \
  -H 'Content-Type: application/json' \
  -d '{"model":"this-model-does-not-exist","messages":[{"role":"user","content":"hi"}],"max_tokens":10}'

BEFORE (upstream litellm_internal_staging HEAD, pre-fix, OpenAI-shaped envelope):

{
  "error": {
    "message": "400: {'error': 'anthropic_messages: Invalid model name passed in model=this-model-does-not-exist. Call `/v1/models` to view available models for your key.'}",
    "type": "None",
    "param": "None",
    "code": "400"
  }
}

HTTP 400. Anthropic SDK clients reject this body because the top-level discriminator they expect is type == "error", not error.type. They surface the failure as a parse error instead of error.error.type == "invalid_request_error".

AFTER (PR head 40ecda47e8, Anthropic-shaped envelope):

{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "400: {'error': 'anthropic_messages: Invalid model name passed in model=this-model-does-not-exist. Call `/v1/models` to view available models for your key.'}"
  }
}

Still HTTP 400, same status code mapping (ProxyException.code -> int), but the envelope now matches Anthropic's spec: top-level type: "error" plus a nested error.type enum. The Anthropic SDK parses this normally and the calling code reads err.error.type == "invalid_request_error" without a SDK-level parse error.

The message field still contains the verbose litellm-internal wrapper text; that is a separate "trim provider prefixes" concern handled by _strip_litellm_wrapper_prefixes in the same PR for paths where the upstream provider message survives. For pure proxy-side validation failures like this one, no provider message exists to strip.

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the detailed proof of fix — the before/after curl output really helps! One remaining item:

  • Greptile score is 4/5 — the latest review (at commit 40ecda47) flagged the except Exception fallback in count_tokens still returning a non-Anthropic HTTPException envelope. Could you take a look at that remaining gap and get the score to 5/5?

Triggering another Greptile pass in the meantime:

@greptileai

Once that clears, we'll take another look! 🙏

songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 19, 2026
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
@songkuan-zheng
songkuan-zheng force-pushed the fix/anthropic-error-passthrough branch from f5f5ea5 to b9c17be Compare June 19, 2026 14:30
@Sameerlite

Copy link
Copy Markdown
Contributor

@greptileai

Comment on lines +326 to +328
except ProxyException as e:
status_code = int(e.code) if e.code and e.code.isdigit() else 500
detail = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=e.message,
)
raise HTTPException(
status_code=status_code,
detail=detail,
)
return _anthropic_error_response(status_code, e.message)

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 The except ProxyException handler in count_tokens passes e.message directly to _anthropic_error_response without a None guard. If a ProxyException is constructed with message=None, this reaches _strip_litellm_wrapper_prefixes(None) where re.sub raises a TypeError, crashing the error handler and producing an unhandled 500 with no Anthropic-shaped body. The same scenario was explicitly fixed in the anthropic_response() path at lines 263-265 of this PR.

Suggested change
except ProxyException as e:
status_code = int(e.code) if e.code and e.code.isdigit() else 500
detail = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=e.message,
)
raise HTTPException(
status_code=status_code,
detail=detail,
)
return _anthropic_error_response(status_code, e.message)
except ProxyException as e:
status_code = int(e.code) if e.code and e.code.isdigit() else 500
raw_message = e.message if e.message is not None else str(e)
return _anthropic_error_response(status_code, raw_message)

@songkuan-zheng
songkuan-zheng force-pushed the fix/anthropic-error-passthrough branch from 86b6bf4 to 13bb0bc Compare June 22, 2026 04:09
songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 22, 2026
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the contribution! Kicking off a Greptile code review on this one.

@greptileai

@songkuan-zheng
songkuan-zheng force-pushed the fix/anthropic-error-passthrough branch from f070116 to 21423bd Compare June 23, 2026 08:07
songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 23, 2026
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the contribution! Triggering a fresh Greptile review. One important item from the previous review: there's an unresolved P1 thread about a count_tokens call where ProxyException.code can be None — this could cause a runtime error. Please make sure that's addressed before the next review.

@greptileai

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite — the P1 you flagged (count_tokens_strip_litellm_wrapper_prefixes(None) crash when ProxyException.message is None) was already addressed in 9433928b97, current HEAD is 21423bd5f3. The current state of litellm/proxy/anthropic_endpoints/endpoints.py:326-329 matches Greptile's suggestion verbatim:

except ProxyException as e:
    status_code = int(e.code) if e.code and e.code.isdigit() else 500
    raw_message = e.message if e.message is not None else str(e)
    return _anthropic_error_response(status_code, raw_message)

Same Nonestr(e) fallback as the anthropic_response() path (lines 235-238). The bare-except Exception catch-all below (line 336) also already passes str(e) so None cannot reach the regex from there either.

The fresh Greptile pass should reflect this — pinging for re-score.

@greptileai

songkuan-zheng and others added 8 commits June 27, 2026 20:44
The Anthropic-compatible /v1/messages endpoint returned OpenAI-shaped
error bodies ({"error": {message, type, param, code}}) instead of
Anthropic-shaped ({"type": "error", "error": {type, message}}),
breaking Anthropic SDK clients that switch on error.error.type. The
message also leaked stacked LiteLLM class-name prefixes and an escaped
upstream JSON body (litellm.RateLimitError: AnthropicException - {...}).

Changes
-------

1. AnthropicExceptionMapping:
   - Add _strip_litellm_wrapper_prefixes() to peel stacked
     `litellm.<Class>:` and `<Provider>Exception - ` prefixes, and call
     it inside transform_to_anthropic_error() so an embedded upstream
     Anthropic error body is detected and passed through unchanged
     (preserving the real error.type enum instead of deriving it from
     the HTTP status).
   - Use json.JSONDecoder.raw_decode as a fallback when safe_json_loads
     rejects the message. The Router appends debug suffixes after the
     upstream Anthropic JSON (`{"type":"error",...}. Received Model
     Group=...`) that break strict JSON parsing — raw_decode parses
     the leading object and ignores the trailing garbage.
   - Extract nested error.message from OpenAI-compat upstream bodies
     ({"error":{"code","message","type"}}, used by OpenAI, the
     `new-api` proxy, and many OpenAI-compat gateways) so the result
     is the inner human message instead of the full stringified JSON.

2. /v1/messages error path: return JSONResponse with the Anthropic
   body instead of raising ProxyException. ProxyException routes
   through the global OpenAI-shaped handler; JSONResponse is required
   (not HTTPException, which wraps the dict in a spurious
   {"detail": ...}).

3. count_tokens: same root-cause fix — its existing
   HTTPException(detail=...) was double-wrapping the Anthropic body in
   {"detail": ...}. Switch to JSONResponse. Framework-level 400s for
   missing model/messages stay as HTTPException.

x-litellm-* response headers are preserved on the error path.

Why `!`: clients that parsed error.param / error.code on this
Anthropic-compat endpoint will no longer see those OpenAI-only fields.

Tests
-----
- Prefix-stripper + wrapped-passthrough unit tests
- /v1/messages TestClient integration (asserts top-level shape, no
  detail wrapper, no param/code, headers present)
- count_tokens error-format tests updated to assert the JSONResponse
  body
- Trailing-garbage JSON recovery via raw_decode
- Nested OpenAI-compat error.message extraction

39 tests pass across the changed files.
…hten regex

Three greptile threads on BerriAI#30385:

P1 — **ProxyException silently mapped to 500**
  ProxyException stores its HTTP code on `.code` (string), not
  `.status_code`. Reading only `.status_code` falls through to the
  default 500 for every auth/permission/billing rejection. Now reads
  `.code` first (preferred when it's a numeric string), falls back to
  `.status_code`, defaults to 500 — matching the `count_tokens` handler
  in the same module.

P2 — **None .message crashes the strip-prefix regex**
  `getattr(e, "message", str(e))` returns None when `.message` is
  explicitly None (e.g. `litellm.BadRequestError(message=None, ...)`).
  That None then crashes `re.sub` in `_strip_litellm_wrapper_prefixes`,
  yielding a bare 500 with no Anthropic-shaped body. Coerce
  None -> str(e) before passing to the mapping helper.

P2 — **`_PROVIDER_EXCEPTION_PREFIX` regex was overly broad**
  `\w+Exception` matched any word ending in `Exception`, including
  generic `TimeoutException - <real upstream body>`,
  `ConnectionException - <real upstream body>`,
  `RequestException - <real upstream body>` — silently swallowing the
  front of legitimate error strings. Anchored to a known provider-name
  allowlist (47 names, maintained from `litellm/llms/**` + common
  aliases). Generic Python/network exception names no longer get
  swallowed.

Tests (`test_anthropic_error_passthrough.py` +3):
- `test_proxy_exception_code_attribute_is_honored`: ProxyException(code="401")
  -> HTTP 401, not 500.
- `test_none_message_falls_back_to_str`: exception with .message=None
  yields a well-formed Anthropic body, not an unhandled 500.
- `test_provider_exception_prefix_does_not_strip_generic_timeout`:
  `TimeoutException - upstream took too long` survives intact.

42/42 pass locally (existing + 3 new).
The proxy-infra component-allowlist test on this PR's CI was failing
with /api/event_logging/batch missing from both gateway and backend
allowlists. The route comes from upstream commit d2bd029 (PR BerriAI#20504),
not from this PR's diff, but the union-coverage test fails on every fork
PR that rebases onto internal_staging.

Add the route to GATEWAY_EXACT_PATHS: it's a stub Anthropic event-logging
endpoint that Claude Code clients hit as part of the /v1/messages data
path (the endpoint's own docstring describes it as preventing 404s from
Claude Code telemetry), so it belongs on the gateway component, not the
control-plane backend.

Drive-by because this PR's CI surfaced it; happy to extract to a separate
PR if the maintainer prefers.
Greptile (4/5 on commit 40ecda4) flagged the bare `except Exception`
fallback in `count_tokens` still re-raising as `HTTPException(500,
detail={"error": ...})`, which leaves FastAPI's `{"detail": ...}` wrapper
around an OpenAI-shaped body. Closing that gap by itself would still
leave the same bug in the two `raise HTTPException(400, ...)` branches
(missing `model`, missing `messages`) and in the `except HTTPException:
raise` re-raise that lets an HTTPException from the internal counter
through unchanged. Same bug class as the one this PR's main commit
fixed on `/v1/messages`; same surface (the Anthropic SDK rejects any
non-Anthropic envelope).

Fix: every error path out of `count_tokens` now returns
`AnthropicExceptionMapping.transform_to_anthropic_error(...)` wrapped in
a `JSONResponse`. Extracted a `_anthropic_error_response(status_code,
raw_message)` helper at module scope so the four exit points (400 missing
model, 400 missing messages, HTTPException pass-through with detail
extraction, ProxyException with `.code`, generic Exception fallback) all
share one shape.

Tests: extend `tests/proxy_unit_tests/test_proxy_token_counter.py` with
four regressions covering the previously-uncovered paths (missing model,
missing messages, HTTPException from internal counter, generic Exception
fallback). Updated the pre-existing `test_anthropic_endpoint_error_handling`
which pinned the old buggy `HTTPException` contract.
…ens path

Greptile P1 on commit b9c17be: the new `except ProxyException` branch
in `count_tokens` passes `e.message` directly to
`_anthropic_error_response`, which forwards it to
`_strip_litellm_wrapper_prefixes`. When `ProxyException` is constructed
with `message=None`, that None reaches `re.sub` and raises TypeError,
crashing the error handler and producing an unhandled 500 with no
Anthropic-shaped body. The same scenario already has an explicit
guard on the `/v1/messages` `anthropic_response()` path (introduced
earlier in this PR); the `count_tokens` sibling needs the same.

Coerce `e.message is None -> str(e)` before handing off. Adds a
regression test that constructs a ProxyException with `.message = None`
and asserts the handler returns a well-formed Anthropic envelope at the
expected status code.
Co-authored-by: songkuan-zheng <songkuan-zheng@users.noreply.github.com>
@songkuan-zheng
songkuan-zheng force-pushed the fix/anthropic-error-passthrough branch from 21423bd to c2d95c0 Compare June 27, 2026 20:49
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

Rebased onto current litellm_internal_staging to resolve the conflict in litellm/proxy/anthropic_endpoints/endpoints.py.

Conflict regions:

  • count_tokens validation early-returns: kept this PR's return _anthropic_error_response(...) for the 400 path (the whole point of the PR — Anthropic-shaped errors).
  • count_tokens generic exception handler: kept this PR's return _anthropic_error_response(500, str(e)) and adopted upstream's multi-line .format(...) layout on the verbose_proxy_logger.exception call.

Tests passing locally:

  • tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py
  • tests/test_litellm/proxy/test_anthropic_error_passthrough.py ✓ (42 passed total)

State now MERGEABLE / no failing CI.

`ruff format --check` (delta-vs-base lint gate landed on
litellm_internal_staging while this PR was open) flagged 3 line-wrapped
calls in this PR's diff. Apply the formatter's canonical layout to
satisfy the gate. No behavior change.

Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com>
Co-authored-by: songkuan-zheng <songkuan-zheng@users.noreply.github.com>
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

Pushed ruff format reformat — the new delta-vs-base lint gate flagged 3 line-wrapped calls in this PR's diff (exception_mapping_utils.py x2, endpoints.py x1). Pure whitespace; no behavior change. CI re-running now.

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@ishaan-jaff friendly bump — open 15 days, MERGEABLE with green CI (latest rebase + ruff format yesterday). This makes /v1/messages and /v1/messages/count_tokens return Anthropic-shaped error envelopes ({"type":"error","error":{"type":...}}) on every error path instead of the LiteLLM/FastAPI envelope, so Anthropic SDK clients can parse errors uniformly. Would appreciate a review 🙏

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite friendly bump — open 17 days, MERGEABLE with green CI (rebased + ruff format 3 days ago). The P1 you flagged (ProxyException.message=None crashing _strip_litellm_wrapper_prefixes) was addressed in 9433928b97; full Greptile passes are green. Would appreciate a look when you have a moment 🙏

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants