Skip to content

feat(proxy): make CORS expose_headers configurable via LITELLM_CORS_EXPOSE_HEADERS#33125

Open
devin-ai-integration[bot] wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_cors_expose_headers_env
Open

feat(proxy): make CORS expose_headers configurable via LITELLM_CORS_EXPOSE_HEADERS#33125
devin-ai-integration[bot] wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_cors_expose_headers_env

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #33123

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

Captured at commit 55722dabcf against a live proxy hitting the real Anthropic API (claude-haiku-4-5)

With LITELLM_CORS_EXPOSE_HEADERS="x-litellm-response-cost, x-litellm-model-api-base":

$ LITELLM_CORS_EXPOSE_HEADERS="x-litellm-response-cost, x-litellm-model-api-base" \
    python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml

$ curl -sD - -o /dev/null -X POST http://localhost:4000/v1/chat/completions \
    -H "Content-Type: application/json" -H "Origin: https://app.example.com" \
    -H "Authorization: Bearer sk-1234" \
    -d '{"model":"anthropic-haiku-4-5","messages":[{"role":"user","content":"say hi in one word"}]}'
HTTP/1.1 200 OK
x-litellm-response-cost: 3.2000000000000005e-05
access-control-expose-headers: x-litellm-semantic-filter, x-litellm-semantic-filter-tools, x-litellm-adaptive-router-model, x-litellm-response-cost, x-litellm-model-api-base

With the env var unset (default, unchanged), only the built-in defaults are exposed:

access-control-expose-headers: x-litellm-semantic-filter, x-litellm-semantic-filter-tools, x-litellm-adaptive-router-model

Type

🆕 New Feature

Changes

expose_headers on the proxy's CORSMiddleware was hardcoded to LITELLM_UI_ALLOW_HEADERS, so browser clients calling the proxy cross-origin could not read useful x-litellm-* response headers such as x-litellm-response-cost and x-litellm-model-api-base, even though allow_origins/credentials were already env-configurable

This adds a LITELLM_CORS_EXPOSE_HEADERS env var (comma-separated, parsed like LITELLM_CORS_ORIGINS). A new _get_cors_expose_headers helper appends the configured headers to the existing LITELLM_UI_ALLOW_HEADERS defaults, de-duplicated with order preserved, and the result is passed to CORSMiddleware(expose_headers=...). When the var is unset or empty the behaviour is unchanged (defaults only)

def _get_cors_expose_headers(expose_headers_env: str | None = None) -> list[str]:
    _raw = expose_headers_env if expose_headers_env is not None else os.getenv("LITELLM_CORS_EXPOSE_HEADERS")
    extra = [h.strip() for h in _raw.split(",") if h.strip()] if _raw else []
    return list(dict.fromkeys([*LITELLM_UI_ALLOW_HEADERS, *extra]))

Docs for the new env var are added in BerriAI/litellm-docs#552; the documentation validation check here checks out that docs repo and stays red until it merges

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

Link to Devin session: https://app.devin.ai/sessions/4db978bbfa1045a8a89c634e6648eeba

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the expose_headers list passed to Starlette's CORSMiddleware configurable via the LITELLM_CORS_EXPOSE_HEADERS environment variable (comma-separated), replacing the hardcoded LITELLM_UI_ALLOW_HEADERS constant. When unset, behaviour is unchanged.

  • Adds _get_cors_expose_headers() in proxy_server.py, evaluated at module load time alongside the existing _get_cors_config() call, with order-preserving deduplication of any extras against the defaults.
  • Adds three focused unit tests covering the empty/whitespace fallback, append, and dedup paths — all mock-only, consistent with the test file's existing style.

Confidence Score: 4/5

The change is additive and isolated to CORS expose-headers configuration; when the env var is unset, runtime behaviour is identical to before.

The one correctness gap is case-sensitive deduplication against case-insensitive HTTP header names — a mixed-case duplicate from the env var would survive into the final list. Everything else — parsing logic, defaults preservation, test coverage, and module-level wiring — looks correct.

The deduplication logic in _get_cors_expose_headers inside proxy_server.py is worth a second look for the case-sensitivity issue.

Important Files Changed

Filename Overview
litellm/proxy/proxy_server.py Adds _get_cors_expose_headers() helper and module-level cors_expose_headers variable, replacing the hardcoded LITELLM_UI_ALLOW_HEADERS passed to CORSMiddleware(expose_headers=...) with the env-configurable list. Logic is straightforward; deduplication is case-sensitive.
tests/test_litellm/proxy/test_cors_config.py Adds three new pure-mock tests for _get_cors_expose_headers: defaults, append, and dedup. Tests are isolated, make no network calls, and correctly cover the core behaviour.

Reviews (1): Last reviewed commit: "feat(proxy): make CORS expose_headers co..." | Re-trigger Greptile

Comment on lines +1483 to +1485
_raw = expose_headers_env if expose_headers_env is not None else os.getenv("LITELLM_CORS_EXPOSE_HEADERS")
extra = [h.strip() for h in _raw.split(",") if h.strip()] if _raw else []
return list(dict.fromkeys([*LITELLM_UI_ALLOW_HEADERS, *extra]))

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.

P2 The deduplication uses a case-sensitive dict.fromkeys, but HTTP header names are case-insensitive (RFC 7230). If a user sets LITELLM_CORS_EXPOSE_HEADERS=X-LiteLLM-Semantic-Filter (mixed-case), it won't be recognised as a duplicate of the lowercase x-litellm-semantic-filter already in LITELLM_UI_ALLOW_HEADERS, so the final list will contain two entries for what the browser treats as the same header. Normalising to lowercase before the dedup check preserves correctness without changing the wire value.

Suggested change
_raw = expose_headers_env if expose_headers_env is not None else os.getenv("LITELLM_CORS_EXPOSE_HEADERS")
extra = [h.strip() for h in _raw.split(",") if h.strip()] if _raw else []
return list(dict.fromkeys([*LITELLM_UI_ALLOW_HEADERS, *extra]))
_raw = expose_headers_env if expose_headers_env is not None else os.getenv("LITELLM_CORS_EXPOSE_HEADERS")
extra = [h.strip() for h in _raw.split(",") if h.strip()] if _raw else []
seen: dict[str, str] = {}
for h in [*LITELLM_UI_ALLOW_HEADERS, *extra]:
seen.setdefault(h.lower(), h)
return list(seen.values())

@codspeed-hq

codspeed-hq Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_cors_expose_headers_env (55722da) with litellm_internal_staging (0c376d8)1

Open in CodSpeed

Footnotes

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

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.

Make CORS expose_headers configurable (LITELLM_CORS_EXPOSE_HEADERS) so browser clients can read x-litellm-* response headers

1 participant