feat: add opt-in routing, quota, and observability for LiteLLM Sample - #355
feat: add opt-in routing, quota, and observability for LiteLLM Sample#355pweiber wants to merge 16 commits into
Conversation
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
…e to include the improvements Signed-off-by: pweiber <periclesweiber@ciandt.com>
leonm1
left a comment
There was a problem hiding this comment.
Some initial large scoped feedback, I will continue reading through and give more targeted comments as well.
| """Request-leg pre-check: unknown/expired key, model allowlist, RPM, TPM, | ||
| accumulated token budget, and per-model budget (model_max_budget). | ||
|
|
||
| Quota is opt-in per request: a request that presents no virtual key (no |
There was a problem hiding this comment.
Quota can't be opt-in per request: if you exceed your quota, you can just remove the API key. Please make it so that quota is either enabled for all or none requests, or provide an opt-in configuration option (i.e. defaulted to off) which allows unauthenticated requests to bypass quota.
There was a problem hiding this comment.
Done, let me know if that's fine now
There was a problem hiding this comment.
This optional_params is passed to LiteLLM's get_complete_url() function directly, and req_map is parsed from the request body. This allows adversaries to smuggle parameters to get_complete_url and override the behavior of endpoint picking.
Validate all the parameters that get used, and only pass explicitly validated parameters that we plan to support.
There was a problem hiding this comment.
Done, let me know if that's fine now
| from datetime import datetime, timezone | ||
| from typing import Any, NamedTuple, Optional | ||
|
|
||
| import redis |
There was a problem hiding this comment.
This callout server could be fairly high concurrency. We should be using redis.asyncio.Redis rather than sync redis.
There was a problem hiding this comment.
Thanks for all the review, for the async redis I believe we would need an await point in the callout, and our current sdk is still sync. I created a draft to move it to asyncio in #315 while we were load testing it some months ago. I think this affects limits.aio too. What do you think?
There was a problem hiding this comment.
I will review #315 in parallel, it looks fairly small in comparison. We can implement the handler here with asyncio and, worst case, run the async handlers from a sync method with asyncio.run(). It'll probably be slower in the short term (waiting for async from sync provides no performance benefit afaict), but once we can land #315 we can make it async native, which will greatly increase performance.
There was a problem hiding this comment.
Done, I tried asyncio.run() per call and it does not survive a second request. It closes its loop on return while the Redis pool outlives it, so the next call gets a connection bound to a dead loop:
RuntimeError: Event loop is closed
then got
Future attached to a different loop
Running the coroutines on one long-lived loop instead fixed it with no other change. That loop is ~20 lines and I believe it can be deleted the moment #315 lands.
| if not _enabled or not api_key: | ||
| return {} | ||
| try: | ||
| cfg = _client.hgetall(f"key:{api_key}") |
There was a problem hiding this comment.
This will fetch the config from redis three times per request: once in check(), once in usage_headers(), and once in record() (that'll be for the request headers, response headers, and response body iiuc).
That amplifies the incoming traffic into significantly more per request. We should use a provider with short TTL (1-5s) cache for the config (best) to share it across requests, or at the very least fetch it only once per request and store it in the state of the grpc stream handler (okay).
There was a problem hiding this comment.
Done, let me know if that's fine now
| if allowed_models and not _model_allowed(model, allowed_models): | ||
| return QuotaDecision(False, 403, "model not allowed for key") | ||
| rpm_limit = int(cfg.get("rpm_limit", 0) or 0) | ||
| if rpm_limit: |
There was a problem hiding this comment.
Rather than implememting limits ourselves, we should use a library that implements all of this and is well tested. limits.aio has a redis library that will:
- Use asyncio for communication with redis, with a single pipelined request rather than multiple round trips
- Use atomic redis-side accounting for the limits, which is not subject to concurrency races when querying and updating concurrently
- Has unit and integration tests asserting its correctness
There was a problem hiding this comment.
Adopted for rpm_limit: limits.aio with MovingWindowRateLimiter over the same Redis. But for tpm_limit did not fit and stays a counter. limits declines to record a hit whose cost exceeds the limit, and a single response routinely costs more than a whole minute's token allowance, so the window never filled and the limit never fired: hit(cost=30) against a limit of 10 returns False and leaves remaining: 10. From the test I did, tpm went [200, 200, 200] on the library and [200, 429, 429] on the counter. Maybe after the #315 we can try again to see if it works properly or if it was a configuration issue from my side
| @@ -91,6 +98,25 @@ | |||
| "host", ":authority", ":path", "content-length", "content-type", | |||
There was a problem hiding this comment.
I believe it could not go in that set. _MANAGED_HEADERS (:107) is a skip-list over LiteLLM's output headers, since the callout owns :path, :authority and the content headers itself, adding authorization there would skip LiteLLM's own Authorization: Bearer <ADC token> and send every Vertex request out unauthenticated. It is handled at :525 instead: when LiteLLM emits an authorization the rewrite overwrites the caller's value (OVERWRITE_IF_EXISTS_OR_ADD), and when the provider authenticates some other way and emits none (Anthropic's x-api-key), authorization is added to remove_headers. Either way the caller's virtual key stops at the gateway.
| section = _config.get("router_settings") | ||
| if not section: | ||
| return None | ||
| tag_rules: dict[tuple[str, str], str] = {} |
There was a problem hiding this comment.
The second parameter to .get() is a default, but it is not returned in case the yaml section is explicitly set but null. It's safer to explicitly fall back with a falsiness check:
# If section.get("tag_rules") returns None, `None or []` evaluates to `[]`
for r in (section.get("tag_rules") or []):
There was a problem hiding this comment.
Done, let me know if that's fine now
| total = sum(weight for _, weight in members) | ||
| if total <= 0: | ||
| return {} | ||
| key = headers.get(hash_header) or model_id |
There was a problem hiding this comment.
Falling back to the model_id will break split routing. All requests will go to the same backend in the failure case. If there is no header to use for determinism, we should fail safe and use a generated random value for the request.
There was a problem hiding this comment.
Done, let me know if that's fine now
|
|
||
| def quota_settings() -> QuotaSettings: | ||
| section = _config.get("general_settings") or {} | ||
| return QuotaSettings(fail_open=bool(section.get("quota_fail_open", True))) |
There was a problem hiding this comment.
LLMs are expensive, we should probably fail closed
There was a problem hiding this comment.
Done, let me know if that's fine now
| return | ||
| span.set_attribute("gen_ai.operation.name", operation) | ||
| span.set_attribute("gen_ai.system", provider or "") | ||
| span.set_attribute("gen_ai.request.model", model or "") |
There was a problem hiding this comment.
Should we also log original model from x-request-id?
There was a problem hiding this comment.
Added as litellm.request.original_model, under litellm.
| if cost is not None: | ||
| span.set_attribute("gen_ai.cost.total_cost", cost) | ||
| if call_id: | ||
| span.set_attribute("litellm.call_id", call_id) |
There was a problem hiding this comment.
Should we also log the strategy that caused the model to be chosen?
There was a problem hiding this comment.
Added as litellm.routing_strategy
|
|
||
| ### Telemetry (OpenTelemetry) | ||
|
|
||
| Each LLM request emits one span (`llm.request`) with GenAI |
There was a problem hiding this comment.
The opentelemetry spec says model requests should be logged with a specific span name:
{gen_ai.operation.name} {gen_ai.request.model}
There was a problem hiding this comment.
Done, let me know if that's fine now
| headers that do not yet include that request's own usage, since spend is | ||
| recorded after the response body is read. | ||
|
|
||
| ### Telemetry (OpenTelemetry) |
There was a problem hiding this comment.
This exposes spans, but we should also expose metrics: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md
There was a problem hiding this comment.
Done, let me know if that's fine now
|
|
||
| # Quota reject HTTP status codes mapped to Envoy StatusCode values. | ||
| _QUOTA_STATUS = { | ||
| 401: StatusCode.Unauthorized, |
There was a problem hiding this comment.
If I understand correctly, StatusCode.Unauthorized is an enum with the value "401". Is the mapping here providing any value? If you are worried that the int must be a valid value in the proto, you could use something like:
try:
StatusCode.Name(http_status_int)
http_status_valid = True
except ValueError:
http_status_valid = False
or (less readably but apparently faster):
http_status_valid = http_status_int in StatusCode.DESCRIPTOR.values_by_number
There was a problem hiding this comment.
No value, the enum values are the HTTP codes, so it was an identity map. Dropped, and the status is passed straight through, with a values_by_number guard so an unmapped code degrades to 500 rather than raising inside the proto.
| _config: dict[str, Any] = {} | ||
|
|
||
|
|
||
| def load() -> None: |
There was a problem hiding this comment.
Rather than a free function that references a global, I would typically expect callers to use a constructor or factory that returns the config, something like:
config = RoutingSettings.load()
Is there a reason to prefer the module global? If you need to override this for testing, you can mock out the function.
There was a problem hiding this comment.
Done, GatewayConfig.load() and .from_dict() are classmethods that return a value, and the module global is gone. Same shape applied to quota, telemetry and routing.
| tag = r.get("tag") if isinstance(r, dict) else None | ||
| target = r.get("target") if isinstance(r, dict) else None | ||
| if not provider_prefix or not tag or not target: | ||
| logging.warning( |
There was a problem hiding this comment.
Rather than logging a warning, what do you think about instead raising for any error you find in the config? Since this parser is run at startup, it seems reasonable to fail to start if the config is bad.
In that world, it's probably a good idea to do this parsing as soon as you load the config (and save the result in a [member] variable), rather than waiting until routing_settings is called.
There was a problem hiding this comment.
Agreed, the whole file is parsed in from_dict at startup and anything unusable raises
| return None | ||
| tag_rules: dict[tuple[str, str], str] = {} | ||
| for r in section.get("tag_rules", []): | ||
| provider_prefix = r.get("provider_prefix") if isinstance(r, dict) \ |
There was a problem hiding this comment.
All of this code seems to depend on isinstance(r, dict) being true. I think it would make sense to check that up-front and error handle it specifically, then the code below can unconditionally reference r. I think that would make the rest of the code easier to read. What do you think?
There was a problem hiding this comment.
Done, _section() does the check once and every parser entry point goes through it, so a non-mapping section fails with the section name rather than an AttributeError deeper in.
| for group, members in section.get("weighted_groups", {}).items(): | ||
| parsed: list[tuple[str, int]] = [] | ||
| for m in members: | ||
| model = m.get("model") if isinstance(m, dict) else None |
There was a problem hiding this comment.
Same fix, a non-mapping member raises with the group name and the offending entry, instead of being assumed to be a mapping.
| ) -> service_pb2.ProcessingResponse | None: | ||
| state = _state(context) | ||
| if state.route_mode: | ||
| header_map = { |
There was a problem hiding this comment.
I think it would be good to move this route mode handling to its own function. WDYT?
There was a problem hiding this comment.
Agreed, it is _route_extension_response() now.
| except json.JSONDecodeError as e: | ||
| logging.warning("Invalid JSON body: %s", e) | ||
| return callout_tools.header_immediate_response(StatusCode.BadRequest) | ||
| self._end_span(state, 400) |
There was a problem hiding this comment.
WDYT about a single function that both updates the telemetry and calls header_immediate_response? This seems especially useful now that we know that StatusCode.BadRequest == 400.
There was a problem hiding this comment.
Agreed, it is _reject(state, status) now
| if state.provider in ("vertex_ai", "vertex_ai_beta"): | ||
| from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( | ||
| ModelResponseIterator, | ||
| from litellm.llms.vertex_ai.gemini import ( |
There was a problem hiding this comment.
There is a performance penalty to importing a python package inline, rather than once at the top of the file, and this is a hot function. Is that a reason you decided to do it this way?
There was a problem hiding this comment.
No good reason, it was a misconfiguration from my IDE, should be fine now
| def init_tracer() -> None: | ||
| """Initialize the global tracer once. No-op without an OTLP endpoint.""" | ||
| global _tracer, _enabled | ||
| if _enabled: |
There was a problem hiding this comment.
Do we need _enabled, or can we just check if _tracer is None?
There was a problem hiding this comment.
Gone, same as in quota. enabled() is self._tracer is not None, and a Telemetry with no tracer is a working no-op, so nothing branches on it.
| resource=Resource.create({"service.name": service_name})) | ||
| provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) | ||
| _install_provider(provider) | ||
| atexit.register(provider.shutdown) |
There was a problem hiding this comment.
Do you need this? The internet seems to think that TracerProvider will do this for you.
There was a problem hiding this comment.
Just checked, the TracerProvider.__init__ defaults shutdown_on_exit=True and registers it already. Removed the duplicate.
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
…hers Signed-off-by: pweiber <periclesweiber@ciandt.com>
Signed-off-by: pweiber <periclesweiber@ciandt.com>
hillsp
left a comment
There was a problem hiding this comment.
Thanks for the improvements! I would have resolved a lot of the comment threads but I it turns out that I don't have permission. Please resolve anything that I didn't explicitly comment on.
| return int(time.time()) // _window_seconds(window) | ||
|
|
||
|
|
||
| def _parse_list(raw: str) -> list[str]: |
There was a problem hiding this comment.
Thanks for the change here and in _parse_model_budgets. I notice that you removed the try/catch for JSONDecodeError. Are you confident that we don't need that any more, or should these return empty in that case?
| return _enabled | ||
|
|
||
|
|
||
| def _window_seconds(window: str) -> int: |
There was a problem hiding this comment.
Appreciate you moving this out of the request parsing. If it turns out that we need to consume a LiteLLM keys file, we could easily add that parsing into seed_keys.py in the future, without breaking anything. Thanks!
| if not _enabled or not api_key: | ||
| return {} | ||
| try: | ||
| cfg = _client.hgetall(f"key:{api_key}") |
There was a problem hiding this comment.
Thanks for those changes - that should have improved the performance for sure.
Looking at the code you have now, I think it would be a nice cleanup to wrap the API key, model and config into a class, something like this (much simplified):
class QuotaHandler:
@classmethod
def create(cls, async_bridge, quota, api_key, model):
cfg = async_bridge.run(quota.key_config(state.api_key))
return QuotaHandler(async_runner, quota, api_key, model, cfg)
def check(self):
try:
return self.async_bridge.run(self.quota.check(self.api_key, self.model, self.cfg))
except Exception:
return self.quota.unavailable()
if self.quota.enabled:
state.quota_handler = QuotaHandler.create(self._async, self.quota, state.api_key, model)
decision = state.quota_handler.check()
else:
sate.quota_handler = None
[...]
if state.quota_handler:
self.quota_handler.record()
That's a totally optional suggestion - what you have solves the performance problem.
Adds 3 optional capabilities to the LiteLLM callout sample. Each one is inert unless configured, so with nothing set the callout behaves exactly as before.
Model routing: A route extension rewrites the x-model-id header before the URL map evaluates routing, so a request can be steered by model alias, by a tier tag, by a weighted A/B split, or by simple-shuffle across a model group. The rewritten header also becomes authoritative over the request body, so a routing decision changes the model that answers, not just the backend it
reaches. Enabled by the router_settings section of the gateway config.
Token management: Virtual keys stored in Memorystore for Redis, enforced in two phases: checked before the upstream call and recorded from the real token usage on the response. Supports token budgets per key and per model, requests
per minute and tokens per minute limits, model allowlists, key expiry, and warn-only soft budgets, returning 401, 402, 403 and 429 as appropriate.
Successful responses carry the LiteLLM usage headers. Keys are defined in Terraform and seeded by a Cloud Run Job, or by a seeder script for ad hoc changes. Enabled by REDIS_HOST.
Observability: One OpenTelemetry span per request with GenAI semantic-convention attributes: provider, model, token usage, cost in USD from LiteLLM's bundled price map, a call id that is also returned as a response header, and a truncated hash of the virtual key. Exported over OTLP to any collector, with per-request opt-out. Enabled by OTEL_EXPORTER_OTLP_ENDPOINT.
Regional LB: A regional Terraform configuration that deploys the gateway with both extensions, Memorystore, and the seed-keys job.
Docs: a WALKTHROUGH.md that steps through each capability with the exact curl and log commands and the expected output for the new scenarios.