litellm/tests/integration/spend/_background_responses.py
devin-ai-integration[bot] fbc2e358bc
fix(responses): bill a background response once, on the first read or poll that sees it finished (#45703)
* fix(responses): bill a background response once, from the poll job

Reading a completed background response with GET /v1/responses/{id} wrote a
full-cost spend row on every read, on top of the row the background cost poll
writes. Reads of a Responses object (GET, DELETE, cancel) now never bill, and
the poll job is the single biller.

The poll job now bills with the creating request's attribution (key hash, team,
org, user, aliases, tags) read off the managed object row, through a helper it
shares with the batch cost poll. The background create persists the creating
key and request tags so that attribution survives until the poll runs.

* test(proxy): a background response read reports zero cost

* fix(responses): bill a background response once, on the first read or poll that sees it finished

A conditional update moves the stored row out of queued or in_progress so only one worker or pod bills it, through one re-read stamped with the creator's attribution. A failed billing read puts the row back for the next poll

* fix(responses): keep the caller's response id out of the billing failure warning

* fix(responses): keep the creating client's User-Agent tags on the one background charge

* fix(responses): keep managed background reads free and bill each response once

The managed files hook marks reads and cancels of proxy-issued response ids, so only those stay free. SDK and database-less reads of a background response still bill, extra_body background creates are tracked, and the poll's status read no longer cools down a deployment on a 404.

* fix(responses): bill finished background responses from the fetched body and store rows Postgres accepts

* fix(responses): give the background billing event its own spend log row and bill responses the create left untracked

* fix(responses): narrow the managed files hook with a protocol instead of an enterprise import

* fix(responses): leave a read the caller retargets with client-side credentials to the cost poll

* fix(responses): bill background responses on the creator's end user and report the charge on the settling read

The read that settles the bill returns the charge in x-litellm-response-cost, the billed event
carries the creator's end user and the key, team, and user budget fields, and its spend row is
named after the id the client got. Override reads, cancels, and deletes settle from the
configured deployment, a streamed create marks its row billed, stored rows keep no model
output, and model insights counts the billed row's spend without a request.

* test(responses): cover background response billing across proxies, reads and chaos

* fix(responses): settle background billing only for callers allowed to address the response

A delete now settles the bill only after the response id access check passes, a streamed
create is marked billed when it sets background inside extra_body or its completed event
arrives after the last chunk, the billing event keeps the deployment's litellm_params
base_model, a key with no user and no team bills no user instead of its owner scope, and the
enterprise cost check import is optional

* fix(responses): record the bound on the pending status IN filters

* fix(responses): send the billed cost split on the settling read and keep the batch cost event as it was

The read that settles a background response now sends the whole cost header
family from the billed event, so the breakdown headers add up to the charge.
Batch cost events go back to main's attribution (creator id as the user, no
budget fields); only background responses get the end user and budgets.

Integration tests expect the charge on the settling read (or none when the
poll billed first), one charge for a streamed create, and SDK flows that poll
with the id each read returns.

* fix(responses): bill each background response once across upgrades, streams, and failed settles

Rows stored before this billing model are found by the issued id and rekeyed by the
provider id, and the cost poll retires one a read already billed. A streamed background
create is free on its own and bills the finished response through the same managed row
before the client sees response.completed, so a racing read cannot bill it again. A
settle that fails retries in the background, a retargeted read with no row tracks and
bills from the configured deployment, and a delete keeps its billed cost header.
Reverts the unrelated json.dumps narrowing in the completion transformation.

* fix(responses): bill cache-polled background streams and match poll rows by the issued id

* test(responses): cover deletes, failed settles, budgets, base models, and cache-polled background billing

Adds integration cells for the background response billing paths the audit
inventory lists: a DELETE that settles the bill and refuses a stranger, a
settle the database refuses that retries once it recovers, the Prometheus
remaining budget, base_model pricing from model_info and litellm_params, the
daily model usage rollup, the end user, the settling read's cost split, stored
rows without model output, a forged managed marker on the create, streamed
creates with background in extra_body, and the cache-polled create

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-15 11:00:14 -07:00

327 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], ...]]:
fresh: Final = (calls_to(observed_requests(gateway), handle) for _ in itertools.count())
return itertools.accumulate(fresh, lambda seen, calls: (*seen, *calls))
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"
)