mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
* feat(passthrough): let upstreams report the model name via x-litellm-upstream-model A pass-through upstream that prices its own requests already reports cost and tokens in x-litellm-response-cost / x-litellm-total-tokens. Product endpoints that take no `model` in the body still landed under `unknown`, so their spend could not be broken down. When the upstream sends x-litellm-upstream-model, record its value as the request's model on success and failure rows, overriding any body `model`. The name is a label only: it never marks the request as upstream-priced and is not used to look up a price. A blank or missing header changes nothing. Refs LIT-9910 * fix(passthrough): keep the upstream-reported model name bounded The upstream model name becomes a spend row, a daily rollup key and a metric label. Use only the first element of a repeated header (RFC 9110 field combining, which is how httpx joins it) and cut the name to MAX_SPEND_LOG_MODEL_NAME_LENGTH, the limit the spend log already applies to model names. Refs LIT-9910 * test(passthrough): cover x-litellm-upstream-model through spend rows and rollups Drives a config-defined pass-through against a scripted upstream and reads back the spend row, the daily team rollup and /team/daily/activity. Covers the header winning over a body model on buffered and streamed responses, failure rows, a model-only header keeping cost_per_request, the no-header and blank header cases, and repeated or long header values. Refs LIT-9910 * test(passthrough): annotate new test locals with Final Refs LIT-9910
228 lines
9.8 KiB
Python
228 lines
9.8 KiB
Python
import json
|
|
import uuid
|
|
from collections.abc import Callable, Mapping
|
|
from datetime import datetime, timedelta, timezone
|
|
from hashlib import sha256
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from integration._support.client import Gateway, JsonValue, Scenario, eventually, object_value
|
|
from integration._support.database import read_rows
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
from pydantic import TypeAdapter
|
|
|
|
pytestmark: Final = pytest.mark.shares_worker_state
|
|
|
|
_REPORTED_MODEL: Final = "support-summarizer"
|
|
_REPORTED_COST: Final = 0.0123
|
|
_REPORTED_TOKENS: Final = 4210
|
|
_USAGE_HEADERS: Final = {
|
|
"x-litellm-response-cost": str(_REPORTED_COST),
|
|
"x-litellm-total-tokens": str(_REPORTED_TOKENS),
|
|
}
|
|
_MODEL_HEADER: Final = {"x-litellm-upstream-model": _REPORTED_MODEL}
|
|
|
|
|
|
def _upstream(
|
|
marker: str, headers: Mapping[str, str], *, status: int = 200, stream: bool = False
|
|
) -> Callable[[Request], Reply]:
|
|
def respond(request: Request) -> Reply:
|
|
assert request.method == "POST", request
|
|
assert json.loads(request.body)["ticket_id"] == marker, request
|
|
if stream:
|
|
return Reply(
|
|
status=status,
|
|
content_type="text/event-stream",
|
|
chunks=(f'data: {{"delta": "{marker}"}}\n\n'.encode(), b"data: [DONE]\n\n"),
|
|
headers=headers,
|
|
)
|
|
return Reply(status=status, body=json.dumps({"summary": marker}).encode(), headers=headers)
|
|
|
|
return respond
|
|
|
|
|
|
def _endpoint(gateway: Gateway, scenario: Scenario, marker: str, target: str, **fields: JsonValue) -> str:
|
|
path: Final = f"/support-ai-{marker}"
|
|
created: Final = gateway.post(
|
|
"/config/pass_through_endpoint", {"path": path, "target": target, "auth": True, **fields}
|
|
)
|
|
endpoint_id: Final = object_value(TypeAdapter(list[JsonValue]).validate_python(created["endpoints"])[0])["id"]
|
|
scenario.cleanups.callback(
|
|
lambda: gateway.request("DELETE", "/config/pass_through_endpoint", params={"endpoint_id": str(endpoint_id)})
|
|
)
|
|
return path
|
|
|
|
|
|
def _spend_row(key: str) -> dict[str, JsonValue]:
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
'SELECT model, spend, total_tokens, status FROM "LiteLLM_SpendLogs" WHERE api_key=%s AND call_type=%s',
|
|
(sha256(key.encode()).hexdigest(), "pass_through_endpoint"),
|
|
),
|
|
lambda values: len(values) == 1,
|
|
seconds=70,
|
|
)
|
|
return rows[0]
|
|
|
|
|
|
def _row_values(row: Mapping[str, JsonValue]) -> tuple[JsonValue, float, JsonValue, JsonValue]:
|
|
spend: Final = row["spend"]
|
|
assert isinstance(spend, (int, float)), row
|
|
return row["model"], float(spend), row["total_tokens"], row["status"]
|
|
|
|
|
|
def test_upstream_reported_model_names_the_spend_row_daily_rollup_and_usage_breakdown(gateway: Gateway) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
with wire_server(_upstream(marker, {**_USAGE_HEADERS, **_MODEL_HEADER})) as wire, gateway.scenario() as scenario:
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize")
|
|
team: Final = scenario.team()
|
|
key: Final = scenario.key(team_id=team, allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, {"ticket_id": marker}, key=key)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"summary": marker}, response.text
|
|
assert response.headers["x-litellm-upstream-model"] == _REPORTED_MODEL, response.headers
|
|
|
|
assert _row_values(_spend_row(key)) == (
|
|
_REPORTED_MODEL,
|
|
pytest.approx(_REPORTED_COST),
|
|
_REPORTED_TOKENS,
|
|
"success",
|
|
)
|
|
daily: Final = eventually(
|
|
lambda: read_rows('SELECT model, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)),
|
|
lambda values: len(values) == 1,
|
|
seconds=70,
|
|
)
|
|
assert (daily[0]["model"], daily[0]["spend"]) == (_REPORTED_MODEL, pytest.approx(_REPORTED_COST)), daily
|
|
|
|
today: Final = datetime.now(timezone.utc)
|
|
activity: Final = gateway.request(
|
|
"GET",
|
|
"/team/daily/activity",
|
|
params={
|
|
"team_ids": team,
|
|
"start_date": (today - timedelta(days=1)).strftime("%Y-%m-%d"),
|
|
"end_date": (today + timedelta(days=1)).strftime("%Y-%m-%d"),
|
|
},
|
|
)
|
|
assert activity.status_code == 200, activity.text
|
|
results: Final = object_value(activity.json())["results"]
|
|
assert isinstance(results, list) and len(results) == 1, activity.text
|
|
models: Final = object_value(object_value(object_value(results[0])["breakdown"])["models"])
|
|
assert set(models) == {_REPORTED_MODEL}, activity.text
|
|
assert object_value(object_value(models[_REPORTED_MODEL])["metrics"])["spend"] == pytest.approx(
|
|
_REPORTED_COST
|
|
), activity.text
|
|
|
|
|
|
@pytest.mark.parametrize("stream", [False, True], ids=["buffered", "streamed"])
|
|
def test_upstream_reported_model_wins_over_the_request_body_model(gateway: Gateway, stream: bool) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
with (
|
|
wire_server(_upstream(marker, {**_USAGE_HEADERS, **_MODEL_HEADER}, stream=stream)) as wire,
|
|
gateway.scenario() as scenario,
|
|
):
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize")
|
|
key: Final = scenario.key(allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, {"ticket_id": marker, "model": "gpt-4o"}, key=key)
|
|
assert response.status_code == 200, response.text
|
|
assert marker in response.text, response.text
|
|
|
|
assert _row_values(_spend_row(key)) == (
|
|
_REPORTED_MODEL,
|
|
pytest.approx(_REPORTED_COST),
|
|
_REPORTED_TOKENS,
|
|
"success",
|
|
)
|
|
|
|
|
|
def test_upstream_error_row_carries_the_reported_model_and_cost(gateway: Gateway) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
with (
|
|
wire_server(_upstream(marker, {**_USAGE_HEADERS, **_MODEL_HEADER}, status=500)) as wire,
|
|
gateway.scenario() as scenario,
|
|
):
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize")
|
|
key: Final = scenario.key(allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, {"ticket_id": marker, "model": "gpt-4o"}, key=key)
|
|
assert response.status_code == 500, response.text
|
|
|
|
assert _row_values(_spend_row(key)) == (
|
|
_REPORTED_MODEL,
|
|
pytest.approx(_REPORTED_COST),
|
|
_REPORTED_TOKENS,
|
|
"failure",
|
|
)
|
|
|
|
|
|
def test_model_header_alone_keeps_the_configured_cost_per_request(gateway: Gateway) -> None:
|
|
"""The name is a label: an upstream that reports only a model does not own
|
|
the cost, so the endpoint's flat cost_per_request still applies."""
|
|
marker: Final = uuid.uuid4().hex
|
|
with wire_server(_upstream(marker, _MODEL_HEADER)) as wire, gateway.scenario() as scenario:
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize", cost_per_request=0.5)
|
|
key: Final = scenario.key(allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, {"ticket_id": marker}, key=key)
|
|
assert response.status_code == 200, response.text
|
|
|
|
model, spend, _, status = _row_values(_spend_row(key))
|
|
assert (model, spend, status) == (_REPORTED_MODEL, pytest.approx(0.5), "success")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body_model", "header_value", "expected_model"),
|
|
[(None, None, "unknown"), ("gpt-4o", None, "gpt-4o"), ("gpt-4o", " ", "gpt-4o")],
|
|
ids=["no_header_no_body_model", "no_header_body_model", "blank_header_body_model"],
|
|
)
|
|
def test_without_a_usable_model_header_the_row_keeps_the_request_model(
|
|
gateway: Gateway, body_model: str | None, header_value: str | None, expected_model: str
|
|
) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
headers: Final = {**_USAGE_HEADERS, **({} if header_value is None else {"x-litellm-upstream-model": header_value})}
|
|
body: Final[dict[str, JsonValue]] = {"ticket_id": marker, **({} if body_model is None else {"model": body_model})}
|
|
with wire_server(_upstream(marker, headers)) as wire, gateway.scenario() as scenario:
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize")
|
|
key: Final = scenario.key(allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, body, key=key)
|
|
assert response.status_code == 200, response.text
|
|
|
|
assert _row_values(_spend_row(key)) == (
|
|
expected_model,
|
|
pytest.approx(_REPORTED_COST),
|
|
_REPORTED_TOKENS,
|
|
"success",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("header_value", "expected_model"),
|
|
[
|
|
(f"{_REPORTED_MODEL}, other-name", _REPORTED_MODEL),
|
|
("m" * 5000, "m" * 256),
|
|
],
|
|
ids=["repeated_header_keeps_first", "long_name_cut_to_256"],
|
|
)
|
|
@pytest.mark.parametrize("status", [200, 500], ids=["success", "failure"])
|
|
def test_spend_row_keeps_a_bounded_first_model_name(
|
|
gateway: Gateway, header_value: str, expected_model: str, status: int
|
|
) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
headers: Final = {**_USAGE_HEADERS, "x-litellm-upstream-model": header_value}
|
|
with wire_server(_upstream(marker, headers, status=status)) as wire, gateway.scenario() as scenario:
|
|
path: Final = _endpoint(gateway, scenario, marker, wire.url + "/v1/summarize")
|
|
key: Final = scenario.key(allowed_passthrough_routes=[path])
|
|
|
|
response: Final = gateway.request("POST", path, {"ticket_id": marker}, key=key)
|
|
assert response.status_code == status, response.text
|
|
|
|
assert _row_values(_spend_row(key)) == (
|
|
expected_model,
|
|
pytest.approx(_REPORTED_COST),
|
|
_REPORTED_TOKENS,
|
|
"success" if status == 200 else "failure",
|
|
)
|