Skip to content

fix(proxy): stop putting the literal string "None" in error payloads - #39521

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_fix_guardrail_error_stringified_none
Sep 3, 2026
Merged

fix(proxy): stop putting the literal string "None" in error payloads#39521
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_fix_guardrail_error_stringified_none

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Guardrail blocks return "type": "None" and "param": "None" as strings
  • Clients matching OpenAI's error types match neither, so they retry
  • Hits /v1/chat/completions, /v1/responses, /v1/messages, streaming and not

How it solves it:

  • The getattr defaults were the string "None", not None
  • type now falls back to the type its status code stands for
  • param now serializes as JSON null

User Flow

Before: a developer whose app routes chat through the gateway with a Bedrock guardrail turned on gets an error body whose type and param are the literal string None, so their error handling matches no known type and retries a block that will never succeed

  1. They send POST https://litellm-domain/v1/chat/completions with {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "How do I brew the perfect cup of coffee?"}]}, a prompt their guardrail denies
  2. They get HTTP 400 with {"error": {"message": "Violated guardrail policy", "type": "None", "param": "None", "code": "400", ...}}
  3. Their handler compares error.type against OpenAI's values (invalid_request_error, rate_limit_error, ...), matches none, and falls into its generic unknown-error branch, which retries the same blocked prompt
  4. They read error.param to say which field was at fault and get the four-character string None, so the message they show says the problem is in a field called None
  5. They send the same prompt to POST https://litellm-domain/v1/responses and POST https://litellm-domain/v1/messages, and to /v1/chat/completions with "stream": true, and every one of them carries the same two "None" strings
  6. From the OpenAI Python SDK the call raises BadRequestError whose err.body["param"] is 'None', a truthy string, so if err.body["param"]: takes the wrong branch

After: the same block comes back with a real error type and a null param, so their handler recognizes it, shows the guardrail message, and stops retrying

  1. They send the same POST https://litellm-domain/v1/chat/completions with the same denied prompt
  2. They get HTTP 400 with {"error": {"message": "Violated guardrail policy", "type": "invalid_request_error", "param": null, "code": "400", ...}}
  3. Their handler matches invalid_request_error, surfaces the guardrail message to the user, and does not retry
  4. They read error.param, get JSON null, and skip the which-field branch entirely
  5. The same prompt on POST https://litellm-domain/v1/responses, POST https://litellm-domain/v1/messages, and /v1/chat/completions with "stream": true all carry the same real type and null param
  6. From the OpenAI Python SDK the call raises BadRequestError whose err.body["param"] is None, so if err.body["param"]: takes the branch they meant

Relevant issues

Linear ticket

Resolves LIT-6808

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, identical on both sides: one config with a real Bedrock guardrail that denies the topic "coffee", each proxy booted with 2 uvicorn workers on its own port, both against the same Postgres.

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY

guardrails:
  - guardrail_name: bedrock-pre-guard
    litellm_params:
      guardrail: bedrock
      mode: pre_call
      guardrailIdentifier: gf3sc1mzinjw
      guardrailVersion: DRAFT
      aws_region_name: us-east-1
      default_on: true

general_settings:
  master_key: sk-1234
# before leg, at the merge base
git worktree add --detach ../litellm-base 658f50663d
PYTHONPATH=$PWD/../litellm-base python litellm/proxy/proxy_cli.py --config guardrail_config.yaml --port 31877 --num_workers 2 --detailed_debug

# after leg, at this PR's tip
PYTHONPATH=$PWD python litellm/proxy/proxy_cli.py --config guardrail_config.yaml --port 27431 --num_workers 2 --detailed_debug

Before (658f506)

POST /v1/chat/completions

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:31877/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. The block comes back with both fields as the string "None":
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/chat/completions with "stream": true

  1. Send the same prompt as a stream:
