litellm/tests/test_litellm/proxy/test_collector.py
devin-ai-integration[bot] ae01882535
feat(proxy): offload spend tracking to a pod-local collector sidecar (#40545)
* 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>
2026-09-10 17:14:13 -07:00

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)