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 0000000..4558d99 Binary files /dev/null and b/__pycache__/patch_expose_cost_headers.cpython-314.pyc differ 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}"