mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
* fix(e2e): route model management to the control plane and restore Gateway.create_model
The split-transport routing table listed only /model/info as a control-plane
prefix, so /model/new and /model/delete were sent to the data-plane gateway,
which does not serve management routes and 404s them. Every suite that
registers deployments at runtime (llm_translation, batches, access_control)
failed on the split stage deployment because of this. Widen the prefix to
/model/ so all model-management routes reach the control plane while /models
stays on the data plane.
Separately, batch_client.py and several llm_translation tests call
gateway.create_model, but Gateway never had that method, so all 17 batch tests
errored at fixture setup with AttributeError. Add create_model/delete_model to
Gateway (with the optional mode that batches needs) and make EndpointsClient
delegate to it instead of carrying its own copy.
Regression tests cover both: the routing predicate for management vs LLM paths
and the Gateway model-management surface via a typed fake Transport. Both fail
on the previous code
* test(e2e): make the fake transport payload depend on response_type
The recording fake always answered with {"model_id": ...} even when the
caller asked for NoBody, which only validated because pydantic ignores extra
fields by default. Return an empty payload for response types that carry no
fields so a future extra="forbid" on NoBody cannot turn the delete test into
a ValidationError inside the fake
* test(e2e): probe the full spend read surface including schema-hidden routes
The curated spend-route list missed twelve read endpoints, most of them
include_in_schema=False and therefore invisible to the schema-discovery test:
/spend/logs/v2, /spend/logs/session/ui, /global/all_end_users,
/global/activity/exceptions/deployment, and the per-entity daily activity
family (user, user aggregated, team, organization, customer, end_user, tag).
Add them all, verified responsive against the live split stage deployment.
/end_user was missing from CONTROL_PLANE_PREFIXES, so /end_user/daily/activity
would have been routed to the data plane and 404ed like /model/new used to;
add the prefix and pin it plus the daily-activity routes in the transport
routing test.
/provider/budgets stays excluded with a documented reason: it returns 500
whenever router_settings.provider_budget_config is absent, so probing it on a
proxy without provider budget routing configured can never be green
223 lines
6.7 KiB
Python
223 lines
6.7 KiB
Python
"""Spend-tracking e2e client: a Gateway plus the spend-specific read endpoints.
|
|
|
|
Generic proxy operations (keys, customers, chat/embed, route probing, SpendLogs
|
|
polling) come from the shared Gateway, DI'd in (composition, not inheritance).
|
|
This client adds only the spend surface: /spend/calculate, /spend/tags,
|
|
key-spend polling, and the route probes the breadth test uses.
|
|
|
|
Re-exports unwrap / is_ok / unique_marker / SpendLogRow so the tests import their
|
|
helpers from one place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from e2e_config import unique_marker
|
|
from e2e_http import (
|
|
NoBody,
|
|
ProbeResult,
|
|
Result,
|
|
StreamingResponse,
|
|
Success,
|
|
is_ok,
|
|
unwrap,
|
|
)
|
|
from e2e_gateway import Gateway, build_gateway
|
|
from models import (
|
|
ChatBody,
|
|
ChatMessage,
|
|
ChatMetadata,
|
|
ChatResponse,
|
|
DateRangeParams,
|
|
EmbedBody,
|
|
EmbedResponse,
|
|
OpenAPISchema,
|
|
SpendCalculateBody,
|
|
SpendCalculateResponse,
|
|
SpendLogRow,
|
|
SpendLogsPage,
|
|
SpendLogsPageParams,
|
|
SpendTagsResponse,
|
|
TagSpend,
|
|
)
|
|
|
|
__all__ = [
|
|
"SpendClient",
|
|
"build_client",
|
|
"reset_spend_logs",
|
|
"unique_marker",
|
|
"unwrap",
|
|
"is_ok",
|
|
"SpendLogRow",
|
|
"ProbeResult",
|
|
]
|
|
|
|
|
|
def reset_spend_logs() -> None:
|
|
"""Truncate LiteLLM_SpendLogs for a clean slate. No proxy endpoint deletes
|
|
spend logs (/global/spend/reset keeps them), so go to the DB directly. Uses
|
|
DATABASE_URL (default: the local docker postgres on its mapped host port; note
|
|
the in-container `@db` host isn't resolvable from the host, so default to
|
|
localhost).
|
|
"""
|
|
import psycopg
|
|
|
|
url = os.environ.get(
|
|
"DATABASE_URL",
|
|
"postgresql://llmproxy:dbpassword9090@localhost:5432/litellm",
|
|
)
|
|
with psycopg.connect(url) as conn:
|
|
_ = conn.execute('TRUNCATE TABLE "LiteLLM_SpendLogs"')
|
|
|
|
|
|
def _chat_body(
|
|
model: str,
|
|
content: str,
|
|
*,
|
|
max_tokens: int | None = None,
|
|
tags: list[str] | None = None,
|
|
user: str | None = None,
|
|
stream: bool = False,
|
|
) -> ChatBody:
|
|
return ChatBody(
|
|
model=model,
|
|
messages=[ChatMessage(role="user", content=content)],
|
|
max_tokens=max_tokens,
|
|
stream=stream,
|
|
user=user,
|
|
metadata=ChatMetadata(tags=tags) if tags else None,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SpendClient:
|
|
gateway: Gateway
|
|
|
|
def chat(
|
|
self,
|
|
key: str,
|
|
model: str,
|
|
content: str,
|
|
*,
|
|
max_tokens: int | None = None,
|
|
tags: list[str] | None = None,
|
|
user: str | None = None,
|
|
) -> Result[ChatResponse]:
|
|
return self.gateway.chat(
|
|
key, _chat_body(model, content, max_tokens=max_tokens, tags=tags, user=user)
|
|
)
|
|
|
|
def chat_stream(
|
|
self, key: str, model: str, content: str, *, max_tokens: int | None = None
|
|
) -> StreamingResponse:
|
|
return self.gateway.chat_stream(
|
|
key, _chat_body(model, content, max_tokens=max_tokens, stream=True)
|
|
)
|
|
|
|
def embed(self, key: str, model: str, content: str) -> Result[EmbedResponse]:
|
|
return self.gateway.embed(key, EmbedBody(model=model, input=content))
|
|
|
|
def poll_logs_for_key(
|
|
self,
|
|
key: str,
|
|
*,
|
|
min_rows: int = 1,
|
|
predicate: Callable[[list[SpendLogRow]], bool] | None = None,
|
|
) -> list[SpendLogRow]:
|
|
return self.gateway.poll_logs_for_key(
|
|
key, min_rows=min_rows, predicate=predicate
|
|
)
|
|
|
|
def calculate_spend(self, model: str, content: str) -> float:
|
|
return unwrap(
|
|
self.gateway.transport.post(
|
|
"/spend/calculate",
|
|
headers=self.gateway.transport.master,
|
|
json=SpendCalculateBody(
|
|
model=model, messages=[ChatMessage(role="user", content=content)]
|
|
),
|
|
response_type=SpendCalculateResponse,
|
|
)
|
|
).cost
|
|
|
|
def spend_by_tags(self) -> list[TagSpend]:
|
|
result = self.gateway.transport.get(
|
|
"/spend/tags",
|
|
headers=self.gateway.transport.master,
|
|
params=NoBody(),
|
|
response_type=SpendTagsResponse,
|
|
)
|
|
match result:
|
|
case Success(data=data):
|
|
return data.root
|
|
case _:
|
|
return []
|
|
|
|
def poll_tag_spend(self, tag: str, *, minimum: float = 0.0) -> TagSpend | None:
|
|
"""Poll /spend/tags until the tag's aggregate reaches `minimum`; last seen."""
|
|
deadline = time.monotonic() + self.gateway.poll_timeout
|
|
entry: TagSpend | None = None
|
|
while time.monotonic() < deadline:
|
|
matches = [
|
|
t for t in self.spend_by_tags() if t.individual_request_tag == tag
|
|
]
|
|
if matches:
|
|
entry = matches[0]
|
|
if (entry.total_spend or 0.0) >= minimum:
|
|
return entry
|
|
time.sleep(self.gateway.poll_interval)
|
|
return entry
|
|
|
|
def poll_key_spend(self, key: str, *, minimum: float = 0.0) -> float:
|
|
deadline = time.monotonic() + self.gateway.poll_timeout
|
|
spend = 0.0
|
|
while time.monotonic() < deadline:
|
|
spend = self.gateway.key_info(key).spend or 0.0
|
|
if spend > minimum:
|
|
return spend
|
|
time.sleep(self.gateway.poll_interval)
|
|
return spend
|
|
|
|
def spend_logs_page(
|
|
self, *, api_key: str | None, page: int, page_size: int
|
|
) -> SpendLogsPage:
|
|
"""One page of /spend/logs/v2 over a window wide enough to contain every
|
|
row this test run wrote (the endpoint requires explicit dates)."""
|
|
now = datetime.now(timezone.utc)
|
|
fmt = "%Y-%m-%d %H:%M:%S"
|
|
return unwrap(
|
|
self.gateway.transport.get(
|
|
"/spend/logs/v2",
|
|
headers=self.gateway.transport.master,
|
|
params=SpendLogsPageParams(
|
|
start_date=(now - timedelta(days=1)).strftime(fmt),
|
|
end_date=(now + timedelta(days=1)).strftime(fmt),
|
|
page=page,
|
|
page_size=page_size,
|
|
api_key=api_key,
|
|
),
|
|
response_type=SpendLogsPage,
|
|
)
|
|
)
|
|
|
|
def probe(self, path: str, *, params: DateRangeParams) -> ProbeResult:
|
|
return self.gateway.transport.probe(path, params=params)
|
|
|
|
def openapi(self) -> OpenAPISchema:
|
|
return unwrap(
|
|
self.gateway.transport.get(
|
|
"/openapi.json",
|
|
headers=self.gateway.transport.master,
|
|
params=NoBody(),
|
|
response_type=OpenAPISchema,
|
|
)
|
|
)
|
|
|
|
|
|
def build_client() -> SpendClient:
|
|
return SpendClient(gateway=build_gateway())
|