Skip to content

fix(proxy): omit litellm_batch_guardrail when no guardrail acted - #37964

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_batch_guardrail_field_absent
Aug 22, 2026
Merged

fix(proxy): omit litellm_batch_guardrail when no guardrail acted#37964
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_batch_guardrail_field_absent

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Every /v1/files response gained a litellm_batch_guardrail: null
  • Happens with zero guardrails configured, contradicting the field's own docstring
  • Breaks byte-equality with the OpenAI file shape clients were getting before

How it solves it:

  • A wrap serializer drops the key when nothing set it
  • The populated report still serialises unchanged, nested nulls included
  • One model change covers create, retrieve, and list, and the managed-files list route drops the key it already persisted

User Flow

Before: a developer uploading a plain file to the gateway suddenly gets a field back that OpenAI never sends, on a proxy with no guardrails turned on at all

  1. They send POST https://litellm-domain/v1/files with purpose=user_data and a text file
  2. The 200 comes back with ten keys, the last one "litellm_batch_guardrail": null, where OpenAI's own API returns nine
  3. Their client, which asserts the response matches the OpenAI file shape, sees an unexpected key and their strict-schema parse fails
  4. They send GET https://litellm-domain/v1/files/file-abc123 for the same file and get the same extra null back

After: the same upload returns exactly what OpenAI returns, and the guardrail report only shows up when a guardrail actually did something

  1. They send POST https://litellm-domain/v1/files with purpose=user_data and a text file
  2. The 200 comes back with the nine keys OpenAI itself returns, and no litellm_batch_guardrail
  3. They send GET https://litellm-domain/v1/files/file-abc123 and get the same nine keys
  4. A proxy admin turns on a guardrail, and they send POST https://litellm-domain/v1/files with purpose=batch and a JSONL file where one row trips it
  5. That 200 does carry litellm_batch_guardrail, reporting submitted_records and every changed row by custom_id and line, exactly as before

Relevant issues

Linear ticket

Refs LIT-5276

Pre-Submission checklist

  • 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
  • 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

Screenshots / Proof of Fix

End to end against a live proxy pair, real OpenAI files API, one shared Postgres. Port 4762 is the current build, 4761 is this branch. The guardrail report survives untouched, and the key disappears from the plain upload, the file list, and the retrieve

proof of fix

Shared setup, one proxy config, one plain file, one batch file. Two guardrails are on by default so the batch case has something to report, and the plain upload proves the key stays away even when guardrails exist.

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

files_settings:
  - custom_llm_provider: openai
    api_key: os.environ/OPENAI_API_KEY

guardrails:
  - guardrail_name: "mask-litellm"
    litellm_params:
      guardrail: litellm.proxy.example_config_yaml.custom_guardrail.myCustomGuardrail
      mode: "pre_call"
      default_on: true
  - guardrail_name: "block-bad"
    litellm_params:
      guardrail: litellm.proxy.example_config_yaml.pipeline_test_guardrails.StrictFilter
      mode: "pre_call"
      default_on: true

general_settings:
  master_key: sk-bgabsent-1234
