From 197abce9e6434696b77736d5bbe79263f526f1d0 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Sat, 31 Oct 2026 17:20:01 +0000 Subject: [PATCH] gateway: honour LITELLM_CORS_ORIGINS from app env; expose cost headers (#38, #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start.sh hardcoded and exported LITELLM_CORS_ORIGINS, so the Cloudron app env that is meant to control it was silently overridden — the change I made earlier tonight looked applied and was inert. It now defaults only when unset, so the app env wins, and the co-op decision to allow all origins (co-op/support#38) actually takes effect. Comment rewritten for the current version and the reasoning. Also adds patch_expose_cost_headers.py (wired into the Dockerfile) so x-litellm-response-cost* can be read by cross-origin clients (co-op/support#39); upstream closed the request as stale, so it is handled at the app layer like the other patches. --- Dockerfile | 4 + .../patch_expose_cost_headers.cpython-314.pyc | Bin 0 -> 3951 bytes patch_expose_cost_headers.py | 80 ++++++++++++++++++ start.sh | 20 +++-- 4 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 __pycache__/patch_expose_cost_headers.cpython-314.pyc create mode 100644 patch_expose_cost_headers.py diff --git a/Dockerfile b/Dockerfile index 78372c2..9e02f6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,6 +62,10 @@ RUN python3 /app/code/patch_audio_router_pricing.py COPY patch_strip_provider_headers.py /app/code/patch_strip_provider_headers.py RUN python3 /app/code/patch_strip_provider_headers.py +# Expose the per-request cost headers to cross-origin browser clients (co-op/support#39). +COPY patch_expose_cost_headers.py /app/code/patch_expose_cost_headers.py +RUN python3 /app/code/patch_expose_cost_headers.py + # Audio keep-alive: scheduled pings keep the Tinfoil TEE audio enclave # warm so voice mode skips the ~24s cold-start (see audio_keepalive.py header). COPY audio_keepalive.py /app/code/audio_keepalive.py diff --git a/__pycache__/patch_expose_cost_headers.cpython-314.pyc b/__pycache__/patch_expose_cost_headers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4558d99a80a38ce242ae60d322d161edc3157b98 GIT binary patch literal 3951 zcmb7H-)|Jh9iP43^PQKoF#*C45g2d^81BwsJFP(w$$%rA8sjWrn$~cv_r`aN_jb2C zbJ%z4!!?SOMoCpFMJf+{=tCYFHGe=~+rQwD-9omJ8nseY-b^W~+L!h-ySK4%bFDJc z&d$z!=R4mYpZR{jd$PYzCupYz|8n=QIYRy{Ka!U244(ZKgBCG}Ah(I3OevRBB6UcP zQ&Xx?i^SM=RS}w@inO5_>Dj)7!N%L@*+Xt=17xf-HKxRa{00dzGMm3d#>nm=M(-Zt z42bNlUPB-M4k6|Jgd8BZ)Y)B|Hg<-G$*t_+|2p9iq#TT%pRXT?o8o&H`#VHgg3p>iU(*D;FjW zYD~U$WAfT{dff5~oj2?V=`ZLV2koZ5og zu0CN|SS?)iToHIqAy&L_Io7V2SO7Z(*L8}QaTs`1t1ZPpywA_voFKf&(`>P7MxL8fOu|Mnn*f5@vP@YHcOZm=?$L?*tiDYg09?IjWD4fzrcdP zo|qc$$TB!OdU|9O;zM;|IxH_mg~h9w0Av<$3^a4V1%71I6cIPm_o-txU=p3Jn3ixF z)UlUXv=)2ZHwEki=SksgPRn+{Ea3$W0BxdNwuOBUrgEJ|kxmKfz-vH+#1Y&c;bSw9 z{lK;)z7{>X7%39rq(N%{x4vwP$VN7Rw(Qc0*(nOYM0R)UwFT)`(~(RY)Q6P-_gE~p z0l@_ii(s$Ag@xF9@<1sN&quyZINY8d3C`9E+S20)WMS;&hl!$$mRd9QDN5@JuX2NV88qbJWnjb zxID5|LD~`52lt#_bi8^w@LXKb8K|;|vjA2YQwNj)nxO8mv(#Rs^#mj}0H5QAg4J>W zfJ)1Pu~a=zZ%!C9k@{=}pg0xT)TA|=bC^fBJQueG@xw~cNNaV(92ejX-~;AIh}aww z=mJE?Ua2Fiz#H5KegPG0=`1xDam#>%GV>h|7t8!IN+lspMis~dWdr6Q(v*|X!l9m1 z);Wto_(JG&Y%VDQ#s3*Z0LMt&FE5flq=ee6DXKHg!qdA&LIHOQl2b&`Hh4~f-D zBvB5~&=2ByRz!D%9758%PTtr?h_4uAPRSv8ji@W6l|DqijU?4e4n~RLHPu#z)Ml@g zK(-@I^+;Zy`GgFm!c_58s8o3h43E^QECAqN~K^srpEE`o#CqZmkV!D@LcXKf4T)GQFQwaueAid#bMt8?mYWqcVNU)myTdQJt_dPdZQwj;AL zEA70c&822{Y?;F6#(DGL9=9>+T(k|Yo9vUB-yN@F@5{+3p@cM9iT{x;x(?~h{$XUM z;Ua?4JtVk==9u3=u5?FF40A-bf^S+&h_5_Je#22xjr&qua{&7F8#plveDT$YvUH)7 zZO_WEzS7vBMx+hZ5arR%beIG&ZU$1nj0SdP3*G2QyuRJixJWvg_TDdvZ zT{i4TmPM>fvhrzq@v3nR0lSPGzHB0c$8o(Quqp`!VD@*geniLFdgX_MI{-J z;y|I*Oxq0xp>`A?euF`a{Jn>~wD;#)`<24#-r}RZqYw9vw#V+S?p<2l;k4fTq)kn{z%*XP}_aq`q|P? zmp&f+n6JKka!t#BuJyKaCq5nh^araa&$UmSZ=YxFMW@YbZQWhdywA4ff&pj|W=kch zTd6dZYwqa+vq0Xy=D{wj1+Ok73TfB%=+D7)n#a0Plx-e)vS8L^snh&MH^LemWxEO4 z(f>n+^2I{c*6d0b7vmNHB~VT@k96Y~(@ESl4|k)%D)(I6*6wVChd+?0RBP_rU}HHZ zs+mO_e_%414s8oFrBd0mN~JJ^R9i(rif@6+8$37&R|GPlhT2^Zm^>iAB`~EY<&O`x zX|n1HWV(rb978yJuI80d#l0S!#DdIJyoPRFRTSlEYM-J#A0x`XKM?(I*}j2PE4O}- zWWLo>*EJJfLys=BwFrA e%{6u3V=eRE<##T>fBl2G-)p;`szlo#iSr+y=X9|E literal 0 HcmV?d00001 diff --git a/patch_expose_cost_headers.py b/patch_expose_cost_headers.py new file mode 100644 index 0000000..be6e078 --- /dev/null +++ b/patch_expose_cost_headers.py @@ -0,0 +1,80 @@ +"""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. +COST_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", +] + +OLD = " expose_headers=LITELLM_UI_ALLOW_HEADERS,\n" +NEW = ( + " expose_headers=LITELLM_UI_ALLOW_HEADERS\n" + " + [\n" + + "".join(f' "{h}",\n' for h in COST_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(COST_HEADERS)} cost header names to expose_headers in {target}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/start.sh b/start.sh index 0414d38..060d346 100644 --- a/start.sh +++ b/start.sh @@ -89,13 +89,19 @@ export NO_OPENAPI="True" # Proxy settings for Cloudron's reverse proxy export LITELLM_PROXY_BASE_URL="https://${CLOUDRON_APP_DOMAIN}" -# CORS: LiteLLM v1.84.0 reads LITELLM_CORS_ORIGINS natively (upstream added -# the env var in place of the old hardcoded `origins = ["*"]`). Lock browser -# access to the co-op's own chat origin. The gateway is called -# server-to-server (chat → portal → gateway) and by member API keys -# (curl/SDKs, not browsers), so no wildcard is needed. A `*` here would let -# any website's JS hit the gateway. -export LITELLM_CORS_ORIGINS="https://chat.inference.coop" +# CORS: LiteLLM reads LITELLM_CORS_ORIGINS natively (upstream replaced the +# hardcoded `origins = ["*"]` with this env var in v1.84.0). The value is +# deliberately OVERRIDABLE — a Cloudron app env now wins, where before this +# export silently overrode it and made the app env look effective when it was not. +# +# The co-op decided to allow all origins (co-op/support#38, agreed on Loomio and +# by a member): CORS is not an auth boundary here. Every request carries a +# per-member bearer key, which the browser never attaches implicitly, so an origin +# list buys nothing against a party that already holds a key — and a leaked key is +# curl-able regardless. What actually constrains abuse is the per-key budget, +# model scoping and revocation. The fallback keeps the old chat-only lock if the +# env var is ever unset. +export LITELLM_CORS_ORIGINS="${LITELLM_CORS_ORIGINS:-https://chat.inference.coop}" echo "PostgreSQL: ${CLOUDRON_POSTGRESQL_HOST}:${CLOUDRON_POSTGRESQL_PORT}" echo "Redis: ${CLOUDRON_REDIS_HOST}:${CLOUDRON_REDIS_PORT}"