litellm/tests/integration/observability/_ternary_support.py
yucheng-berri a5d05355b6
feat(focus): add ternary destination for FOCUS export (#46106)
* feat(focus): add ternary destination for FOCUS export

Add a `ternary` FOCUS export destination (plus a `ternary` callback) so a LiteLLM proxy can push FOCUS-formatted spend to Ternary with `callbacks: ["ternary"]` -- no separate export job

- FocusTernaryDestination: a pure sink modeled on the sibling vantage/mavvrik/gcs destinations; POSTs the FOCUS CSV to the Ternary cost-ingestion endpoint, chunking an oversized backfill (csv-module repartition, quoted-newline safe; oversized single row raises rather than silently dropping) with a stable X-Ternary-Upload-Id + 0-based index/total so the receiver stages-then-swaps. connection_id is validated + URL-encoded; base_url must be HTTPS (loopback exempt for local); the upload has a bounded timeout; it aborts on the first failed chunk so nothing partial lands
- TernaryLogger: presets the FOCUS pipeline (CSV) and enriches the FOCUS Tags JSON with the per-request token counts the shared transformer drops (Ternary weights allocation by tokens); the enrichment is confined to this path and only adds keys. Cadence is daily (default) or interval (validation loops, an explicit interval-seconds required); the export window is day-aligned and drops days before the window start so a whole-day-replace receiver never truncates
- Registration mirrors vantage/mavvrik: ternary callback literal, logging dispatch, scheduler job, and CustomLoggerRegistry entry -- no shared-code changes
- Tests: mocked unit tests only (no live calls), 100% line coverage of both new modules

* fix(focus): warn instead of silently dropping token tags on mismatch

_merge_token_tags returned the frame unchanged when the transformed data
had no `Tags` column or its row count disagreed with the source, so every
token count was dropped with no signal -- the receiver then lands NULLs
that read as "this gateway reports no tokens". Emit a warning on each
degenerate path (mirroring _drop_days_before), and cover both with caplog
tests asserting the warning and the unchanged return.

* feat(focus): upload one UTC day per Ternary signed-URL PUT

Replace the multipart POST with chunking by a per-day signed-URL upload,
matching the Mavvrik destination's pattern. The logger partitions the raw
usage rows on `date` (dropping unparseable dates with a warning), then
transforms, enriches and delivers each day separately; one day's failure
is logged and the remaining days still upload, with a single error raised
at the end. The destination requests a signed URL for the day, validates it
is an HTTPS GCS URL, and PUTs the CSV with exactly the signed headers.

Removes the 30 MiB / 100k-row chunking, the X-Ternary-* upload headers and
the .partN filenames; a re-sent day overwrites its object, so retries stay
idempotent without them.

* fix(focus): re-send the last two whole days so late-flushed spend lands

LiteLLM writes DailyUserSpend in batches, so spend dated yesterday can be
flushed after the 00:05 UTC daily export. That export only covered the
previous UTC midnight onward, and the next day's export moved past it, so
the tail of each day was never sent. Start the window at UTC midnight two
days back: the following run re-sends yesterday whole, overwriting its
per-day object with the complete day.

* fix(focus): lazy-load the Ternary engine, fetch whole days, keep ternary out of the generic factory

Every proxy imported polars at boot through the logger registry, so a proxy without polars failed to start even with no FOCUS callback. The polars-backed engine now lives in its own module and loads on the first export

A non-UTC Postgres session timezone shifted the naive updated_at filter, so the oldest day in each run lost rows and Ternary's whole-day replace made the loss permanent. The Ternary read now starts one day early with no upper bound and keeps rows by their own UTC date

callbacks: ["focus"] with FOCUS_PROVIDER=ternary uploaded a parquet blob of one interval over a whole-day CSV file. The generic factory no longer builds a Ternary destination and the Ternary engine injects its own

* fix(focus): keep only the days a Ternary export window covers

Dropping the updated_at upper bound let a bounded export_usage_data window upload every day through today. Rows are now kept by their UTC date between the window's first day and the day before its end instant

* refactor(focus): leave the shared FOCUS export engine as on main

The Ternary engine sets the base engine's fields itself and passes its own
destination, so focus/export_engine.py and its test match main again.

---------

Co-authored-by: Aaron Sproul <aaron@ternary.app>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-12 22:52:40 -07:00

229 lines
8.5 KiB
Python

"""Owned Ternary peers for the FOCUS export contracts: the cost-import API and the signed-URL storage host.
The API answers `upload-url` with a signed `https://storage.googleapis.com/...` PUT target, the way Ternary
does. The proxy only PUTs to an HTTPS GCS host, so the storage peer serves TLS under that name and the
proxy reaches it through an owned tunnelling forward proxy that trusts the peer's own certificate.
"""
from __future__ import annotations
import csv
import io
import json
import threading
import uuid
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Final
from urllib.parse import urlsplit
import psycopg
import yaml
from integration._support.forward_proxy import tunnelling_forward_proxy
from integration._support.tls import server_context, write_self_signed_cert
from integration._support.wire import Reply, Request, Wire, wire_server
from psycopg import sql
TERNARY_API_KEY: Final = "synthetic-ternary-api-key"
CONNECTION_ID: Final = "ternary-integration-connection"
GCS_HOST: Final = "storage.googleapis.com"
BUCKET: Final = "ternary-integration-bucket"
UPLOAD_URL_PATH: Final = f"/external-cost-sources/v1/{CONNECTION_ID}/upload-url"
SIGNED_HEADERS: Final = MappingProxyType(
{
"Content-Encoding": "identity",
"Content-Type": "text/csv",
"x-goog-content-length-range": "0,67108864",
"x-goog-meta-ecs-connection-id": CONNECTION_ID,
"x-goog-meta-ecs-provider": "litellm",
}
)
_SCRUBBED: Final = frozenset(
{"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE"}
)
def utc_today() -> date:
return datetime.now(timezone.utc).date()
def signed_target(day: str) -> str:
return f"/{BUCKET}/{CONNECTION_ID}/{day}.csv?X-Goog-Signature=synthetic-{day}"
@dataclass(frozen=True, slots=True)
class Upload:
day: str
headers: Mapping[str, str]
body: bytes
def rows(self) -> tuple[Mapping[str, str], ...]:
return tuple(MappingProxyType(row) for row in csv.DictReader(io.StringIO(self.body.decode("utf-8"))))
def cost_by_service(self) -> Mapping[str, float]:
return MappingProxyType({row["ServiceName"]: float(row["BilledCost"]) for row in self.rows()})
@dataclass(slots=True)
class TernaryPeers:
api: Wire
storage: Wire
environment: Mapping[str, str]
_upload_url_requests: list[Request] = field(default_factory=list) # mutable-ok: drain() consumes the wire queue
_puts: list[Request] = field(default_factory=list) # mutable-ok: drain() consumes the wire queue
def upload_url_requests(self) -> tuple[Request, ...]:
self._upload_url_requests.extend(self.api.drain())
return tuple(self._upload_url_requests)
def puts(self) -> tuple[Request, ...]:
self._puts.extend(self.storage.drain())
return tuple(self._puts)
def uploads(self) -> tuple[Upload, ...]:
return tuple(
Upload(day=urlsplit(put.target).path.rsplit("/", 1)[-1].removesuffix(".csv"), headers=put.headers, body=put.body)
for put in self.puts()
if put.method == "PUT"
)
def latest_by_day(self) -> Mapping[str, Upload]:
return MappingProxyType({upload.day: upload for upload in self.uploads()})
def outbound(self) -> int:
return len(self.upload_url_requests()) + len(self.puts())
def _api(request: Request) -> Reply:
if (request.method, request.target) != ("POST", UPLOAD_URL_PATH):
return Reply(status=404, body=b'{"message": "unexpected Ternary request"}')
if request.headers.get("authorization") != f"Bearer {TERNARY_API_KEY}":
return Reply(status=401, body=b'{"message": "invalid secret"}')
day: Final = str(json.loads(request.body)["day"])
return Reply(
body=json.dumps(
{
"url": f"https://{GCS_HOST}{signed_target(day)}",
"method": "PUT",
"headers": dict(SIGNED_HEADERS),
"expiresAt": (datetime.now(timezone.utc) + timedelta(seconds=899)).isoformat(),
"day": day,
}
).encode()
)
def _storage(request: Request) -> Reply:
if request.method != "PUT" or not request.target.startswith(f"/{BUCKET}/{CONNECTION_ID}/"):
return Reply(status=404, body=b"<Error><Code>NoSuchKey</Code></Error>", content_type="application/xml")
return Reply(body=b"", content_type="text/plain")
@contextmanager
def ternary_peers(directory: Path) -> Iterator[TernaryPeers]:
cert, key = write_self_signed_cert(directory, (GCS_HOST,))
with (
wire_server(_storage, tls=server_context(cert, key)) as storage,
tunnelling_forward_proxy(int(storage.url.rsplit(":", 1)[1])) as tunnel,
wire_server(_api) as api,
):
yield TernaryPeers(
api=api,
storage=storage,
environment=MappingProxyType(
{
"TERNARY_API_KEY": TERNARY_API_KEY,
"TERNARY_CONNECTION_ID": CONNECTION_ID,
"TERNARY_BASE_URL": api.url,
"HTTPS_PROXY": tunnel.url,
"NO_PROXY": "127.0.0.1,localhost",
"SSL_CERT_FILE": str(cert),
}
),
)
def scrubbed_environment(inherited: Mapping[str, str]) -> tuple[str, ...]:
return tuple(name for name in inherited if name.upper() in _SCRUBBED)
def callbacks_config(directory: Path, callbacks: tuple[str, ...]) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["litellm_settings"]["callbacks"] = list(callbacks)
path: Final = directory / "ternary-callbacks.yaml"
path.write_text(yaml.safe_dump(config))
return path
def set_database_timezone(database_url: str, zone: str) -> None:
name: Final = urlsplit(database_url).path.lstrip("/")
with psycopg.connect(database_url, autocommit=True) as connection:
connection.execute(sql.SQL("ALTER DATABASE {} SET timezone TO {}").format(sql.Identifier(name), sql.Literal(zone)))
@dataclass(frozen=True, slots=True)
class SpendRow:
"""One `LiteLLM_DailyUserSpend` row; `service` becomes the FOCUS `ServiceName` (the model group)."""
day: date
service: str
spend: float
updated_at: datetime
prompt_tokens: int = 7
completion_tokens: int = 3
def seed_daily_spend(database_url: str, rows: tuple[SpendRow, ...]) -> None:
"""Insert every row in one transaction so no export run can read a partial seed."""
with psycopg.connect(database_url) as connection, connection.transaction():
for row in rows:
naive_utc: Final = row.updated_at.astimezone(timezone.utc).replace(tzinfo=None)
connection.execute(
"""
INSERT INTO "LiteLLM_DailyUserSpend"
(id, user_id, date, api_key, model, model_group, custom_llm_provider,
prompt_tokens, completion_tokens, spend, api_requests, successful_requests,
failed_requests, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, 'openai', %s, %s, %s, 1, 1, 0, %s, %s)
""",
(
str(uuid.uuid4()),
"ternary-integration-user",
row.day.isoformat(),
f"ternary-integration-key-{row.service}",
"gpt-4o-mini",
row.service,
row.prompt_tokens,
row.completion_tokens,
row.spend,
naive_utc,
naive_utc,
),
)
@contextmanager
def refreshing_updated_at(database_url: str, service: str) -> Iterator[None]:
"""Keep a seeded row's `updated_at` at the current UTC instant, as ongoing traffic would."""
stop: Final = threading.Event()
def refresh() -> None:
with psycopg.connect(database_url, autocommit=True) as connection:
while not stop.wait(0.2):
connection.execute(
"""UPDATE "LiteLLM_DailyUserSpend" SET updated_at = (now() AT TIME ZONE 'UTC') WHERE model_group = %s""",
(service,),
)
worker: Final = threading.Thread(target=refresh, daemon=True)
worker.start()
try:
yield
finally:
stop.set()
worker.join(timeout=5)
assert not worker.is_alive(), "updated_at refresher survived cleanup"