curl -sS -i -X POST http://127.0.0.1:31877/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. Same two "None" strings:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/responses

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:31877/v1/responses \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","input":"How do I brew the perfect cup of coffee?"}'
  1. Same two "None" strings:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/messages

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:31877/v1/messages \
  -H "x-api-key: sk-1234" -H "anthropic-version: 2023-06-01" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","max_tokens":64,"messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. Same two "None" strings:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

OpenAI Python SDK

  1. Run the client code a developer would actually write:
import openai

client = openai.OpenAI(base_url="http://127.0.0.1:31877/v1", api_key="sk-1234")
try:
    client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "How do I brew the perfect cup of coffee?"}],
    )
except openai.BadRequestError as err:
    print("error.type :", repr(err.body["type"]))
    print("error.param:", repr(err.body["param"]))
  1. Both come back as truthy strings:
error.type : 'None'
error.param: 'None'

After (1b4d2e2)

POST /v1/chat/completions

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:27431/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. Real type, null param, everything else unchanged:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"invalid_request_error","param":null,"code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/chat/completions with "stream": true

  1. Send the same prompt as a stream:
curl -sS -i -X POST http://127.0.0.1:27431/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. Real type, null param:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"invalid_request_error","param":null,"code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/responses

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:27431/v1/responses \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","input":"How do I brew the perfect cup of coffee?"}'
  1. Real type, null param:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"invalid_request_error","param":null,"code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

POST /v1/messages

  1. Send the denied prompt:
curl -sS -i -X POST http://127.0.0.1:27431/v1/messages \
  -H "x-api-key: sk-1234" -H "anthropic-version: 2023-06-01" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","max_tokens":64,"messages":[{"role":"user","content":"How do I brew the perfect cup of coffee?"}]}'
  1. Real type, null param:
HTTP/1.1 400 Bad Request
{"error":{"message":"Violated guardrail policy","type":"invalid_request_error","param":null,"code":"400","provider_specific_fields":{"error":"Violated guardrail policy","bedrock_guardrail_response":"Sorry, the model cannot answer this question.","guardrailIdentifier":"gf3sc1mzinjw","guardrailVersion":"DRAFT","assessments":[{"policy":"topicPolicy","matches":[{"category":"topics","name":"coffee","type":"DENY","action":"BLOCKED"}]}],"guardrail_name":"bedrock-pre-guard","guardrail_mode":"pre_call"}}}

OpenAI Python SDK

  1. Run the same client code against the after proxy:
import openai

client = openai.OpenAI(base_url="http://127.0.0.1:27431/v1", api_key="sk-1234")
try:
    client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "How do I brew the perfect cup of coffee?"}],
    )
except openai.BadRequestError as err:
    print("error.type :", repr(err.body["type"]))
    print("error.param:", repr(err.body["param"]))
  1. A real type and a genuine None:
error.type : 'invalid_request_error'
error.param: None

Notes from the run:

  • The status code decides the type: 401 gives authentication_error, 403 permission_error, 429 rate_limit_error
  • Anything a caller sets on the exception still wins over the fallback
  • provider_specific_fields is untouched, so the guardrail detail still rides along
  • Every HTTP error the proxy converts was affected, not just guardrails
  • Live 401 and 429 bodies are byte-identical before and after

Type

🐛 Bug Fix

Caveats (if any)

Low

  • Every error on these routes gets a real type, not only guardrail blocks
    • Exceptions carrying no type used to serialize as JSON null
    • A 500 now reads internal_server_error, LiteLLM's own value
    • Values the exception does carry still win unchanged
  • Other endpoint files still pass "None" as a getattr default
    • Roughly 150 sites across 17 files, none on this error path
    • GET /v1/files with a bad target_model_names still shows it
    • Follow-up sweep tracked as LIT-6829, and the type half as LIT-6839
  • A stringified request_id in spend logs looks related but is not
    • It comes from the spend-log row builder, a different module
    • Fixing it needs its own change

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

  • 1b4d2e2 passes /live-pr-risk


Note

Medium Risk
Changes shared proxy error-shaping for all routes; behavior is more correct for clients but any consumer that depended on the string "None" will see different type/param values.

