litellm/tests/integration/pricing/test_per_second_pricing.py
devin-ai-integration[bot] abc85c2651
fix(cost_calculator): bill chat per-second pricing once with a new cost_per_second field (#43614)
* feat(cost_calculator): add cost_per_second for chat per-second pricing

Keep legacy input_cost_per_second and output_cost_per_second as aliases for chat, completion, embedding and responses. When both legacy fields are set, input_cost_per_second wins

Move Bedrock commitment rows to cost_per_second so they bill once

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(cost_calculator): drop legacy per-second fields from chat paths

Keep Azure chat token pricing generic and update inert Voxtral rates and SageMaker examples

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cost_calculator): recognize output-only per-second rates

Include output_cost_per_second when checking whether a deployment cost entry has pricing so output-only legacy aliases remain attached to the deployment during cost selection

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(pricing): cover cost_per_second and legacy per-second aliases through the proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(cost_calculator): drop output_cost_per_second as a chat per-second alias

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(cost_calculator): restore output_cost_per_second as a chat per-second fallback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cost-map): keep input_cost_per_second on bedrock commitment rows for older clients

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-29 11:27:14 -07:00

196 lines
8.5 KiB
Python

import json
import uuid
from collections.abc import Mapping
from typing import Final
import httpx
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.get("/__observations")
assert response.status_code == 200, 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)
assert len(observations) == 1
return object_value(object_value(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