litellm/tests/unit/integrations/focus/test_ternary_destination.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

274 lines
11 KiB
Python

"""Tests for FocusTernaryDestination behavior."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.integrations.focus.destinations.base import FocusTimeWindow
from litellm.integrations.focus.destinations.ternary_destination import (
TERNARY_UPLOAD_TIMEOUT_SECONDS,
FocusTernaryDestination,
_NoRedirectHTTPHandler,
)
MOCK_TARGET = "litellm.integrations.focus.destinations.ternary_destination._NoRedirectHTTPHandler"
def _window(freq: str = "daily", hour: int = 5) -> FocusTimeWindow:
start = datetime(2024, 1, 2, hour, tzinfo=timezone.utc)
end = start + timedelta(hours=1)
return FocusTimeWindow(start_time=start, end_time=end, frequency=freq)
def _config(**overrides: Any) -> dict[str, Any]:
base = {
"api_key": "test-api-key",
"connection_id": "conn-1234",
"base_url": "https://ternary.test",
}
base.update(overrides)
return base
_SIGNED_URL = "https://storage.googleapis.com/ter-ecs-t-us/days/cloud-1/2024-01-02.csv?X-Goog-Signature=abc"
_SIGNED_HEADERS = {
"Content-Type": "text/csv",
"x-goog-meta-ecs-provider": "litellm",
"x-goog-meta-ecs-connection-id": "conn-1234",
"x-goog-content-length-range": "0,1073741824",
}
def _response(status: int = 200, body: Any = None) -> MagicMock:
response = MagicMock()
response.status_code = status
response.json = MagicMock(return_value=body)
response.text = "" if body is None else str(body)
return response
def _upload_url_body(**overrides: Any) -> dict[str, Any]:
body = {
"url": _SIGNED_URL,
"method": "PUT",
"headers": dict(_SIGNED_HEADERS),
"expiresAt": "2024-01-02T05:15:00Z",
"day": "2024-01-02",
}
body.update(overrides)
return body
def _handler(
*, url_response: MagicMock | None = None, put_response: MagicMock | None = None
) -> tuple[MagicMock, list[dict[str, Any]], list[dict[str, Any]]]:
"""Return a mock handler whose client records each upload-url POST and signed-URL PUT."""
posts: list[dict[str, Any]] = []
puts: list[dict[str, Any]] = []
async def capture_post(url, **kwargs):
posts.append({"url": url, **kwargs})
return url_response if url_response is not None else _response(body=_upload_url_body())
async def capture_put(url, **kwargs):
puts.append({"url": url, **kwargs})
return put_response if put_response is not None else _response()
handler = MagicMock()
handler.close = AsyncMock()
handler.client.post = capture_post
handler.client.put = capture_put
return handler, posts, puts
@pytest.mark.asyncio
async def test_should_skip_empty_content():
dest = FocusTernaryDestination(prefix="exports", config=_config())
with patch(MOCK_TARGET, side_effect=AssertionError("client initialized on empty content")):
assert await dest.deliver(content=b"", time_window=_window(), filename="usage.csv") is None
@pytest.mark.asyncio
async def test_should_request_a_signed_url_for_the_window_day_then_put_the_csv():
dest = FocusTernaryDestination(prefix="exports", config=_config())
handler, posts, puts = _handler()
with patch(MOCK_TARGET, return_value=handler):
await dest.deliver(content=b"header\nrow1\n", time_window=_window(), filename="usage.csv")
assert len(posts) == 1
assert posts[0]["url"] == "https://ternary.test/external-cost-sources/v1/conn-1234/upload-url"
assert posts[0]["headers"] == {"Authorization": "Bearer test-api-key"}
assert posts[0]["json"] == {"day": "2024-01-02"}
assert posts[0]["timeout"] == TERNARY_UPLOAD_TIMEOUT_SECONDS
assert len(puts) == 1
assert puts[0]["url"] == _SIGNED_URL
assert puts[0]["content"] == b"header\nrow1\n"
assert puts[0]["headers"] == _SIGNED_HEADERS
assert "Authorization" not in puts[0]["headers"]
handler.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_should_key_the_upload_on_the_window_start_day_not_its_end():
dest = FocusTernaryDestination(prefix="exports", config=_config())
start = datetime(2024, 1, 2, tzinfo=timezone.utc)
one_day = FocusTimeWindow(start_time=start, end_time=start + timedelta(days=1), frequency="daily")
handler, posts, _ = _handler()
with patch(MOCK_TARGET, return_value=handler):
await dest.deliver(content=b"h\nr\n", time_window=one_day, filename="usage.csv")
assert posts[0]["json"] == {"day": "2024-01-02"}
@pytest.mark.asyncio
async def test_no_redirect_handler_disables_redirects_including_retry_client():
handler = _NoRedirectHTTPHandler()
retry_client = handler.create_client(timeout=1.0, event_hooks=None)
try:
assert handler.client.follow_redirects is False
assert retry_client.follow_redirects is False
finally:
await retry_client.aclose()
await handler.close()
@pytest.mark.asyncio
async def test_should_url_encode_the_connection_id():
dest = FocusTernaryDestination(prefix="exports", config=_config(connection_id="conn+id~ok"))
handler, posts, _ = _handler()
with patch(MOCK_TARGET, return_value=handler):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
assert posts[0]["url"].endswith("/external-cost-sources/v1/conn%2Bid~ok/upload-url")
@pytest.mark.asyncio
async def test_should_pass_tags_column_through_unstripped():
"""The Ternary sink must not drop/strip any columns (forwards Tags as-is)."""
dest = FocusTernaryDestination(prefix="exports", config=_config())
handler, _, puts = _handler()
content = b'ServiceName,Tags,x_unknown\nfoo,"{""team_id"": ""t1""}",keepme\n'
with patch(MOCK_TARGET, return_value=handler):
await dest.deliver(content=content, time_window=_window(), filename="usage.csv")
assert puts[0]["content"] == content
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [400, 401, 404, 422, 429, 500])
async def test_should_raise_and_skip_the_put_when_the_upload_url_request_fails(status):
dest = FocusTernaryDestination(prefix="exports", config=_config())
handler, _, puts = _handler(url_response=_response(status=status, body={"message": "nope"}))
with patch(MOCK_TARGET, return_value=handler):
with pytest.raises(RuntimeError, match=f"upload-url request for 2024-01-02 failed \\({status}\\)"):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
assert puts == []
handler.close.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [400, 403, 503])
async def test_should_raise_when_the_signed_put_fails(status):
dest = FocusTernaryDestination(prefix="exports", config=_config())
handler, _, _ = _handler(put_response=_response(status=status, body="SignatureDoesNotMatch"))
with patch(MOCK_TARGET, return_value=handler):
with pytest.raises(RuntimeError, match=f"upload for 2024-01-02 failed \\({status}\\)"):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
handler.close.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"http://storage.googleapis.com/b/days/c/2024-01-02.csv",
"https://evil.example.com/b/days/c/2024-01-02.csv",
"https://storage.googleapis.com.evil.example.com/b/o",
"https://169.254.169.254/computeMetadata/v1/",
],
)
async def test_should_refuse_to_put_to_a_non_gcs_url(url):
dest = FocusTernaryDestination(prefix="exports", config=_config())
handler, _, puts = _handler(url_response=_response(body=_upload_url_body(url=url)))
with patch(MOCK_TARGET, return_value=handler):
with pytest.raises(ValueError, match="HTTPS GCS URL"):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
assert puts == []
@pytest.mark.asyncio
async def test_should_accept_a_virtual_hosted_gcs_url():
dest = FocusTernaryDestination(prefix="exports", config=_config())
url = "https://ter-ecs-t-us.storage.googleapis.com/days/c/2024-01-02.csv?X-Goog-Signature=abc"
handler, _, puts = _handler(url_response=_response(body=_upload_url_body(url=url)))
with patch(MOCK_TARGET, return_value=handler):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
assert puts[0]["url"] == url
@pytest.mark.asyncio
@pytest.mark.parametrize(
("overrides", "match"),
[
({"url": ""}, "missing 'url' or 'headers'"),
({"headers": None}, "missing 'url' or 'headers'"),
({"method": "POST"}, "unsupported upload method 'POST'"),
],
)
async def test_should_reject_an_incomplete_upload_url_response(overrides, match):
dest = FocusTernaryDestination(prefix="exports", config=_config())
body = _upload_url_body(**overrides)
handler, _, puts = _handler(url_response=_response(body=body))
with patch(MOCK_TARGET, return_value=handler):
with pytest.raises(RuntimeError, match=match):
await dest.deliver(content=b"h\nr\n", time_window=_window(), filename="usage.csv")
assert puts == []
@pytest.mark.parametrize("url", ["http://api.ternary.app", "http://evil.example.com:8080", "ftp://ternary.test"])
def test_should_reject_non_https_base_url(url):
with pytest.raises(ValueError, match="HTTPS"):
FocusTernaryDestination(prefix="exports", config=_config(base_url=url))
@pytest.mark.parametrize("url", ["https://ternary.test", "http://localhost:8080", "http://127.0.0.1:8080"])
def test_should_accept_https_or_loopback_base_url(url):
dest = FocusTernaryDestination(prefix="exports", config=_config(base_url=url))
assert dest.base_url == url.rstrip("/")
def test_generic_focus_factory_does_not_build_a_ternary_destination():
from litellm.integrations.focus.destinations.factory import FocusDestinationFactory
with pytest.raises(NotImplementedError, match="'ternary' not supported"):
FocusDestinationFactory.create(
provider="ternary",
prefix="exports",
config={"api_key": "k", "connection_id": "c", "base_url": "https://ternary.test"},
)
def test_ternary_logger_resolves_its_destination_from_env(monkeypatch):
monkeypatch.setenv("TERNARY_API_KEY", "envk")
monkeypatch.setenv("TERNARY_CONNECTION_ID", "envc")
monkeypatch.setenv("TERNARY_BASE_URL", "https://ternary.test")
from litellm.integrations.ternary.ternary_logger import TernaryLogger
dest = TernaryLogger()._ensure_engine().destination
assert isinstance(dest, FocusTernaryDestination)
assert (dest.api_key, dest.connection_id, dest.base_url) == ("envk", "envc", "https://ternary.test")
@pytest.mark.parametrize("missing", ["TERNARY_API_KEY", "TERNARY_CONNECTION_ID", "TERNARY_BASE_URL"])
def test_ternary_logger_requires_each_env_var(monkeypatch, missing):
for var in ("TERNARY_API_KEY", "TERNARY_CONNECTION_ID", "TERNARY_BASE_URL"):
monkeypatch.setenv(var, "https://ternary.test" if var == "TERNARY_BASE_URL" else "x")
monkeypatch.delenv(missing, raising=False)
from litellm.integrations.ternary.ternary_logger import TernaryLogger
with pytest.raises(ValueError, match=missing):
TernaryLogger()._ensure_engine()