Skip to content

fix(anthropic_endpoints): return Anthropic type:error envelope for /v1/messages errors - #39037

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_fix_anthropic_messages_error_envelope
Sep 4, 2026
Merged

fix(anthropic_endpoints): return Anthropic type:error envelope for /v1/messages errors#39037
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_fix_anthropic_messages_error_envelope

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Errors on /v1/messages came back in OpenAI's error envelope
  • type and param were the literal string "None"
  • Anthropic-format clients expect {"type":"error","error":{...}} per Anthropic's docs

How it solves it:

  • The route now returns Anthropic's documented error envelope for every error it handles
  • Error type is mapped from the status code (400 -> invalid_request_error, 429 -> rate_limit_error, ...)
  • Guardrail detail from dict-detail blocks still rides along under error.provider_specific_fields
  • request_id echoes the client's x-request-id, matching the existing context-management error path
  • Response headers (x-litellm-call-id, ...) and OTel server-span error stamping are preserved

User Flow

Before: a developer whose Anthropic-format app hits a guardrail block gets an OpenAI-shaped error with "None" strings, so their Anthropic error handling can't classify it

  1. Their app sends POST https://litellm-domain/v1/messages with {"model": "claude-sonnet-5", "messages": [...]} and the message trips the admin's content guardrail
  2. The response is HTTP 400 with {"error": {"message": "Content blocked: keyword 'kumquat' detected", "type": "None", "param": "None", "code": "400"}}
  3. That is OpenAI's envelope, not the {"type": "error", "error": {"type": ..., "message": ...}} shape api.anthropic.com returns, and the error type reads as the string "None"
  4. Their error handling, written against Anthropic's documented format, finds no type: "error" marker and no usable error type, so the block surfaces as an unclassified failure

After: the same request comes back exactly in Anthropic's documented error format

  1. Their app sends the same POST https://litellm-domain/v1/messages and the message trips the same guardrail
  2. The response is HTTP 400 with {"type": "error", "error": {"type": "invalid_request_error", "message": "Content blocked: keyword 'kumquat' detected", "provider_specific_fields": {...}}, "request_id": "req-..."}
  3. That matches what api.anthropic.com itself returns for a 400, so their Anthropic error handling classifies it like any other invalid-request error, and the guardrail detail is still there under error.provider_specific_fields

Relevant issues

Linear ticket

Resolves LIT-6468

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

Shared setup: proxy run from the worktree with python litellm/proxy/proxy_cli.py --config config.yaml --port <port> (before leg on 35768 at the merge base, after leg on 20535 at this PR's tip), real Anthropic API key, master key sk-1234

model_list:
  - model_name: claude-sonnet-5
    litellm_params:
      model: anthropic/claude-sonnet-5
      api_key: os.environ/ANTHROPIC_API_KEY

guardrails:
  - guardrail_name: "keyword-block"
    litellm_params:
      guardrail: custom_guardrail.myCustomGuardrail
      mode: ["pre_call", "post_call"]
      default_on: true

general_settings:
  master_key: sk-1234

custom_guardrail.py raises HTTPException(400, detail={"error": "Content blocked: keyword 'kumquat' detected", "keyword": "kumquat", "guardrail": "keyword-block"}) from async_post_call_success_hook when the request mentions kumquat, and the same from async_pre_call_hook for durian

Reference: what api.anthropic.com itself returns for a 400 (the target shape)

$ curl -sS -w '\nHTTP %{http_code}\n' https://api.anthropic.com/v1/messages -H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' -d '{"model":"claude-sonnet-5","max_tokens":-5,"messages":[{"role":"user","content":"hi"}]}'
{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: must be greater than or equal to 0"},"request_id":"req_011CebgCquxQ6fxUGmhUPnv1"}
HTTP 400

Before (81c8c93)

/v1/messages post_call guardrail block (non-streaming)

  1. curl -sS -w '\nHTTP %{http_code}\n' http://localhost:35768/v1/messages -H 'Authorization: Bearer sk-1234' -H 'content-type: application/json' -H 'x-request-id: req-lit6468-demo' -d '{"model":"claude-sonnet-5","max_tokens":100,"messages":[{"role":"user","content":"Say the word kumquat back to me"}]}'
  2. OpenAI envelope with "type":"None","param":"None", no request_id:
{"error":{"message":"Content blocked: keyword 'kumquat' detected","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Content blocked: keyword 'kumquat' detected","keyword":"kumquat","guardrail":"keyword-block","guardrail_name":"keyword-block","guardrail_mode":["pre_call","post_call"]}}}
HTTP 400

/v1/messages pre_call guardrail block with stream: true

  1. curl -sS -w '\nHTTP %{http_code}\n' http://localhost:35768/v1/messages -H 'Authorization: Bearer sk-1234' -H 'content-type: application/json' -d '{"model":"claude-sonnet-5","max_tokens":50,"stream":true,"messages":[{"role":"user","content":"Tell me about durian fruit"}]}'
  2. Same OpenAI envelope:
{"error":{"message":"Content blocked: keyword 'durian' detected","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Content blocked: keyword 'durian' detected","keyword":"durian","guardrail":"keyword-block-pre","guardrail_name":"keyword-block","guardrail_mode":["pre_call","post_call"]}}}
HTTP 400

