fix(proxy): cache litellm model resolution to stop repeated Provider List spam - #2860
Conversation
…List spam _resolve_litellm_model() ran an uncached litellm.cost_per_token() probe on every savings-tracking call (i.e. every request). For any model litellm can't price (custom/local/gateway names), that probe fails and litellm prints its "Provider List: https://docs.litellm.ai/docs/providers" banner as a side effect on every failure, flooding the proxy log once (or several times) per request instead of once per process. Cache the resolution per model name, mirroring the existing _resolved_model_cache pattern in headroom.pricing.litellm_pricing that solves this same class of problem. Fixes headroomlabs-ai#2851 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR governanceThis PR follows the template and is marked ready for human review. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Caching the failed probe addresses the repeated banner, but the cache needs a bound before it is safe on a request-facing proxy. The model name is client-controlled, and _model_resolution_cache is a process-lifetime plain dict. A caller can send a new arbitrary model string on every request and grow it without limit; this turns a log-noise fix into an easy memory-retention path. The similar existing cache is not a reason to duplicate that exposure.
Please use a bounded cache (with a deliberate size/eviction policy) and add the automated regression test already outlined in the PR: repeated resolution of one unknown model probes once, while enough distinct names demonstrate the cache remains bounded and an evicted name can be resolved again. That test should also clear/isolate cache state so unrelated pricing tests remain order-independent.
Description
The proxy repeatedly prints LiteLLM's
Provider List: https://docs.litellm.ai/docs/providersbanner during normal operation, with no explanation or way to suppress it (#2851).Root cause:
_resolve_litellm_model()inheadroom/proxy/savings_tracker.pyruns on every savings-tracking update (i.e. every request). For any model LiteLLM can't price (a custom/local/gateway model name — e.g. the reporter's local oMLX setup), the uncached fallback path callslitellm.cost_per_token(...)purely to probe resolvability. When that probe fails, LiteLLM prints the banner as an internal side effect before raising, and since the probe was never cached, it re-fires on every single request for the same unresolvable model.Closes #2851
Type of Change
Changes Made
_model_resolution_cache: dict[str, str]inheadroom/proxy/savings_tracker.py._resolve_litellm_model()into a thin cache-checking wrapper plus_resolve_litellm_model_uncached()(the original logic, unchanged), so the LiteLLM probe runs at most once per distinct model name per process._resolved_model_cachepattern already used for this same problem class inheadroom/pricing/litellm_pricing.py.model_costlookup) — only the noisy uncached probe path is memoized.Testing
pytest)ruff check .)mypy headroom) — not run;mypyisn't installed in this environmentTest Output
Real Behavior Proof
headroom._core) built (maturin developwas not run), so I could not start the actual proxy server or exercise this through a real HTTP request.headroom/proxy/savings_tracker.pyitself has no dependency on the compiled extension, so it imports and runs standalone.headroom.proxy.savings_trackerdirectly, installs a fakelitellmmodule whosecost_per_tokenalways raises (reproducing LiteLLM's real behavior of printing the banner then raising for an unresolvable model), and calls_estimate_compression_savings_usd("oMLX-custom-model", 100)20 times in a loop to simulate 20 proxied requests against the same unpriced model.main(before this fix, verified viagit stash/checkout) the banner printed 20 times for 20 simulated requests — once per request. On this branch (after the fix), the banner printed 1 time total across all 20 simulated requests — confirming the probe now runs once per model per process instead of once per request.headroom proxyprocess handling live HTTP requests against a genuinely unpriced model) — blocked by the missing compiledheadroom._coreextension in this sandbox, as noted above. The savings-tracking call path exercised here (_estimate_compression_savings_usd→_resolve_litellm_model) is the same code path the real proxy calls per-request fromheadroom/proxy/server.py, so I'm confident this generalizes, but I have not confirmed it against the live proxy myself.Review Readiness
Checklist
tests/test_savings_tracker_zero_price.pysuite already covers_resolve_litellm_model's callers and continues to pass unchanged)CHANGELOG.mdAdditional Notes
test_proxy_savings_history.py,test_gateway_sidecar_ports.py, etc.) require the compiledheadroom._coreRust extension, which isn't built in this sandbox (ModuleNotFoundError: No module named 'headroom._core'). Confirmed viagit stashthat this is a pre-existing environment gap unrelated to this change.tests/test_proxy_savings_history.py::test_litellm_resolution_and_savings_estimation_fallbacks(which exercises_resolve_litellm_modelwith a mutatedmodel_costdict across several assertions in one test) against the new caching behavior and confirmed no asserted value changes, since the cache only memoizes the resolved model name string, not the price lookups (which stay live).litellm.cost_per_tokencall count) if a maintainer points me to where they'd want it.