litellm/tests/integration/pricing/test_per_second_pricing.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

238 lines
10 KiB
Python

import json
import uuid
from collections.abc import Mapping
from typing import Final
import httpx
import litellm
import pytest
from pydantic import JsonValue
from tests.integration._support.client import JSON_OBJECT, Gateway, eventually, object_value, string_value
from tests.integration._support.database import read_rows
from tests.integration._support.upstream import delete_scenario, register_scenario
from tests.integration.cost_calculation.cost_tracking_case import SseResponse
RATE: Final = 0.5
FRAME_DELAY_MS: Final = 300
CONTENT: Final = ("one", " two", " three", " four")
PRICING_FIELDS: Final = frozenset({"cost_per_second", "input_cost_per_second", "output_cost_per_second"})
PER_SECOND_CONFIGURATIONS: Final[tuple[tuple[str, Mapping[str, JsonValue]], ...]] = (
("new_field", {"cost_per_second": RATE}),
("legacy_input", {"input_cost_per_second": RATE}),
("legacy_output", {"output_cost_per_second": RATE}),
("legacy_both", {"input_cost_per_second": RATE, "output_cost_per_second": 0.25}),
(
"all_three",
{"cost_per_second": RATE, "input_cost_per_second": 0.25, "output_cost_per_second": 0.125},
),
)
def _sse_chunk(delta: dict[str, JsonValue], finish_reason: str | None) -> str:
payload: Final = {
"id": "$REQUEST_ID",
"object": "chat.completion.chunk",
"created": 1,
"model": "integration-per-second",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
return f"data: {json.dumps(payload)}"
def _sse_frames() -> tuple[str, ...]:
content_frames: Final = tuple(_sse_chunk({"content": content}, None) for content in CONTENT)
usage_payload: Final = {
"id": "$REQUEST_ID",
"object": "chat.completion.chunk",
"created": 1,
"model": "integration-per-second",
"choices": [],
"usage": {"prompt_tokens": 20, "completion_tokens": 20, "total_tokens": 40},
}
usage_frame: Final = f"data: {json.dumps(usage_payload)}"
return (*content_frames, _sse_chunk({}, "stop"), usage_frame, "data: [DONE]")
def _stream_content(event: dict[str, JsonValue]) -> str:
choices: Final = event.get("choices")
if not isinstance(choices, list) or not choices:
return ""
delta: Final = object_value(object_value(choices[0])["delta"])
content: Final = delta.get("content")
return content if isinstance(content, str) else ""
def _clear_observations(upstream: httpx.Client) -> None:
response: Final = upstream.delete("/__observations")
assert response.status_code == 204, response.text
def _observed_request_body(upstream: httpx.Client) -> dict[str, JsonValue]:
observations: Final = JSON_OBJECT.validate_json(upstream.get("/__observations").content)["requests"]
assert isinstance(observations, list)
post_observations: Final = tuple(
observation for observation in observations if isinstance(observation, dict) and observation.get("method") == "POST"
)
assert len(post_observations) == 1
return object_value(object_value(post_observations[0])["body"])
@pytest.mark.parametrize(
("pricing_case", "pricing"),
PER_SECOND_CONFIGURATIONS,
ids=("new_field", "legacy_input", "legacy_output", "legacy_both", "all_three"),
)
def test_chat_per_second_pricing_is_charged_once_and_not_forwarded(
gateway: Gateway, pricing_case: str, pricing: Mapping[str, JsonValue]
) -> None:
with gateway.scenario() as scenario:
scenario_id: Final = f"per-second-{pricing_case}-{uuid.uuid4().hex}"
key: Final = scenario.key()
model: Final = scenario.model(
model=f"openai/integration-per-second-{uuid.uuid4().hex}",
api_key=scenario_id,
api_base=f"{gateway.upstream_url.rstrip('/')}/v1",
**pricing,
)
with httpx.Client(base_url=gateway.upstream_url, trust_env=False) as upstream:
_clear_observations(upstream)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "price this request"}]},
key=key,
)
body: Final = _observed_request_body(upstream)
assert response.status_code == 200, f"{pricing_case}: {response.text}"
response_cost: Final = float(response.headers.get("x-litellm-response-cost", "0"))
duration_ms: Final = float(response.headers.get("x-litellm-response-duration-ms", "0"))
assert response_cost > 0, f"{pricing_case}: cost={response_cost}, duration_ms={duration_ms}, body={body}"
assert response_cost == pytest.approx(RATE * duration_ms / 1000, rel=1e-3), (
f"{pricing_case}: cost={response_cost}, duration_ms={duration_ms}, body={body}"
)
assert not PRICING_FIELDS.intersection(body), body
request_id: Final = string_value(object_value(response.json())["id"])
rows: Final = eventually(
lambda: read_rows(
'SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id = %s',
(request_id,),
),
lambda values: len(values) == 1,
seconds=70,
)
assert float(str(rows[0]["spend"])) == pytest.approx(response_cost, rel=1e-3)
@pytest.mark.parametrize(
("pricing_case", "pricing"),
PER_SECOND_CONFIGURATIONS,
ids=("new_field", "legacy_input", "legacy_output", "legacy_both", "all_three"),
)
def test_streaming_chat_per_second_pricing_covers_the_full_stream(
gateway: Gateway, pricing_case: str, pricing: Mapping[str, JsonValue]
) -> None:
with gateway.scenario() as scenario:
scenario_id: Final = f"per-second-stream-{pricing_case}-{uuid.uuid4().hex}"
frames: Final = _sse_frames()
handle: Final = register_scenario(
scenario_id,
SseResponse(content_type="text/event-stream", frames=frames, frame_delay_ms=FRAME_DELAY_MS),
)
scenario.cleanups.callback(delete_scenario, handle)
key: Final = scenario.key()
model: Final = scenario.model(
model=f"openai/integration-per-second-{uuid.uuid4().hex}",
api_key=scenario_id,
api_base=handle.api_base(),
**pricing,
)
with httpx.Client(base_url=gateway.upstream_url, trust_env=False) as upstream:
_clear_observations(upstream)
with gateway.client.stream(
"POST",
"/v1/chat/completions",
json={
"model": model,
"messages": [{"role": "user", "content": "price this streamed request"}],
"stream": True,
"stream_options": {"include_usage": True},
},
headers={"Authorization": f"Bearer {key}"},
) as response:
stream_lines: Final = tuple(response.iter_lines())
assert response.status_code == 200, "\n".join(stream_lines)
body: Final = _observed_request_body(upstream)
events: Final = tuple(
JSON_OBJECT.validate_json(line.removeprefix("data: "))
for line in stream_lines
if line.startswith("data: ") and line != "data: [DONE]"
)
assert len(events) == len(frames) - 1, events
assert "".join(_stream_content(event) for event in events) == "".join(CONTENT), events
usage: Final = object_value(events[-1]["usage"])
assert usage["total_tokens"] == 40, events[-1]
request_id: Final = string_value(events[0]["id"])
rows: Final = eventually(
lambda: read_rows(
'SELECT spend, request_duration_ms, '
'CAST(EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000 AS DOUBLE PRECISION) '
'AS elapsed_duration_ms '
'FROM "LiteLLM_SpendLogs" WHERE request_id = %s',
(request_id,),
),
lambda values: len(values) == 1,
seconds=70,
)
spend: Final = float(str(rows[0]["spend"]))
request_duration_ms: Final = float(str(rows[0]["request_duration_ms"]))
elapsed_duration_ms: Final = float(str(rows[0]["elapsed_duration_ms"]))
assert spend == pytest.approx(RATE * request_duration_ms / 1000, rel=5e-2), (
f"spend={spend}, request_duration_ms={request_duration_ms}, "
f"endTime-startTime duration_ms={elapsed_duration_ms}, body={body}"
)
total_frame_delay_seconds: Final = (len(frames) - 1) * FRAME_DELAY_MS / 1000
assert spend >= RATE * total_frame_delay_seconds * 0.95, (
f"spend={spend}, total frame delay={total_frame_delay_seconds}s, body={body}"
)
assert not PRICING_FIELDS.intersection(body), body
@pytest.mark.parametrize(
("model", "custom_llm_provider", "expected_cost"),
(
("deepgram/nova-3", "deepgram", (0.0007167, 0.0)),
("mistral/voxtral-mini-transcribe-realtime-latest", "mistral", (0.001, 0.0)),
),
ids=("deepgram_nova_3", "mistral_voxtral_realtime"),
)
def test_transcription_per_second_model_bills_request_time_through_cost_per_token(
model: str, custom_llm_provider: str, expected_cost: tuple[float, float]
) -> None:
cost: Final = litellm.cost_per_token(model=model, custom_llm_provider=custom_llm_provider, response_time_ms=10_000.0)
assert cost == pytest.approx(expected_cost)
def test_transcription_per_second_model_bills_audio_length_through_cost_per_token() -> None:
cost: Final = litellm.cost_per_token(
model="deepgram/nova-3",
custom_llm_provider="deepgram",
response_time_ms=10_000.0,
audio_transcription_file_duration=60.0,
)
assert cost == pytest.approx((0.0043002, 0.0))
def test_transcription_response_still_bills_audio_length_through_completion_cost() -> None:
response: Final = litellm.TranscriptionResponse(text="hello")
response.duration = 60.0
cost: Final = litellm.completion_cost(
completion_response=response, model="deepgram/nova-3", custom_llm_provider="deepgram", total_time=10.0
)
assert cost == pytest.approx(0.0043002)