/v1/messages invalid model

  1. curl -sS -w '\nHTTP %{http_code}\n' http://localhost:35768/v1/messages -H 'Authorization: Bearer sk-1234' -H 'content-type: application/json' -d '{"model":"no-such-model","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
  2. OpenAI envelope again:
{"error":{"message":"anthropic_messages: Invalid model name passed in model=no-such-model. Call `/v1/models` to view available models for your key.","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"anthropic_messages: Invalid model name passed in model=no-such-model. Call `/v1/models` to view available models for your key."}}}
HTTP 400

/v1/messages happy path

  1. curl -sS -w '\nHTTP %{http_code}\n' http://localhost:35768/v1/messages -H 'Authorization: Bearer sk-1234' -H 'content-type: application/json' -d '{"model":"claude-sonnet-5","max_tokens":50,"messages":[{"role":"user","content":"Reply with exactly: hello from QA"}]}'
  2. Real completion, HTTP 200:
{"model":"claude-sonnet-5","id":"msg_011Cebg9m1whJLUU4QVTvUq3","type":"message","role":"assistant","content":[{"type":"text","text":"hello from QA"}],"stop_reason":"end_turn",...}
HTTP 200

Anthropic python SDK

  1. anthropic.Anthropic(base_url="http://localhost:35768", api_key="sk-1234") then client.messages.create(...) with the kumquat message
  2. The SDK raises BadRequestError whose body is the OpenAI envelope with the "None" strings:
BadRequestError 400
body: {'error': {'message': "Content blocked: keyword 'kumquat' detected", 'type': 'None', 'param': 'None', 'code': '400', ...}}

/v1/chat/completions and /v1/responses (OpenAI-format surfaces)

  1. Same kumquat/durian requests against http://localhost:35768/v1/chat/completions (non-streaming and stream:true) and http://localhost:35768/v1/responses
  2. All three return the OpenAI envelope, e.g.:
{"error":{"message":"Content blocked: keyword 'kumquat' detected","type":"None","param":"None","code":"400","provider_specific_fields":{...}}}
HTTP 400

After (562664f)

/v1/messages post_call guardrail block (non-streaming)

  1. Same curl as before against port 20535
  2. Anthropic's documented envelope, request_id echoing the client's x-request-id, guardrail detail preserved:
{"type":"error","error":{"type":"invalid_request_error","message":"Content blocked: keyword 'kumquat' detected","provider_specific_fields":{"error":"Content blocked: keyword 'kumquat' detected","keyword":"kumquat","guardrail":"keyword-block","guardrail_name":"keyword-block","guardrail_mode":["pre_call","post_call"]}},"request_id":"req-lit6468-demo"}
HTTP 400

/v1/messages pre_call guardrail block with stream: true

  1. Same curl as before against port 20535
  2. Anthropic envelope:
{"type":"error","error":{"type":"invalid_request_error","message":"Content blocked: keyword 'durian' detected","provider_specific_fields":{"error":"Content blocked: keyword 'durian' detected","keyword":"durian","guardrail":"keyword-block-pre","guardrail_name":"keyword-block","guardrail_mode":["pre_call","post_call"]}}}
HTTP 400

/v1/messages invalid model

  1. Same curl as before against port 20535
  2. Anthropic envelope:
{"type":"error","error":{"type":"invalid_request_error","message":"anthropic_messages: Invalid model name passed in model=no-such-model. Call `/v1/models` to view available models for your key.","provider_specific_fields":{"error":"anthropic_messages: Invalid model name passed in model=no-such-model. Call `/v1/models` to view available models for your key."}}}
HTTP 400
  1. Response headers still carry the LiteLLM ids: x-litellm-call-id: 244ef570-e175-482f-ad76-6e5b778e4914, x-litellm-version: 1.100.0

/v1/messages happy path

  1. Same curl as before against port 20535
  2. Identical 200 completion shape:
{"model":"claude-sonnet-5","id":"msg_011CebgFXKrFhKPE5jKAHbsm","type":"message","role":"assistant","content":[{"type":"text","text":"hello from QA"}],"stop_reason":"end_turn",...}
HTTP 200

Anthropic python SDK

  1. Same SDK call against port 20535
  2. Still BadRequestError (the SDK keys its error classes on the status code), body now the documented envelope:
BadRequestError 400
body: {'type': 'error', 'error': {'type': 'invalid_request_error', 'message': "Content blocked: keyword 'kumquat' detected", 'provider_specific_fields': {...}}}

/v1/chat/completions and /v1/responses (OpenAI-format surfaces)

  1. Same requests against port 20535
  2. All three responses are byte-identical to the Before leg (verified with diff), so the OpenAI-format surfaces are untouched

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • Deliberate wire-format change: clients parsing the old OpenAI envelope on /v1/messages errors must adapt
    • Status codes and message text are unchanged; official Anthropic SDKs are unaffected (they key on status)
    • This is the ticket's requested behavior; the surface serves Anthropic-format clients by definition
  • Errors raised before the route runs keep the OpenAI envelope
    • Auth failures (bad proxy key) and malformed multipart bodies exit through the global handler
  • Mid-stream SSE error frames are still OpenAI-shaped
    • That serializer is shared streaming infrastructure owned by separate in-flight work

Low

  • param is no longer surfaced on this route's errors; Anthropic's envelope has no slot for it
  • /v1/messages/count_tokens errors are unchanged (FastAPI {"detail": ...} wrapping, out of scope)
  • The Rust gateway's separate /v1/messages implementation still emits its own OpenAI-ish envelope

Test expectation changes in tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py, all because the route now returns an Anthropic-envelope JSONResponse instead of re-raising ProxyException:

  • TestProxyExceptionPassthrough -> TestProxyExceptionAnthropicEnvelope: pytest.raises(ProxyException) flips to asserting the returned 400 envelope (and a new 429 -> rate_limit_error case)
  • TestHttpExceptionDictDetail: same flip; still proves the LIT-6466 clean message and the dict detail, now under error.provider_specific_fields
  • TestFailureHookRequestData: same flip to a returned 500 envelope; the hook-data assertions it exists for are unchanged

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

  • 562664f passes /live-pr-risk


Note

Medium Risk
This is an intentional wire-format change on a primary API surface; status and messages stay the same but clients that parsed the old OpenAI envelope on /v1/messages must adapt.

Overview
/v1/messages error responses now use Anthropic’s documented {"type":"error","error":{...}} shape instead of bubbling ProxyException through the global OpenAI-style handler (which produced "type":"None" / "param":"None").

A new _anthropic_error_json_response helper builds the envelope via AnthropicExceptionMapping, preserves status codes and LiteLLM headers, stamps OTel server spans like the global handler, echoes x-request-id as request_id, and maps HTTP status to Anthropic error types (e.g. 400 → invalid_request_error, 429 → rate_limit_error). Guardrail and other dict-detail failures still attach their payload under error.provider_specific_fields, now typed on AnthropicErrorDetail.

ProxyException, HTTPException, and generic failures in anthropic_response return this JSONResponse rather than re-raising. Tests were updated to assert the Anthropic envelope instead of pytest.raises(ProxyException).

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

@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes route-handled /v1/messages failures to use Anthropic error envelopes while preserving status codes, response headers, provider-specific details, request IDs, and error-span stamping.

  • Adds Anthropic error response typing for provider-specific fields.
  • Centralizes route-level Anthropic error response construction.
  • Updates endpoint tests for 400, 429, and 500 error responses.

Confidence Score: 4/5

The PR is not yet safe to merge because the previously reported unconditional compatibility break remains and requires a user-controlled transition mechanism.

The current route still changes every handled /v1/messages error response to the new wire format without a compatibility flag; the author’s reply states that a flag is intentionally omitted, so the previously reported issue remains unresolved.

Files Needing Attention: litellm/proxy/anthropic_endpoints/endpoints.py

Important Files Changed

Filename Overview
litellm/anthropic_interface/exceptions/exceptions.py Extends the Anthropic error detail type to represent optional read-only provider-specific fields.
litellm/proxy/anthropic_endpoints/endpoints.py Converts route-handled failures into Anthropic envelopes while preserving headers, status mapping, failure hooks, and error-span handling.
tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py Updates unit coverage to assert Anthropic envelopes and status-to-error-type mapping.

Reviews (2): Last reviewed commit: "fix(anthropic_endpoints): return Anthrop..." | Re-trigger Greptile

Comment thread litellm/proxy/anthropic_endpoints/endpoints.py
@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_anthropic_messages_error_envelope (562664f) with litellm_internal_staging (f93d9b6)

Open in CodSpeed

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor Author

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 562664f. Configure here.

@mateo-berri
mateo-berri requested a review from tin-berri August 31, 2026 23:45
@mateo-berri
mateo-berri enabled auto-merge August 31, 2026 23:45

@tin-berri tin-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

@mateo-berri
mateo-berri merged commit c4e9076 into litellm_internal_staging Sep 4, 2026
130 checks passed
@mateo-berri
mateo-berri deleted the litellm_fix_anthropic_messages_error_envelope branch September 4, 2026 00:16
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.

2 participants