Files
litellm/patch_expose_cost_headers.py
T
inference-bot 83c543a0ba gateway: expose the eight telemetry headers requested in co-op/support#39
Adds x-litellm-model-name, -call-id, -key-spend, -response-duration-ms, -overhead-duration-ms, -callback-duration-ms, -attempted-retries and -attempted-fallbacks to the CORS expose list, on the GM's go-ahead. Renames COST_HEADERS to EXPOSED_HEADERS since the list is no longer only costs. x-litellm-key-spend discloses the caller's own key spend, which is their own data; noted in the thread so it can be dropped if anyone objects.
2026-10-31 21:26:03 +00:00

91 lines
3.7 KiB
Python

"""Expose LiteLLM's per-request cost headers to cross-origin browser clients.
MEMBER REQUEST (co-op/support#39, 2026-10-31). Browser JS calling the gateway from
another origin cannot read a response header unless the server names it in
Access-Control-Expose-Headers. LiteLLM sets
expose_headers=LITELLM_UI_ALLOW_HEADERS
which covers seven names (semantic-filter, guardrail, cache-key, ...) and none of
the cost ones — so `x-litellm-response-cost*` reads as null in JS even though the
proxy sends those headers on the response. Upstream closed the request
(BerriAI/litellm#33123) as stale, so it has to be handled here at the app layer,
exactly like the other patches in this directory.
Additive only. It lets a caller read per-request pricing for requests they made
with their own API key — numbers already present in the response body for
non-streaming calls, and already visible in their dashboard. It exposes no prompt,
key, or account data, and no other member's usage.
Run at BUILD time (the runtime rootfs is read-only under Cloudron). Non-fatal, per
house rule: if upstream moves or rewrites this call, WARN and exit 0 — a missing
convenience header must never keep the gateway from booting.
Regression check: after a deploy, a browser request from an unrelated origin should
see the cost headers listed in Access-Control-Expose-Headers on the response.
"""
from pathlib import Path
import sys
# The header names the proxy can set, taken from the running image rather than
# guessed: grep -rhoE 'x-litellm-[a-z0-9-]*cost[a-z0-9-]*' over the litellm package.
EXPOSED_HEADERS = [
"x-litellm-response-cost",
"x-litellm-response-cost-input",
"x-litellm-response-cost-output",
"x-litellm-response-cost-original",
"x-litellm-response-cost-discount-amount",
"x-litellm-response-cost-margin-amount",
"x-litellm-response-cost-margin-percent",
"x-litellm-response-cost-cache-creation",
"x-litellm-response-cost-cache-read",
"x-litellm-response-cost-reasoning",
"x-litellm-response-cost-tool-usage",
"x-litellm-classifier-cost",
# Telemetry names, requested in co-op/support#39 (2026-10-31): model identity,
# call id, the caller's own key spend, timings and retry/fallback counts.
"x-litellm-model-name",
"x-litellm-call-id",
"x-litellm-key-spend",
"x-litellm-response-duration-ms",
"x-litellm-overhead-duration-ms",
"x-litellm-callback-duration-ms",
"x-litellm-attempted-retries",
"x-litellm-attempted-fallbacks",
]
OLD = " expose_headers=LITELLM_UI_ALLOW_HEADERS,\n"
NEW = (
" expose_headers=LITELLM_UI_ALLOW_HEADERS\n"
" + [\n"
+ "".join(f' "{h}",\n' for h in EXPOSED_HEADERS)
+ " ],\n"
)
def main() -> int:
# Glob rather than pin a Python version: the venv path moves when the base
# image bumps its interpreter, and a stale path would silently skip the patch.
candidates = sorted(Path("/app/.venv/lib").glob("python3*/site-packages/litellm/proxy/proxy_server.py"))
if not candidates:
print("WARN expose-cost-headers: proxy_server.py not found under /app/.venv/lib — skipping")
return 0
target = candidates[-1]
src = target.read_text()
if "x-litellm-response-cost-input" in src:
print("expose-cost-headers: already applied")
return 0
found = src.count(OLD)
if found != 1:
print(f"WARN expose-cost-headers: expected exactly 1 CORS middleware call, found {found} — skipping")
return 0
target.write_text(src.replace(OLD, NEW))
print(f"expose-cost-headers: appended {len(EXPOSED_HEADERS)} cost header names to expose_headers in {target}")
return 0
if __name__ == "__main__":
sys.exit(main())