Overview
Fixes proxy error JSON that used the literal strings "None" for error.type and error.param when exceptions did not carry those fields—most visible on guardrail blocks and generic failures across streaming and non-streaming routes.

Centralized helpers in common_request_processing.py (_openai_error_type, _openai_error_param, _error_status_code) now derive a real OpenAI-style type from the HTTP status (e.g. 400 → invalid_request_error, 401 → authentication_error, 429 → rate_limit_error) while still honoring type/param set on the exception. param serializes as JSON null when absent.

Those helpers replace the old getattr(..., "None") defaults in proxy_exception_from_http_exception, sse_error_payload, client-disconnect responses, _handle_llm_api_exception, and streaming generator error paths so surfaces stay aligned with ProxyException.to_dict(). Tests add LIT-6808 regressions and update expected SSE/non-streaming payloads.

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

A blocked guardrail (and any other HTTP error the proxy converts) came back
with "type": "None" and "param": "None", because the converters passed the
string "None" as the getattr default instead of None. OpenAI types error.type
as a required string and error.param as nullable, so type now falls back to
the type its status code stands for and param serializes as JSON null.

Covers the non-streaming body, the SSE error frame, the client-disconnect
frame, and the unclassified-exception path, so every unified LLM endpoint and
the anthropic endpoints return the same shape.
@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR normalizes proxy error metadata so missing exception fields produce status-appropriate OpenAI error types and JSON-null parameters instead of the literal string "None".

  • Adds shared helpers for validating status codes and normalizing error types and parameters.
  • Applies the normalized values to non-streaming, SSE, disconnect, and post-call streaming error paths.
  • Adds regression coverage for guardrail blocks, generic failures, status-based type mapping, and explicitly supplied exception fields.

Confidence Score: 5/5

The PR appears safe to merge with no blocking or independently actionable issues identified.

The changed paths preserve valid exception-supplied string fields and integer status codes while replacing invalid stringified-null defaults with OpenAI-compatible fallback types and JSON null, with focused regression coverage across normal and streaming errors.

Important Files Changed

Filename Overview
litellm/proxy/common_request_processing.py Centralizes error metadata normalization and applies it consistently across the affected proxy error serialization paths; no actionable defect identified.
tests/test_litellm/proxy/test_common_request_processing.py Updates corrected wire-format expectations and adds focused regression tests covering guardrail, streaming, fallback, and field-preservation behavior.

Reviews (1): Last reviewed commit: "fix(proxy): stop putting the literal str..." | Re-trigger Greptile

@codspeed-hq

codspeed-hq Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_guardrail_error_stringified_none (1b4d2e2) with litellm_internal_staging (ecabfbd)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (658f506) during the generation of this report, so ecabfbd was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@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 1b4d2e2. Configure here.

@codecov

codecov Bot commented Sep 3, 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
mateo-berri merged commit c67fe2d into litellm_internal_staging Sep 3, 2026
129 of 133 checks passed
@mateo-berri
mateo-berri deleted the litellm_fix_guardrail_error_stringified_none branch September 3, 2026 21:36
pull Bot pushed a commit to chizee/litellm that referenced this pull request Sep 8, 2026
… param

The proxy's exception tails defaulted `type` and `param` to the four-character
string "None", which is neither a known OpenAI error type nor the JSON null the
nullable `param` field is typed as, so a client's error handler matched nothing
and fell into its generic branch.

Lifts the helpers PR BerriAI#39521 added for the unified LLM endpoints into
litellm/proxy/common_utils/openai_error_payload.py and calls them from the file,
rerank, image, realtime, anthropic, and pass-through route families, plus the
shared handle_exception_on_proxy handler that the management, batches,
fine-tuning, credential, SCIM, guardrail, and customer routes funnel through.

The remaining families (proxy_server, auth, health, spend tracking, and
management endpoints) follow in separate PRs so each slice stays QA'able on a
live proxy.
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