litellm/tests/integration/spend/_background_responses.py
yuneng-jiang 44d99475a5
test(integration): stop GET /__observations from draining the upstream log (#47256)
* test(integration): stop GET /__observations from draining the upstream log

GET /__observations used to empty the queue, so a second read in a test
saw nothing and a "never reached upstream" check could pass whether or not
the request was forwarded. GET now returns every request since the last
reset and DELETE /__observations is the reset. An autouse fixture resets
the worker upstream before each test, which also covers tests that read
without the gateway fixture.

Discarded reads used as resets become DELETEs, helpers that accumulated
drained reads now read one snapshot, and tests with several phases reset
between them. The three vacuous negative checks (rag allowlist, guardrail
opt-out chaos, xAI batch output) now assert the absence on the same read
that shows a request which did reach the upstream.

* style(integration): space the observations unit test like its neighbours
2026-10-18 09:58:05 +00:00

326 lines
12 KiB
Python

import itertools
import json
import math
import uuid
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from hashlib import sha256
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario, eventually, held, list_value, object_value, string_value
from integration._support.database import read_rows, write_rows
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import JsonResponse, RoutedResponse, SseResponse
from pydantic import JsonValue
INPUT_COST_PER_TOKEN: Final = 0.001
OUTPUT_COST_PER_TOKEN: Final = 0.002
INPUT_TOKENS: Final = 19
OUTPUT_TOKENS: Final = 7
PRICE: Final = INPUT_TOKENS * INPUT_COST_PER_TOKEN + OUTPUT_TOKENS * OUTPUT_COST_PER_TOKEN
SCHEDULER_PERIOD_CEILING_SECONDS: Final = 31
ONE_POLL_SECONDS: Final = 2 * SCHEDULER_PERIOD_CEILING_SECONDS + 8
POLL_WINDOW_SECONDS: Final = SCHEDULER_PERIOD_CEILING_SECONDS + 9
SPEND_LANDING_SECONDS: Final = 60
PROVIDER_ID: Final = "resp_$REQUEST_ID"
PROMPT: Final = "bill me once"
READS: Final = 3
CONCURRENT_READS: Final = 12
FINISHED_STATUSES: Final = frozenset({"completed", "incomplete", "cancelled"})
MANAGED_READ_MARKER: Final = "_managed_background_response"
ATTRIBUTION_COLUMNS: Final = ("api_key", "team_id", "organization_id", "user")
def response_body(status: str, *, background: bool = True, **fields: JsonValue) -> dict[str, JsonValue]:
finished: Final = status in FINISHED_STATUSES
return {
"id": PROVIDER_ID,
"object": "response",
"created_at": 1,
"status": status,
"background": background,
"store": False,
"error": None,
"incomplete_details": {"reason": "max_output_tokens"} if status == "incomplete" else None,
"model": "gpt-4o-mini",
"output": (
[
{
"id": "msg_$REQUEST_ID",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "pong", "annotations": []}],
}
]
if finished
else []
),
"parallel_tool_calls": True,
"temperature": 1.0,
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"truncation": "disabled",
"usage": (
{
"input_tokens": INPUT_TOKENS,
"output_tokens": OUTPUT_TOKENS,
"total_tokens": INPUT_TOKENS + OUTPUT_TOKENS,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
}
if finished
else None
),
"metadata": {},
**fields,
}
def json_reply(body: dict[str, JsonValue], status: int = 200) -> JsonResponse:
return JsonResponse(content_type="application/json", body=body, status=status)
def scripted_routes(
create: JsonResponse, read: JsonResponse, *, provider_id: str = PROVIDER_ID, **extra: JsonResponse
) -> RoutedResponse:
return RoutedResponse(
content_type="application/x-routed",
routes={
"POST /responses": create,
f"GET /responses/{provider_id}": read,
**{f"POST /responses/{provider_id}/{action}": response for action, response in extra.items()},
},
)
def background_routes(read_status: str, **fields: JsonValue) -> RoutedResponse:
return scripted_routes(
json_reply(response_body("queued", **fields)), json_reply(response_body(read_status, **fields))
)
def sse_frame(event: str, sequence_number: int, response: dict[str, JsonValue]) -> str:
return f"event: {event}\ndata: " + json.dumps(
{"type": event, "sequence_number": sequence_number, "response": response}, separators=(",", ":")
)
def streamed_reply(*, background: bool = True) -> SseResponse:
return SseResponse(
content_type="text/event-stream",
frames=(
sse_frame("response.created", 0, response_body("queued", background=background)),
sse_frame("response.completed", 1, response_body("completed", background=background)),
),
)
@dataclass(frozen=True, slots=True)
class Upstream:
model: str
model_id: str
handle: ScenarioHandle
def script(self, response: RoutedResponse | SseResponse) -> None:
register_scenario(self.handle.scenario_id, response, control_url=self.handle.control_url)
def scripted_upstream(
scenario: Scenario,
response: RoutedResponse | SseResponse,
*,
model_info: Mapping[str, JsonValue] | None = None,
**litellm_params: JsonValue,
) -> Upstream:
scenario_id: Final = f"bgonce-{uuid.uuid4().hex[:12]}"
handle: Final = register_scenario(scenario_id, response)
scenario.cleanups.callback(delete_scenario, handle)
created: Final = scenario.gateway.post(
"/model/new",
{
"model_name": scenario_id,
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-scripted-provider",
"api_base": handle.api_base(),
"input_cost_per_token": INPUT_COST_PER_TOKEN,
"output_cost_per_token": OUTPUT_COST_PER_TOKEN,
**litellm_params,
},
**({"model_info": dict(model_info)} if model_info else {}),
},
)
model_id: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, model_id)
return Upstream(scenario_id, model_id, handle)
def forget_row(response_id: str) -> None:
write_rows('DELETE FROM "LiteLLM_ManagedObjectTable" WHERE unified_object_id = %s', (response_id,))
def create_background_response(
scenario: Scenario,
key: str,
model: str,
*,
body: Mapping[str, JsonValue] | None = None,
headers: Mapping[str, str] | None = None,
) -> str:
response: Final = scenario.gateway.request(
"POST",
"/v1/responses",
body or {"model": model, "input": PROMPT, "background": True, "store": False},
key=key,
headers=headers,
)
assert response.status_code == 200, response.text
response_id: Final = string_value(object_value(response.json())["id"])
scenario.cleanups.callback(forget_row, response_id)
return response_id
@dataclass(frozen=True, slots=True)
class Read:
status: str
text: str
cost: float | None
def reported_cost(headers: httpx.Headers) -> float | None:
reported: Final = headers.get("x-litellm-response-cost")
return None if reported is None else float(reported)
def read_of(response: httpx.Response) -> Read:
assert response.status_code == 200, response.text
body: Final = object_value(response.json())
output: Final = list_value(body["output"])
text: Final = (
string_value(object_value(list_value(object_value(output[0])["content"])[0])["text"]) if output else ""
)
return Read(string_value(body["status"]), text, reported_cost(response.headers))
def read_response(gateway: Gateway, key: str, response_id: str, body: Mapping[str, JsonValue] | None = None) -> Read:
return read_of(gateway.request("GET", f"/v1/responses/{response_id}", body, key=key))
def free_reads(status: str, count: int) -> tuple[Read, ...]:
return (Read(status, "pong", 0.0),) * count
def settling_reads(status: str, count: int) -> tuple[Read, ...]:
return (Read(status, "pong", pytest.approx(PRICE)), *free_reads(status, count - 1))
def hashed(key: str) -> str:
return sha256(key.encode()).hexdigest()
def number(value: JsonValue) -> float:
assert isinstance(value, int | float), value
return float(value)
def key_spend(key: str) -> float:
rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token = %s', (hashed(key),))
return number(rows[0]["spend"])
def team_spend(team_id: str) -> float:
rows: Final = read_rows('SELECT spend FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team_id,))
return number(rows[0]["spend"])
def user_spend(user_id: str) -> float:
rows: Final = read_rows('SELECT spend FROM "LiteLLM_UserTable" WHERE user_id = %s', (user_id,))
return number(rows[0]["spend"])
def org_spend(organization_id: str) -> float:
rows: Final = read_rows(
'SELECT spend FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (organization_id,)
)
return number(rows[0]["spend"])
def tag_spend(tag: str) -> float:
rows: Final = read_rows(
'SELECT COALESCE(SUM(spend), 0) AS spend FROM "LiteLLM_DailyTagSpend" WHERE tag = %s', (tag,)
)
return number(rows[0]["spend"])
def costs(expected: float) -> Callable[[float], bool]:
return lambda spend: math.isclose(spend, expected, abs_tol=1e-9)
def assert_charged_exactly(read_spend: Callable[[], float], expected: float) -> None:
held(read_spend, costs(expected), holding=POLL_WINDOW_SECONDS, seconds=POLL_WINDOW_SECONDS + SPEND_LANDING_SECONDS)
def charged_rows(model_id: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT request_id, call_type, api_key, team_id, organization_id, "user", request_tags, spend, '
'prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE model_id = %s AND spend > 0',
(model_id,),
)
def create_rows(model_id: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT api_key, team_id, organization_id, "user", request_tags FROM "LiteLLM_SpendLogs" '
"WHERE model_id = %s AND call_type = 'aresponses'",
(model_id,),
)
def one_charged_row(model_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(lambda: charged_rows(model_id), lambda found: len(found) >= 1, seconds=30)
assert len(rows) == 1, rows
return rows[0]
def managed_rows(response_id: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT status, api_key, created_by, team_id FROM "LiteLLM_ManagedObjectTable" '
"WHERE unified_object_id = %s OR model_object_id = %s",
(response_id, response_id),
)
def assert_billed_like_its_creator(
row: dict[str, JsonValue], key: str, team_id: str | None, user_id: str | None
) -> None:
assert string_value(row["request_id"]).endswith("_background_cost"), row
assert row["call_type"] == "aget_responses"
assert row["api_key"] == hashed(key)
assert row["team_id"] == team_id
assert row["user"] == user_id
assert row["prompt_tokens"] == INPUT_TOKENS
assert row["completion_tokens"] == OUTPUT_TOKENS
assert number(row["spend"]) == pytest.approx(PRICE)
def observed_requests(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream:
return tuple(map(object_value, list_value(upstream.get("/__observations").json()["requests"])))
def calls_to(requests: Sequence[dict[str, JsonValue]], handle: ScenarioHandle) -> tuple[dict[str, JsonValue], ...]:
return tuple(request for request in requests if string_value(request["path"]).startswith(f"/{handle.scenario_id}/"))
def upstream_log(gateway: Gateway, handle: ScenarioHandle) -> Iterator[tuple[dict[str, JsonValue], ...]]:
return (calls_to(observed_requests(gateway), handle) for _ in itertools.count())
def submissions(log: Sequence[dict[str, JsonValue]], handle: ScenarioHandle) -> tuple[JsonValue, ...]:
return tuple(
call["body"] for call in log if call["method"] == "POST" and call["path"] == f"/{handle.scenario_id}/responses"
)