mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
* feat(proxy): offload spend tracking to a pod-local spend worker sidecar py-spy on the gateway showed the post-response _PROXY_track_cost_callback, spend-log and DBSpendUpdateWriter work running on the inference workers' event loop, so a DB or Redis stall backed up the request path. When LITELLM_SPEND_WORKER_ENABLED=true, _ProxyDBLogger serializes one compact typed SpendEvent per success and hands it to a SpendEventProducer that ships it over a unix socket (default) or loopback-only TCP to a sidecar started as `python -m gateway.spend_worker`. The sidecar runs the unchanged _ProxyDBLogger pipeline against the pod's PgBouncer (pooled_database_url). When the sidecar is unreachable, the buffer is full, or the gateway shuts down with events still queued or in flight, the producer applies LITELLM_SPEND_WORKER_ON_UNAVAILABLE (fallback in-process, or drop). The sidecar half-closes producers on SIGTERM and drains, the producer treats EOF as unavailable, and the gateway flushes buffered spend counters on shutdown. The sidecar honors LITELLM_LOG so its writes are visible in its own process log. Helm: both charts gain an opt-in spend-worker sidecar container sharing an emptyDir socket dir, and the componentized chart's HPA uses a ContainerResource CPU metric scoped to the gateway container so sidecar CPU does not drive inference scaling. * feat(terraform): opt-in spend-worker sidecar for the AWS and GCP gateway stacks Adds spend_worker_* inputs to both modules. On ECS Fargate the sidecar is a second, non-essential container in the gateway task; on Cloud Run it is a second container in the gateway service. Both listen on loopback TCP, share the gateway's DB/Redis/secret env, and set LITELLM_JOB_ROLE=spend_worker. Disabled by default. Plan-only tests cover both, and the terraform CI workflow now runs the gcp module too Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): retrieve a completed batch in the in-process spend path test The base now defers cost tracking for batches that are still in flight, so an in_progress batch never reaches update_database Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): rename the spend worker sidecar to collector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): run the collector from the installed litellm package and finish in-flight fallbacks on shutdown The sidecar command becomes python -m litellm.proxy.collector so the classic image, whose runtime stage copies only the installed package, can run it. The module now assembles DATABASE_URL and the pod-local pgbouncer URL itself, replacing gateway/collector.py The componentized collector sidecar inherits gateway.volumeMounts so custom CA mounts reach it. SpendEventProducer shields an in-progress fallback from the writer task cancellation so close() no longer loses an event already handed to the in-process pipeline Helpers used across modules (address_argument, should_store_prompts_and_responses_in_spend_logs, flush_spend_counters_on_shutdown) become public so the change adds no reportPrivateUsage errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(terraform): drop the gcp job duplicated by the aws/gcp matrix Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(collector): keep metrics env off the classic sidecar and reject shared loopback ports The classic chart no longer hands PROMETHEUS_METRICS_PORT and the billing metrics env to the collector container, and gives it the same /.npm scratch mount as the proxy on a read-only root. AWS and GCP now refuse a plan where the spend collector and the metrics sidecar bind the same loopback port. A regression test drives a sidecar crash mid-stream on asyncio and uvloop and checks no event is billed by both the sidecar and the in-process fallback; the producer docstring spells out why a failed drain() cannot double count Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(proxy): format pooled_database_url after the pgbouncer rebase Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): keep the cache-hit preset key and survive dead producers on collector drain Cache hits updated the logging object after the early return, so the offloaded spend event carried preset_cache_key=None and the collector re-hashed reconstructed kwargs. Also guard write_eof() against producer transports uvloop already closed so one dead connection cannot abort the drain Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(terraform): keep the gcp collector port off the metrics sidecar health port Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): collector connects to Postgres directly under IAM or Entra token auth Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): mark the collector's DATABASE_URL as pooled when it uses the pod's pgbouncer Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
229 lines
8.9 KiB
Python
229 lines
8.9 KiB
Python
import asyncio
|
|
import logging
|
|
from collections.abc import Callable, Iterator
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import pytest
|
|
import uvloop
|
|
|
|
from litellm._logging import verbose_logger, verbose_proxy_logger, verbose_router_logger
|
|
from litellm.proxy.collector import (
|
|
SpendEventConsumer,
|
|
address_argument,
|
|
apply_log_level,
|
|
pod_pgbouncer_database_url,
|
|
)
|
|
from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings
|
|
from litellm.proxy.spend_tracking.spend_event_producer import (
|
|
AddressError,
|
|
SpendEventProducer,
|
|
TcpAddress,
|
|
UnixAddress,
|
|
open_collector_connection,
|
|
)
|
|
|
|
|
|
class _Handler:
|
|
def __init__(self, fail_on: bytes | None = None) -> None:
|
|
self.lines: list[bytes] = [] # mutable-ok: test double records the events the consumer handed over
|
|
self._fail_on = fail_on
|
|
|
|
async def __call__(self, line: bytes) -> None:
|
|
if line == self._fail_on:
|
|
raise RuntimeError("pipeline failed")
|
|
self.lines.append(line)
|
|
|
|
|
|
async def _no_fallback(line: bytes) -> None:
|
|
raise AssertionError(f"unexpected fallback for {line!r}")
|
|
|
|
|
|
class _Fallback:
|
|
def __init__(self) -> None:
|
|
self.lines: list[bytes] = [] # mutable-ok: test double records the events run in-process
|
|
|
|
async def __call__(self, line: bytes) -> None:
|
|
self.lines.append(line)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("transport", ["unix", "tcp"])
|
|
async def test_consumer_handles_each_producer_line_once_in_order(tmp_path: Path, transport: str):
|
|
handler: Final = _Handler(fail_on=b"event-3\n")
|
|
consumer: Final = SpendEventConsumer(handler)
|
|
server: Final = await consumer.serve(
|
|
UnixAddress(path=str(tmp_path / "spend.sock")) if transport == "unix" else TcpAddress("127.0.0.1", 0)
|
|
)
|
|
address: Final = (
|
|
UnixAddress(path=str(tmp_path / "spend.sock"))
|
|
if transport == "unix"
|
|
else TcpAddress("127.0.0.1", server.sockets[0].getsockname()[1])
|
|
)
|
|
producer: Final = SpendEventProducer(
|
|
address=address, on_unavailable="fallback", buffer_size=100, connect_timeout=1.0, fallback=_no_fallback
|
|
)
|
|
for i in range(6):
|
|
await producer.publish(f"event-{i}\n".encode())
|
|
await producer.close(drain_timeout=5.0)
|
|
|
|
server.close()
|
|
assert await consumer.drain(timeout=5.0) == 0
|
|
assert handler.lines == [f"event-{i}\n".encode() for i in range(6) if i != 3]
|
|
assert (consumer.received, consumer.handled, consumer.failed) == (6, 5, 1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consumer_discards_a_truncated_trailing_event(tmp_path: Path):
|
|
handler: Final = _Handler()
|
|
consumer: Final = SpendEventConsumer(handler)
|
|
address: Final = UnixAddress(path=str(tmp_path / "spend.sock"))
|
|
server: Final = await consumer.serve(address)
|
|
_, writer = await open_collector_connection(address, timeout=1.0)
|
|
writer.write(b"whole\npartial-without-newline")
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
await asyncio.sleep(0.05)
|
|
|
|
server.close()
|
|
assert await consumer.drain(timeout=5.0) == 0
|
|
assert handler.lines == [b"whole\n"]
|
|
assert consumer.received == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_reports_producers_still_connected_after_the_timeout(tmp_path: Path):
|
|
consumer: Final = SpendEventConsumer(_Handler())
|
|
address: Final = UnixAddress(path=str(tmp_path / "spend.sock"))
|
|
server: Final = await consumer.serve(address)
|
|
_, writer = await open_collector_connection(address, timeout=1.0)
|
|
await asyncio.sleep(0.05)
|
|
|
|
server.close()
|
|
assert await consumer.drain(timeout=0.1) == 1
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
assert await consumer.drain(timeout=5.0) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_graceful_stop_hands_the_producer_over_to_its_fallback_without_losing_events(tmp_path: Path):
|
|
handler: Final = _Handler()
|
|
fallback: Final = _Fallback()
|
|
consumer: Final = SpendEventConsumer(handler)
|
|
address: Final = UnixAddress(path=str(tmp_path / "spend.sock"))
|
|
server: Final = await consumer.serve(address)
|
|
producer: Final = SpendEventProducer(
|
|
address=address, on_unavailable="fallback", buffer_size=100, connect_timeout=1.0, fallback=fallback
|
|
)
|
|
await producer.publish(b"event-1\n")
|
|
await asyncio.sleep(0.05)
|
|
|
|
server.close()
|
|
draining: Final = asyncio.ensure_future(consumer.drain(timeout=5.0))
|
|
await asyncio.sleep(0.05)
|
|
await producer.publish(b"event-2\n")
|
|
await producer.close(drain_timeout=5.0)
|
|
|
|
assert await draining == 0
|
|
assert handler.lines == [b"event-1\n"]
|
|
assert fallback.lines == [b"event-2\n"]
|
|
assert (producer.stats().sent, producer.stats().fallback) == (1, 1)
|
|
|
|
|
|
@pytest.mark.parametrize("loop_factory", [asyncio.new_event_loop, uvloop.new_event_loop], ids=["asyncio", "uvloop"])
|
|
def test_drain_still_hands_over_live_producers_when_another_connection_already_died(
|
|
tmp_path: Path, loop_factory: Callable[[], asyncio.AbstractEventLoop]
|
|
):
|
|
"""A transport the loop force-closed under a busy handler must not abort the half-close of the others."""
|
|
|
|
async def scenario() -> tuple[int, list[bytes]]:
|
|
release: Final = asyncio.Event()
|
|
|
|
async def slow_handler(line: bytes) -> None:
|
|
await release.wait()
|
|
|
|
consumer: Final = SpendEventConsumer(slow_handler)
|
|
address: Final = UnixAddress(path=str(tmp_path / "spend.sock"))
|
|
server: Final = await consumer.serve(address)
|
|
_, dead = await open_collector_connection(address, timeout=1.0)
|
|
dead.write(b"stuck\n")
|
|
await dead.drain()
|
|
await asyncio.sleep(0.05)
|
|
for connection in consumer._open_connections: # pyright: ignore[reportPrivateUsage] # force-close like uvloop does on a socket error
|
|
connection.transport.close()
|
|
dead.close()
|
|
fallback: Final = _Fallback()
|
|
producer: Final = SpendEventProducer(
|
|
address=address, on_unavailable="fallback", buffer_size=100, connect_timeout=1.0, fallback=fallback
|
|
)
|
|
await producer.publish(b"event-1\n")
|
|
await asyncio.sleep(0.05)
|
|
|
|
server.close()
|
|
draining: Final = asyncio.ensure_future(consumer.drain(timeout=0.5))
|
|
await asyncio.sleep(0.05)
|
|
await producer.publish(b"event-2\n")
|
|
await producer.close(drain_timeout=5.0)
|
|
still_open: Final = await draining
|
|
release.set()
|
|
await asyncio.sleep(0.05)
|
|
return still_open, fallback.lines
|
|
|
|
with asyncio.Runner(loop_factory=loop_factory) as runner:
|
|
still_open, fallback_lines = runner.run(scenario())
|
|
|
|
assert still_open == 2
|
|
assert fallback_lines == [b"event-2\n"]
|
|
|
|
|
|
def test_address_argument():
|
|
assert address_argument((), default="unix:///tmp/x.sock") == "unix:///tmp/x.sock"
|
|
assert address_argument(("--address", "tcp://127.0.0.1:4100"), default="unix:///tmp/x.sock") == (
|
|
"tcp://127.0.0.1:4100"
|
|
)
|
|
assert isinstance(address_argument(("--listen", "x"), default="unix:///tmp/x.sock"), AddressError)
|
|
|
|
|
|
def test_pod_pgbouncer_database_url_points_at_the_proxy_containers_pooler():
|
|
"""With pgbouncer on, the sidecar must not open its own upstream connections but share the pod's pooler."""
|
|
upstream: Final = "postgresql://u:p@db.internal:5432/litellm?schema=public"
|
|
environ: Final = {"DATABASE_URL": upstream}
|
|
assert pod_pgbouncer_database_url(PgBouncerSettings(enabled=False), environ, token_auth=False) is None
|
|
assert (
|
|
pod_pgbouncer_database_url(PgBouncerSettings(enabled=True, port=6543), environ, token_auth=False)
|
|
== "postgresql://u:p@127.0.0.1:6543/litellm?schema=public&pgbouncer=true"
|
|
)
|
|
assert isinstance(pod_pgbouncer_database_url(PgBouncerSettings(enabled=True), {}, token_auth=False), PgBouncerError)
|
|
|
|
|
|
def test_pod_pgbouncer_database_url_goes_direct_under_token_auth():
|
|
"""The proxy's pgbouncer only knows the token that container minted, so the sidecar must mint its own upstream."""
|
|
iam_upstream: Final = "postgresql://u@db.internal:5432/litellm?schema=public"
|
|
assert (
|
|
pod_pgbouncer_database_url(PgBouncerSettings(enabled=True), {"DATABASE_URL": iam_upstream}, token_auth=True)
|
|
is None
|
|
)
|
|
assert pod_pgbouncer_database_url(PgBouncerSettings(enabled=True), {}, token_auth=True) is None
|
|
|
|
|
|
@pytest.fixture
|
|
def restore_log_levels() -> Iterator[None]:
|
|
loggers: Final = (verbose_logger, verbose_router_logger, verbose_proxy_logger)
|
|
levels: Final = tuple(logger.level for logger in loggers)
|
|
yield
|
|
for logger, level in zip(loggers, levels, strict=True):
|
|
logger.setLevel(level)
|
|
|
|
|
|
@pytest.mark.usefixtures("restore_log_levels")
|
|
@pytest.mark.parametrize(
|
|
("litellm_log", "expected"),
|
|
[("DEBUG", logging.DEBUG), ("info", logging.INFO), (None, logging.WARNING), ("loud", logging.WARNING)],
|
|
)
|
|
def test_apply_log_level_mirrors_the_proxy_env_contract(litellm_log: str | None, expected: int):
|
|
verbose_proxy_logger.setLevel(logging.WARNING)
|
|
apply_log_level(litellm_log)
|
|
assert verbose_proxy_logger.isEnabledFor(expected)
|
|
assert not verbose_proxy_logger.isEnabledFor(expected - 10)
|