printf 'hello world\n' > plain.txt
cat > batch.jsonl <<'EOF'
{"custom_id":"clean","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello world"}]}}
{"custom_id":"dirty","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"tell me about litellm proxy"}]}}
{"custom_id":"blocked","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"this is a bad request"}]}}
EOF

Both sides ran against the real OpenAI files API with a real key, same config, same order.

The list route is served from the DB rather than forwarded, so it needs a second config with a database and the managed-id flag on. Same two proxies, same Postgres, so one row is written by the old build and one by the new.

general_settings:
  master_key: sk-bgabsent-1234
  passthrough_managed_object_ids: true
  database_url: "postgresql://..."

Before (490c9f9)

Plain upload, no guardrail can act

  1. curl -sS -X POST http://127.0.0.1:4762/v1/files -H "Authorization: Bearer $KEY" -F purpose=user_data -F file=@plain.txt
{"id":"file-LsWJYXvJgfzWoZjUR2QCgJ","bytes":12,"created_at":1787425081,"filename":"plain.txt","object":"file","purpose":"user_data","status":"processed","expires_at":null,"status_details":null,"litellm_batch_guardrail":null}
  1. curl -sS http://127.0.0.1:4762/v1/files/file-LsWJYXvJgfzWoZjUR2QCgJ -H "Authorization: Bearer $KEY"
{"id":"file-LsWJYXvJgfzWoZjUR2QCgJ","bytes":12,"created_at":1787425081,"filename":"plain.txt","object":"file","purpose":"user_data","status":"processed","expires_at":null,"status_details":null,"litellm_batch_guardrail":null}
  1. For comparison, the same call straight to OpenAI returns nine keys: bytes, created_at, expires_at, filename, id, object, purpose, status, status_details

Batch upload, guardrails act on two rows

  1. curl -sS -X POST http://127.0.0.1:4762/v1/files -H "Authorization: Bearer $KEY" -F purpose=batch -F file=@batch.jsonl
{
    "id": "file-HMSed3EWMHm9NKmj3ed6BN",
    "bytes": 331,
    "created_at": 1787425082,
    "filename": "batch.jsonl",
    "object": "file",
    "purpose": "batch",
    "status": "processed",
    "expires_at": 1790017082,
    "status_details": null,
    "litellm_batch_guardrail": {
        "submitted_records": 2,
        "modified_records": [
            {"line": 2, "custom_id": "dirty", "action": "redacted", "guardrail": null},
            {"line": 3, "custom_id": "blocked", "action": "dropped", "guardrail": null}
        ]
    }
}

File list, one row persisted by an older build

  1. curl -sS http://127.0.0.1:4762/openai_passthrough/v1/files -H "Authorization: Bearer $KEY"
{
    "object": "list",
    "data": [
        {"id": "bGl0ZWxsbV9wcm94eTpwYXNzdGhyb3VnaDtwcm92aWRlcjpvcGVuYWk7dW5pZmllZF9pZCw0Nzk5ZDBmOS1mMWIzLTRkMDgtODE3MS1lZGUyNTc1ZjA2YTc7cmF3X2lkLGZpbGUtR0ZLNUNCOVVuWk1KWVo0M2FKUmUxZw",
         "object": "file", "created_at": 1787427769, "bytes": 481, "status": "processed",
         "purpose": "batch", "filename": "batch.jsonl", "expires_at": 1790019769, "status_details": null},
        {"id": "bGl0ZWxsbV9wcm94eTpwYXNzdGhyb3VnaDtwcm92aWRlcjpvcGVuYWk7dW5pZmllZF9pZCxmYTQ0ZjA4Mi0wZWQ3LTQ5OGUtYjc1NC04MTM4OTk3MmQ0YzY7cmF3X2lkLGZpbGUtOHdnQnZWOThRdVlBQUQxTGJHSnp5dA",
         "object": "file", "created_at": 1787427725, "bytes": 481, "status": "processed",
         "purpose": "batch", "filename": "batch.jsonl", "expires_at": 1790019725, "status_details": null,
         "litellm_batch_guardrail": null}
    ],
    "has_more": false
}
  1. curl -sS http://127.0.0.1:4762/v1/files -H "Authorization: Bearer $KEY" returns both rows carrying "litellm_batch_guardrail": null

After (8aad40a)

Plain upload, no guardrail can act

  1. curl -sS -X POST http://127.0.0.1:4761/v1/files -H "Authorization: Bearer $KEY" -F purpose=user_data -F file=@plain.txt
{"id":"file-TZUb1n69n6KvpyPQFRLrNt","bytes":12,"created_at":1787425082,"filename":"plain.txt","object":"file","purpose":"user_data","status":"processed","expires_at":null,"status_details":null}
  1. curl -sS http://127.0.0.1:4761/v1/files/file-TZUb1n69n6KvpyPQFRLrNt -H "Authorization: Bearer $KEY"
{"id":"file-TZUb1n69n6KvpyPQFRLrNt","bytes":12,"created_at":1787425082,"filename":"plain.txt","object":"file","purpose":"user_data","status":"processed","expires_at":null,"status_details":null}
  1. Nine keys, matching what OpenAI itself returns

Batch upload, guardrails act on two rows

  1. curl -sS -X POST http://127.0.0.1:4761/v1/files -H "Authorization: Bearer $KEY" -F purpose=batch -F file=@batch.jsonl
{
    "id": "file-AFXB9VPkGVa1kB3uvmTmyo",
    "bytes": 331,
    "created_at": 1787425083,
    "filename": "batch.jsonl",
    "object": "file",
    "purpose": "batch",
    "status": "processed",
    "expires_at": 1790017083,
    "status_details": null,
    "litellm_batch_guardrail": {
        "submitted_records": 2,
        "modified_records": [
            {"line": 2, "custom_id": "dirty", "action": "redacted", "guardrail": null},
            {"line": 3, "custom_id": "blocked", "action": "dropped", "guardrail": null}
        ]
    }
}

File list, one row persisted by an older build

  1. curl -sS http://127.0.0.1:4761/openai_passthrough/v1/files -H "Authorization: Bearer $KEY"
{
    "object": "list",
    "data": [
        {"id": "bGl0ZWxsbV9wcm94eTpwYXNzdGhyb3VnaDtwcm92aWRlcjpvcGVuYWk7dW5pZmllZF9pZCw0Nzk5ZDBmOS1mMWIzLTRkMDgtODE3MS1lZGUyNTc1ZjA2YTc7cmF3X2lkLGZpbGUtR0ZLNUNCOVVuWk1KWVo0M2FKUmUxZw",
         "object": "file", "created_at": 1787427769, "bytes": 481, "status": "processed",
         "purpose": "batch", "filename": "batch.jsonl", "expires_at": 1790019769, "status_details": null},
        {"id": "bGl0ZWxsbV9wcm94eTpwYXNzdGhyb3VnaDtwcm92aWRlcjpvcGVuYWk7dW5pZmllZF9pZCxmYTQ0ZjA4Mi0wZWQ3LTQ5OGUtYjc1NC04MTM4OTk3MmQ0YzY7cmF3X2lkLGZpbGUtOHdnQnZWOThRdVlBQUQxTGJHSnp5dA",
         "object": "file", "created_at": 1787427725, "bytes": 481, "status": "processed",
         "purpose": "batch", "filename": "batch.jsonl", "expires_at": 1790019725, "status_details": null}
    ],
    "has_more": false
}

The second row is the one the old build wrote, and it comes back clean without any data migration

  1. curl -sS http://127.0.0.1:4761/v1/files -H "Authorization: Bearer $KEY" returns both rows with the nine OpenAI keys

Byte-identical to the Before run apart from the ids and timestamps OpenAI assigns, including the nested "guardrail": null, which is why the fix targets the one top-level key rather than reaching for exclude_none

Type

🐛 Bug Fix

Caveats (if any)

  • DELETE is unaffected; it returns a different shape
  • The field stays on the shared model rather than moving to _hidden_params, since moving it would change the create route's public body shape a second time
  • OpenAIFileObject.json() is left alone: it returns a dict from a method contracted to return a string, but it has no callers in this repo and any SDK caller relying on it would break
  • The wrap serializer's return annotation is deliberately omitted, because pydantic derives the model's serialization schema from it and any annotation collapses that schema to a bare object

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

The field is declared optional on OpenAIFileObject and its own docstring says it
is absent on every upload guardrails did not touch, but the /v1/files routes have
no response_model, so FastAPI falls through to jsonable_encoder with exclude_none
off and serialises the unset default as an explicit null. Every create and
retrieve response on a proxy with no guardrails configured at all picked up a
litellm_batch_guardrail: null it never had before, and so did every row of a file
list, since those rows are the same object.

A wrap serializer drops the key only when nothing set it, so the populated report
still reaches the wire intact, including a record whose guardrail is null. The
managed-files list route spreads a stored file_object blob rather than the model,
so rows persisted before this lands keep their null until it is dropped there too.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@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 8aad40a. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR restores the OpenAI-compatible file response shape by omitting litellm_batch_guardrail when no guardrail acted

  • Adds a model serializer that removes only the absent top-level guardrail field while preserving populated reports and nested nulls
  • Cleans legacy null guardrail fields from database-backed managed-file list responses
  • Adds endpoint, managed-list, and model-serialization regression coverage

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/types/llms/openai.py Adds targeted serialization behavior that omits an unset batch guardrail report while retaining populated report data
litellm/proxy/pass_through_endpoints/managed_id_rewriter.py Removes the legacy null extension field from locally constructed managed-file list items
tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py Covers file creation with no configured guardrail and with a no-op guardrail
tests/test_litellm/proxy/pass_through_endpoints/test_managed_id_rewriter.py Verifies database-backed file lists omit legacy persisted null guardrail fields
tests/test_litellm/types/llms/test_types_llms_openai.py Covers absent and populated reports, nested null preservation, aliases, schemas, round trips, and nested list serialization

Reviews (2): Last reviewed commit: "fix(proxy): omit litellm_batch_guardrail..." | Re-trigger Greptile

Comment thread litellm/proxy/pass_through_endpoints/managed_id_rewriter.py
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_batch_guardrail_field_absent (8aad40a) with litellm_internal_staging (490c9f9)

Open in CodSpeed

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review latest head and rescore

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks!

@yucheng-berri
yucheng-berri merged commit 8a7c873 into litellm_internal_staging Aug 22, 2026
77 of 78 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_batch_guardrail_field_absent branch August 22, 2026 21:51
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