Merge pull request #42060 from BerriAI/litellm_cost_shard_proxy_behaviour

test(integration): proxy behaviour cost cases
This commit is contained in:
kerry-berri 2026-09-21 14:33:47 -07:00 • committed by GitHub
commit 29792c45a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1068 additions and 75 deletions

View file

@ -34,12 +34,19 @@ def delete_key_if_present(candidate: Gateway, key: str) -> None:
assert read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)) == []
def eventually(read: Callable[[], T], satisfied: Callable[[T], bool], seconds: float = 10) -> T:
def eventually(
read: Callable[[], T],
satisfied: Callable[[T], bool],
seconds: float = 10,
return_last_on_timeout: bool = False,
) -> T:
deadline: Final = time.monotonic() + seconds
while True:
observed: Final = read()
if satisfied(observed):
return observed
if return_last_on_timeout and time.monotonic() >= deadline:
return observed
assert time.monotonic() < deadline, f"State did not converge: {observed!r}"
time.sleep(0.1)

View file

@ -1,9 +1,10 @@
from __future__ import annotations
import argparse
import asyncio
import base64
from collections import deque
from collections.abc import Mapping
from collections.abc import AsyncIterator, Mapping
import json
from dataclasses import dataclass, field
import os
@ -11,6 +12,7 @@ from pathlib import Path
from queue import SimpleQueue
import struct
from typing import Final, cast
import uuid
import zlib
import httpx
@ -18,7 +20,7 @@ import uvicorn
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
from _fake_openai_endpoint_server import chat_completions, completions, embeddings, health, moderations
@ -78,10 +80,15 @@ def _aws_str_header(name: str, value: str) -> bytes:
)
def _aws_event_frame(event_type: str, payload: Mapping[str, JsonValue], scenario_id: str) -> bytes:
def _aws_event_frame(
event_type: str,
payload: Mapping[str, JsonValue],
scenario_id: str,
unique_id: str,
) -> bytes:
payload_bytes: Final = json.dumps(payload, separators=(",", ":")).replace(
"$REQUEST_ID", scenario_id
).encode()
).replace("$UNIQUE_ID", unique_id).encode()
headers_bytes: Final = (
_aws_str_header(":event-type", event_type)
+ _aws_str_header(":content-type", "application/json")
@ -208,11 +215,14 @@ class Provider:
@staticmethod
def _response(response: StoredResponse, scenario_id: str) -> Response:
unique_id: Final = f"{scenario_id}-{uuid.uuid4().hex[:8]}"
match response:
case JsonResponse():
return Response(
content=json.dumps(response.body, separators=(",", ":")).replace(
"$REQUEST_ID", scenario_id
).replace(
"$UNIQUE_ID", unique_id
).encode(),
media_type=response.content_type,
status_code=response.status,
@ -223,9 +233,18 @@ class Provider:
media_type=response.content_type,
)
case SseResponse():
if response.frame_delay_ms > 0:
async def stream() -> AsyncIterator[bytes]:
for frame in response.frames:
yield (
f"{frame.replace('$REQUEST_ID', scenario_id).replace('$UNIQUE_ID', unique_id)}\n\n"
).encode()
await asyncio.sleep(response.frame_delay_ms / 1000)
return StreamingResponse(stream(), media_type=response.content_type)
stream_body: Final = ("\n\n".join(response.frames) + "\n\n").replace(
"$REQUEST_ID", scenario_id
)
).replace("$UNIQUE_ID", unique_id)
return Response(content=stream_body.encode(), media_type=response.content_type)
case EventStreamResponse():
events: Final = (
@ -236,6 +255,7 @@ class Provider:
"bytes": base64.b64encode(
json.dumps(event.payload, separators=(",", ":"))
.replace("$REQUEST_ID", scenario_id)
.replace("$UNIQUE_ID", unique_id)
.encode()
).decode(),
},
@ -246,7 +266,7 @@ class Provider:
else response.events
)
event_body: Final = b"".join(
_aws_event_frame(event.event_type, event.payload, scenario_id) for event in events
_aws_event_frame(event.event_type, event.payload, scenario_id, unique_id) for event in events
)
return Response(content=event_body, media_type=response.content_type)

View file

@ -1624,6 +1624,48 @@
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[openai-deployment-pricing-override]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-upstream_400_zero_spend]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-upstream_401_zero_spend]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-upstream_500_stream_request_zero_spend]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_upstream_500_zero_spend]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_upstream_500_zero_spend]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-fallback_billed_to_answering_deployment]": [
"quota_management.spend_tracking.routing.fallback_billing"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-n_2_choices]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-finish_reason_length]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-stream_usage_in_empty_choices_chunk]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-stream_usage_in_last_delta_chunk]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-unknown_model_response_model_unknown]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-unknown_model_response_model_known]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-chat_request_to_embedding_entry]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-client_disconnect_mid_stream]": [
"quota_management.spend_tracking.scripted_wire.client_disconnect"
],
"tests/integration/mcp/test_mcp_lifecycle.py::test_health_intersects_route_restricted_key_grants_in_both_management_modes": [
"other.mcp.health.restricted_keys_intersect_grants_in_both_modes"
],

View file

@ -4,6 +4,7 @@ import functools
import json
import os
from collections.abc import Mapping
from dataclasses import dataclass
from hashlib import sha256
from typing import Final
@ -13,8 +14,8 @@ from pydantic import BaseModel, ConfigDict
from integration._support.client import JSON_OBJECT, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import CostTrackingTestCase
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import CostTrackingTestCase, StoredResponse
class CostBreakdown(BaseModel):
@ -43,6 +44,7 @@ class CostRow(BaseModel):
status: str | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
model_id: str | None = None
metadata: CostMetadata | None = None
@property
@ -59,6 +61,26 @@ class FailureRow(BaseModel):
completion_tokens: int | None = None
class DailySpend(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
spend: float
prompt_tokens: int
completion_tokens: int
api_requests: int
class Rollups(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
key_spend: float
team_spend: float
user_spend: float
end_user_spend: float
daily_user: DailySpend
daily_team: DailySpend
def approx_equal(actual: float, expected: float) -> bool:
return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
@ -90,7 +112,7 @@ def poll_cost_row(key: str) -> CostRow:
def read() -> CostRow | None:
rows: Final = read_rows(
'SELECT spend, status, metadata, prompt_tokens, completion_tokens '
'SELECT spend, status, metadata, prompt_tokens, completion_tokens, model_id '
'FROM "LiteLLM_SpendLogs" WHERE api_key=%s',
(digest,),
)
@ -101,6 +123,98 @@ def poll_cost_row(key: str) -> CostRow:
return result
def read_rows_now(key: str) -> tuple[CostRow, ...]:
digest: Final = sha256(key.encode()).hexdigest()
rows: Final = read_rows(
'SELECT spend, status, metadata, prompt_tokens, completion_tokens, model_id '
'FROM "LiteLLM_SpendLogs" WHERE api_key=%s ORDER BY "startTime"',
(digest,),
)
return tuple(parsed for row in rows if (parsed := _row(row)) is not None)
def poll_rows(key: str, count: int) -> tuple[CostRow, ...]:
result: Final = eventually(
lambda: read_rows_now(key),
lambda rows: len(rows) >= count,
seconds=60,
)
return result
def poll_rollups(
key: str,
team_id: str,
user_id: str,
end_user_id: str,
target_spend: float,
target_requests: int,
) -> Rollups:
digest: Final = sha256(key.encode()).hexdigest()
def read() -> Rollups | None:
key_rows: Final = read_rows(
'SELECT spend FROM "LiteLLM_VerificationToken" WHERE token=%s',
(digest,),
)
team_rows: Final = read_rows(
'SELECT spend FROM "LiteLLM_TeamTable" WHERE team_id=%s',
(team_id,),
)
user_rows: Final = read_rows(
'SELECT spend FROM "LiteLLM_UserTable" WHERE user_id=%s',
(user_id,),
)
end_user_rows: Final = read_rows(
'SELECT spend FROM "LiteLLM_EndUserTable" WHERE user_id=%s',
(end_user_id,),
)
daily_user_rows: Final = read_rows(
'SELECT spend, prompt_tokens, completion_tokens, api_requests '
'FROM "LiteLLM_DailyUserSpend" WHERE user_id=%s AND api_key=%s AND date=CURRENT_DATE::text',
(user_id, digest),
)
daily_team_rows: Final = read_rows(
'SELECT spend, prompt_tokens, completion_tokens, api_requests '
'FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s AND api_key=%s AND date=CURRENT_DATE::text',
(team_id, digest),
)
if not all((key_rows, team_rows, user_rows, end_user_rows, daily_user_rows, daily_team_rows)):
return None
rollups: Final = Rollups(
key_spend=float(key_rows[0]["spend"]),
team_spend=float(team_rows[0]["spend"]),
user_spend=float(user_rows[0]["spend"]),
end_user_spend=float(end_user_rows[0]["spend"]),
daily_user=DailySpend.model_validate(daily_user_rows[0]),
daily_team=DailySpend.model_validate(daily_team_rows[0]),
)
return rollups
def settled(value: Rollups | None) -> bool:
return value is not None and all(
(
approx_equal(value.key_spend, target_spend),
approx_equal(value.team_spend, target_spend),
approx_equal(value.user_spend, target_spend),
approx_equal(value.end_user_spend, target_spend),
approx_equal(value.daily_user.spend, target_spend),
approx_equal(value.daily_team.spend, target_spend),
value.daily_user.api_requests == target_requests,
value.daily_team.api_requests == target_requests,
)
)
result: Final = eventually(
read,
settled,
seconds=20,
return_last_on_timeout=True,
)
assert result is not None
return result
def poll_failure_row(key: str) -> FailureRow:
digest: Final = sha256(key.encode()).hexdigest()
@ -147,17 +261,30 @@ def _vertex_service_account_json(url: str) -> str:
)
@dataclass(frozen=True, slots=True)
class RegisteredDeployment:
model_name: str
identity: str
handle: ScenarioHandle
def register_scenario_deployment(
scenario: Scenario,
case: CostTrackingTestCase,
marker: str,
key: str,
) -> str:
*,
response: StoredResponse | None = None,
marker_suffix: str = "",
) -> RegisteredDeployment:
control_url: Final = os.environ["INTEGRATION_UPSTREAM_URL"].rstrip("/")
run_marker: Final = sha256(key.encode()).hexdigest()[:12]
handle: Final = register_scenario(f"sc-{marker}-{run_marker}", case.response)
handle: Final = register_scenario(
f"sc-{marker}{marker_suffix}-{run_marker}",
case.response if response is None else response,
)
scenario.cleanups.callback(delete_scenario, handle)
model_name: Final = f"cost-{marker}-{run_marker}"
registered_model_name: Final = f"cost-{marker}{marker_suffix}-{run_marker}"
parameters: Final = {
"model": case.litellm_model,
"api_key": case.api_key,
@ -184,7 +311,7 @@ def register_scenario_deployment(
created: Final = scenario.gateway.post(
"/model/new",
JSON_OBJECT.validate_python({
"model_name": model_name,
"model_name": registered_model_name,
"litellm_params": parameters,
"model_info": (
{"base_model": case.base_model}
@ -195,4 +322,4 @@ def register_scenario_deployment(
)
identity: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, identity)
return model_name
return RegisteredDeployment(model_name=registered_model_name, identity=identity, handle=handle)

View file

@ -119,6 +119,7 @@ class SseResponse(BaseModel):
content_type: Literal["text/event-stream"]
frames: tuple[str, ...]
frame_delay_ms: int = Field(default=0, ge=0)
class EventStreamEvent(BaseModel):
@ -163,6 +164,7 @@ class ExactExpected(BaseModel):
tool_usage_cost: float | None = None
breakdown_persisted: bool = True
cost_header: bool = True
rollups: bool = False
class RecountRates(BaseModel):
@ -176,6 +178,10 @@ class RecountExpected(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
recount: RecountRates
prompt_tokens: int | None = None
completion_tokens: int | None = None
min_completion_tokens: int | None = None
max_completion_tokens: int | None = None
class FailureDetails(BaseModel):
@ -220,6 +226,8 @@ class CostTrackingTestCase(BaseModel):
request: dict[str, JsonValue]
response: StoredResponse
expected: Expected
fallback_from: StoredResponse | None = None
disconnect_after_frames: int | None = Field(default=None, ge=1)
@property
def rates(self) -> CostMapEntry:
@ -442,7 +450,48 @@ def data_errors() -> tuple[str, ...]:
and case.rates.mode != "image_generation"
and not case.reports_provider_cost
)
or (not case.expected.cost_header and case.passthrough_provider is None)
or (
not case.expected.cost_header
and case.passthrough_provider is None
and not isinstance(case.response, SseResponse)
and case.expected.spend != 0.0
)
)
)
invalid_fallbacks: Final = sorted(
case.name
for case in CASES
if case.fallback_from is not None
and (
not isinstance(case.fallback_from, JsonResponse)
or not 400 <= case.fallback_from.status <= 599
)
)
invalid_disconnects: Final = sorted(
case.name
for case in CASES
if case.disconnect_after_frames is not None
and (
not isinstance(case.response, SseResponse)
or case.response.frame_delay_ms <= 0
or not isinstance(case.expected, RecountExpected)
)
)
invalid_rollup_ids: Final = sorted(
case.name
for case in CASES
if isinstance(case.expected, ExactExpected)
and case.expected.rollups
and "$UNIQUE_ID" not in case.response.model_dump_json()
)
invalid_pinned_tool_ids: Final = sorted(
case.name
for case in CASES
if isinstance(case.expected, RecountExpected)
and (case.expected.prompt_tokens is not None or case.expected.completion_tokens is not None)
and any(
marker in case.response.model_dump_json()
for marker in ('"id": "call_$REQUEST_ID"', '"id": "toolu_$REQUEST_ID"')
)
)
return tuple(
@ -458,6 +507,12 @@ def data_errors() -> tuple[str, ...]:
if failure_response_mismatches
else None,
f"invalid passthrough opt-outs: {invalid_opt_outs}" if invalid_opt_outs else None,
f"invalid fallback responses: {invalid_fallbacks}" if invalid_fallbacks else None,
f"invalid disconnect cases: {invalid_disconnects}" if invalid_disconnects else None,
f"rollup responses lack $UNIQUE_ID: {invalid_rollup_ids}" if invalid_rollup_ids else None,
f"pinned tool IDs contain $REQUEST_ID: {invalid_pinned_tool_ids}"
if invalid_pinned_tool_ids
else None,
)
if message is not None
)

View file

@ -618,6 +618,11 @@
"input_cost_per_token": 1.51e-06,
"output_cost_per_token": 7.51e-06
},
"text-embedding-3-large": {
"litellm_provider": "openai",
"mode": "embedding",
"input_cost_per_token": 1.3e-07
},
"text-embedding-4-small": {
"input_cost_per_token": 1.01e-06,
"output_cost_per_token": 0,
@ -7017,7 +7022,7 @@
"response": {
"content_type": "application/json",
"body": {
"id": "msg_$REQUEST_ID",
"id": "msg_$UNIQUE_ID",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
@ -7039,7 +7044,8 @@
"input_cost": 0.00552,
"output_cost": 0.00618,
"prompt_tokens": 1840,
"completion_tokens": 412
"completion_tokens": 412,
"rollups": true
}
},
{
@ -7700,7 +7706,9 @@
"recount": {
"input_cost_per_token": 3e-06,
"output_cost_per_token": 1.5e-05
}
},
"prompt_tokens": 47,
"completion_tokens": 10
}
},
{
@ -7774,7 +7782,7 @@
"content_type": "text/event-stream",
"frames": [
"event: message_start\ndata: {\"type\": \"message_start\", \"message\": {\"id\": \"msg_$REQUEST_ID\", \"type\": \"message\", \"role\": \"assistant\", \"model\": \"claude-sonnet-5\", \"content\": [], \"stop_reason\": null}}",
"event: content_block_start\ndata: {\"type\": \"content_block_start\", \"index\": 0, \"content_block\": {\"type\": \"tool_use\", \"id\": \"toolu_$REQUEST_ID\", \"name\": \"get_weather\", \"input\": {}}}",
"event: content_block_start\ndata: {\"type\": \"content_block_start\", \"index\": 0, \"content_block\": {\"type\": \"tool_use\", \"id\": \"call_fixture_0001\", \"name\": \"get_weather\", \"input\": {}}}",
"event: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"input_json_delta\", \"partial_json\": \"{\\\"city\\\": \\\"Berlin\\\", \\\"days\\\": 7, \\\"units\\\": \\\"metric\\\", \\\"notes\\\": \\\"filler filler filler filler fil\"}}",
"event: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"input_json_delta\", \"partial_json\": \"ler filler filler filler filler filler filler filler filler filler filler filler filler fi\"}}",
"event: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"input_json_delta\", \"partial_json\": \"ller filler filler filler filler filler filler filler filler filler filler filler filler \\\"}\"}}",
@ -7787,7 +7795,8 @@
"recount": {
"input_cost_per_token": 3e-06,
"output_cost_per_token": 1.5e-05
}
},
"min_completion_tokens": 60
}
},
{
@ -7844,7 +7853,9 @@
"recount": {
"input_cost_per_token": 3e-06,
"output_cost_per_token": 1.5e-05
}
},
"prompt_tokens": 301,
"completion_tokens": 9
}
},
{
@ -12874,7 +12885,9 @@
"recount": {
"input_cost_per_token": 5.2e-07,
"output_cost_per_token": 3.12e-06
}
},
"prompt_tokens": 48,
"completion_tokens": 12
}
},
{
@ -12954,7 +12967,8 @@
"recount": {
"input_cost_per_token": 5.2e-07,
"output_cost_per_token": 3.12e-06
}
},
"min_completion_tokens": 60
}
},
{
@ -13006,7 +13020,9 @@
"recount": {
"input_cost_per_token": 5.2e-07,
"output_cost_per_token": 3.12e-06
}
},
"prompt_tokens": 302,
"completion_tokens": 10
}
},
{
@ -20826,7 +20842,7 @@
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"id": "chatcmpl-$UNIQUE_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "gpt-5.6",
@ -20852,7 +20868,8 @@
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412
"completion_tokens": 412,
"rollups": true
}
},
{
@ -21607,7 +21624,9 @@
"recount": {
"input_cost_per_token": 1.75e-06,
"output_cost_per_token": 1.4e-05
}
},
"prompt_tokens": 49,
"completion_tokens": 12
}
},
{
@ -21681,7 +21700,7 @@
"content_type": "text/event-stream",
"frames": [
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\"}, \"finish_reason\": null}], \"usage\": null}",
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\", \"tool_calls\": [{\"index\": 0, \"id\": \"call_$REQUEST_ID\", \"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"arguments\": \"\"}}]}, \"finish_reason\": null}], \"usage\": null}",
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\", \"tool_calls\": [{\"index\": 0, \"id\": \"call_fixture_0001\", \"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"arguments\": \"\"}}]}, \"finish_reason\": null}], \"usage\": null}",
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": \"{\\\"city\\\": \\\"Berlin\\\", \\\"days\\\": 7, \\\"units\\\": \\\"metric\\\", \\\"notes\\\": \\\"filler filler filler filler fil\"}}]}, \"finish_reason\": null}], \"usage\": null}",
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": \"ler filler filler filler filler filler filler filler filler filler filler filler filler fi\"}}]}, \"finish_reason\": null}], \"usage\": null}",
"data: {\"id\": \"chatcmpl-$REQUEST_ID\", \"object\": \"chat.completion.chunk\", \"created\": 1789788263, \"model\": \"gpt-5.6\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": \"ller filler filler filler filler filler filler filler filler filler filler filler filler \\\"}\"}}]}, \"finish_reason\": null}], \"usage\": null}",
@ -21693,7 +21712,8 @@
"recount": {
"input_cost_per_token": 1.75e-06,
"output_cost_per_token": 1.4e-05
}
},
"min_completion_tokens": 60
}
},
{
@ -21748,7 +21768,9 @@
"recount": {
"input_cost_per_token": 1.75e-06,
"output_cost_per_token": 1.4e-05
}
},
"prompt_tokens": 302,
"completion_tokens": 11
}
},
{
@ -30348,6 +30370,586 @@
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-upstream_400_zero_spend",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"status": 400,
"body": {
"error": {
"message": "scripted upstream failure 400",
"type": "server_error",
"code": "400"
}
}
},
"expected": {
"failure": {
"status": 400
}
}
},
{
"name": "gpt-5.6-upstream_401_zero_spend",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"status": 401,
"body": {
"error": {
"message": "scripted upstream failure 401",
"type": "server_error",
"code": "401"
}
}
},
"expected": {
"failure": {
"status": 401
}
}
},
{
"name": "gpt-5.6-upstream_500_stream_request_zero_spend",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": true
},
"response": {
"content_type": "application/json",
"status": 500,
"body": {
"error": {
"message": "scripted upstream failure 500",
"type": "server_error",
"code": "500"
}
}
},
"expected": {
"failure": {
"status": 500
}
}
},
{
"name": "gpt-5.6-responses_upstream_500_zero_spend",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"endpoint": "/v1/responses",
"request": {
"model": "$MODEL",
"input": "proxy behaviour probe",
"stream": false
},
"response": {
"content_type": "application/json",
"status": 500,
"body": {
"error": {
"message": "scripted upstream failure 500",
"type": "server_error",
"code": "500"
}
}
},
"expected": {
"failure": {
"status": 500
}
}
},
{
"name": "claude-sonnet-5-messages_upstream_500_zero_spend",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"endpoint": "/v1/messages",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
]
},
"response": {
"content_type": "application/json",
"status": 500,
"body": {
"error": {
"message": "scripted upstream failure 500",
"type": "server_error",
"code": "500"
}
}
},
"expected": {
"failure": {
"status": 500
}
}
},
{
"name": "gpt-5.6-fallback_billed_to_answering_deployment",
"covers": "quota_management.spend_tracking.routing.fallback_billing",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "gpt-5.6",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "scripted answer"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"fallback_from": {
"content_type": "application/json",
"status": 500,
"body": {
"error": {
"message": "scripted upstream failure 500",
"type": "server_error",
"code": "500"
}
}
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-n_2_choices",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "gpt-5.6",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "scripted answer"
},
"finish_reason": "stop"
},
{
"index": 1,
"message": {
"role": "assistant",
"content": "second choice"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-finish_reason_length",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "gpt-5.6",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "truncated"
},
"finish_reason": "length"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-stream_usage_in_empty_choices_chunk",
"covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": true,
"stream_options": {
"include_usage": true
}
},
"response": {
"content_type": "text/event-stream",
"frames": [
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"scripted answer\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[],\"usage\":{\"prompt_tokens\":1840,\"completion_tokens\":412,\"total_tokens\":2252}}",
"data: [DONE]"
]
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412,
"cost_header": false
}
},
{
"name": "gpt-5.6-stream_usage_in_last_delta_chunk",
"covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": true,
"stream_options": {
"include_usage": true
}
},
"response": {
"content_type": "text/event-stream",
"frames": [
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"scripted answer\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1840,\"completion_tokens\":412,\"total_tokens\":2252}}",
"data: [DONE]"
]
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412,
"cost_header": false
}
},
{
"name": "gpt-5.6-unknown_model_response_model_unknown",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"deployment": {
"model": "openai/not-in-any-map-xyz"
},
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "not-in-any-map-xyz",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "scripted answer"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"expected": {
"spend": 0.0,
"input_cost": 0.0,
"output_cost": 0.0,
"prompt_tokens": 1840,
"completion_tokens": 412,
"cost_header": false
}
},
{
"name": "gpt-5.6-unknown_model_response_model_known",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "gpt-5.6",
"deployment": {
"model": "openai/not-in-any-map-xyz"
},
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "gpt-5.6",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "scripted answer"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"expected": {
"spend": 0.008988,
"input_cost": 0.00322,
"output_cost": 0.005768,
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-chat_request_to_embedding_entry",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "text-embedding-3-large",
"deployment": {
"model": "openai/text-embedding-3-large"
},
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": false
},
"response": {
"content_type": "application/json",
"body": {
"id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"created": 1789788262,
"model": "text-embedding-3-large",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "scripted answer"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 412,
"total_tokens": 2252
}
}
},
"expected": {
"spend": 0.0002392,
"input_cost": 0.0002392,
"output_cost": 0.0,
"prompt_tokens": 1840,
"completion_tokens": 412
}
},
{
"name": "gpt-5.6-client_disconnect_mid_stream",
"covers": "quota_management.spend_tracking.scripted_wire.client_disconnect",
"model": "gpt-5.6",
"request": {
"model": "$MODEL",
"messages": [
{
"role": "user",
"content": "proxy behaviour probe"
}
],
"stream": true,
"stream_options": {
"include_usage": true
}
},
"response": {
"content_type": "text/event-stream",
"frames": [
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-0\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-1\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-2\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-3\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-4\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-5\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-6\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-7\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-8\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-9\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-10\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-11\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-12\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-13\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-14\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-15\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-16\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-17\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-18\"},\"finish_reason\":null}],\"usage\":null}",
"data: {\"id\":\"chatcmpl-$REQUEST_ID\",\"object\":\"chat.completion.chunk\",\"created\":1789788263,\"model\":\"gpt-5.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"frame-19\"},\"finish_reason\":null}],\"usage\":null}",
"data: [DONE]"
],
"frame_delay_ms": 200
},
"disconnect_after_frames": 3,
"expected": {
"recount": {
"input_cost_per_token": 1.75e-06,
"output_cost_per_token": 1.4e-05
},
"prompt_tokens": 10,
"min_completion_tokens": 9,
"max_completion_tokens": 30
}
}
]
}

View file

@ -5,9 +5,12 @@ from __future__ import annotations
import io
import json
import struct
import time
import uuid
import wave
import zlib
from hashlib import sha256
from itertools import islice
from typing import Final, cast
import httpx
@ -16,10 +19,14 @@ from integration._support.client import JSON_OBJECT, Gateway
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.conftest import (
CostBreakdown,
CostRow,
approx_equal,
assert_total_is_sum_of_components,
poll_cost_row,
poll_failure_row,
poll_rollups,
poll_rows,
read_rows_now,
register_scenario_deployment,
)
from integration.cost_calculation.cost_tracking_case import (
@ -178,11 +185,80 @@ def _assert_breakdown(
)
def _assert_exact(
case: CostTrackingTestCase,
expected: ExactExpected,
row: CostRow,
response: httpx.Response,
) -> None:
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
f"{case.name}: spend {row.spend} != expected {expected.spend} "
f"(breakdown {row.breakdown.model_dump() if row.breakdown is not None else None})"
)
breakdown: Final = row.breakdown
if expected.breakdown_persisted:
assert breakdown is not None, f"{case.name}: no cost_breakdown persisted"
if breakdown is not None:
_assert_breakdown(case, expected, breakdown, response)
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != expected {expected.prompt_tokens}"
)
assert row.completion_tokens == expected.completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} != expected {expected.completion_tokens}"
)
if breakdown is not None:
assert_total_is_sum_of_components(row, breakdown, case.name)
def _assert_recount(case: CostTrackingTestCase, expected: RecountExpected, row: CostRow) -> None:
assert row.prompt_tokens is not None and row.prompt_tokens > 0, (
f"{case.name}: recount case counted no input tokens: prompt_tokens={row.prompt_tokens}"
)
assert row.completion_tokens is not None and row.completion_tokens > 0, (
f"{case.name}: recount case counted no output tokens: completion_tokens={row.completion_tokens}"
)
if expected.prompt_tokens is not None:
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != pinned {expected.prompt_tokens}"
)
if expected.completion_tokens is not None:
assert row.completion_tokens == expected.completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} != pinned {expected.completion_tokens}"
)
if expected.min_completion_tokens is not None:
assert row.completion_tokens >= expected.min_completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} < minimum {expected.min_completion_tokens}"
)
if expected.max_completion_tokens is not None:
assert row.completion_tokens <= expected.max_completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} > maximum {expected.max_completion_tokens}"
)
recount: Final = row.prompt_tokens * expected.recount.input_cost_per_token + (
row.completion_tokens * expected.recount.output_cost_per_token
)
assert row.spend is not None and approx_equal(row.spend, recount), (
f"{case.name}: spend {row.spend} != recount {recount} at map rates"
)
assert row.breakdown is not None, f"{case.name}: no cost_breakdown persisted"
assert_total_is_sum_of_components(row, row.breakdown, case.name)
@pytest.mark.parametrize("case", _CASES)
def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase) -> None:
marker: Final = sha256(case.name.encode()).hexdigest()[:12]
with gateway.scenario() as scenario:
key: Final = scenario.key()
expected: Final = case.expected
team_id: Final = scenario.team() if isinstance(expected, ExactExpected) and expected.rollups else None
user_id: Final = (
scenario.user(team_id=team_id)
if team_id is not None
else None
)
key: Final = (
scenario.key(team_id=team_id, user_id=user_id)
if team_id is not None and user_id is not None
else scenario.key()
)
passthrough_provider: Final = case.passthrough_provider
scenario_id: Final = f"sc-{marker}-{sha256(key.encode()).hexdigest()[:12]}"
scenario_handle: Final = (
@ -192,21 +268,59 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
)
if scenario_handle is not None:
scenario.cleanups.callback(delete_scenario, scenario_handle)
deployment: Final = (
register_scenario_deployment(scenario, case, marker, key)
if passthrough_provider not in {"gemini", "anthropic"}
else None
)
fallback_deployment: Final = (
register_scenario_deployment(
scenario,
case,
marker,
key,
response=case.fallback_from,
marker_suffix="-fb",
)
if case.fallback_from is not None
else None
)
model_name: Final = (
case.model
if passthrough_provider in {"gemini", "anthropic"}
else register_scenario_deployment(scenario, case, marker, key)
else deployment.model_name if deployment is not None else None
)
assert model_name is not None
request_model: Final = (
case.model.rsplit("/", 1)[-1]
if passthrough_provider in {"gemini", "anthropic"}
else model_name
else fallback_deployment.model_name if fallback_deployment is not None else model_name
)
request_body: Final = JSON_OBJECT.validate_python(
base_request_values: Final = (
_replace_model(case.request, request_model)
if passthrough_provider is not None
else {**case.request, "model": model_name}
)
end_user_id: Final = (
f"end-user-{uuid.uuid4()}"
if isinstance(expected, ExactExpected) and expected.rollups
else None
)
request_body: Final = JSON_OBJECT.validate_python(
{
**base_request_values,
**(
{"model": fallback_deployment.model_name, "fallbacks": [model_name]}
if fallback_deployment is not None
else {}
),
**(
{"user": end_user_id, "cache": {"no-cache": True}}
if end_user_id is not None
else {}
),
}
)
request_headers: Final = (
{
"x-pass-x-scripted-scenario": scenario_id,
@ -224,12 +338,35 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
if passthrough_provider is not None
else case.endpoint
)
response: Final = (
_multipart_request(gateway, case, model_name, key)
if case.upload is not None
else gateway.request("POST", request_path, request_body, key=key, headers=request_headers)
if case.disconnect_after_frames is not None:
with gateway.client.stream(
"POST",
request_path,
json=request_body,
headers={"Authorization": f"Bearer {key}", **request_headers},
) as stream_response:
frames: Final = tuple(
islice(
(line for line in stream_response.iter_lines() if line.startswith("data:")),
case.disconnect_after_frames,
)
)
assert len(frames) == case.disconnect_after_frames
row: Final = poll_cost_row(key)
assert isinstance(expected, RecountExpected)
assert row.status == "success", f"{case.name}: disconnect row status was {row.status}"
_assert_recount(case, expected, row)
return
responses: Final = tuple(
(
_multipart_request(gateway, case, model_name, key)
if case.upload is not None
else gateway.request("POST", request_path, request_body, key=key, headers=request_headers)
)
for _ in range(3 if isinstance(expected, ExactExpected) and expected.rollups else 1)
)
if isinstance(case.expected, FailureExpected):
response: Final = responses[0]
if isinstance(expected, FailureExpected):
assert response.status_code == case.expected.failure.status, (
f"{case.name}: proxy returned {response.status_code}, expected {case.expected.failure.status}: "
f"{response.text[:400]}"
@ -244,26 +381,19 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert response.is_success, f"{case.name}: proxy returned {response.status_code}: {response.text[:400]}"
if case.response.content_type == "text/event-stream":
_assert_stream_has_no_error(response.text)
row: Final = poll_cost_row(key)
if isinstance(case.expected, RecountExpected):
assert row.prompt_tokens is not None and row.prompt_tokens > 0, (
f"{case.name}: recount case counted no input tokens: prompt_tokens={row.prompt_tokens}"
)
assert row.completion_tokens is not None and row.completion_tokens > 0, (
f"{case.name}: recount case counted no output tokens: completion_tokens={row.completion_tokens}"
)
recount: Final = row.prompt_tokens * case.expected.recount.input_cost_per_token + (
row.completion_tokens * case.expected.recount.output_cost_per_token
)
assert row.spend is not None and approx_equal(row.spend, recount), (
f"{case.name}: spend {row.spend} != recount {recount} at map rates"
)
breakdown: Final = row.breakdown
assert breakdown is not None, f"{case.name}: no cost_breakdown persisted"
assert_total_is_sum_of_components(row, breakdown, case.name)
rows: Final = poll_rows(key, len(responses))
if isinstance(expected, RecountExpected):
row: Final = rows[0]
_assert_recount(case, expected, row)
return
expected: Final = case.expected
assert isinstance(expected, ExactExpected)
if fallback_deployment is not None:
assert deployment is not None
time.sleep(3)
settled_rows: Final = read_rows_now(key)
assert len(settled_rows) == 1
assert settled_rows[0].status == "success"
assert settled_rows[0].model_id == deployment.identity
if isinstance(case.response, BinaryResponse):
header: Final = response.headers.get("x-litellm-response-cost")
if header is not None:
@ -280,20 +410,30 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert approx_equal(float(header), expected.spend), (
f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
)
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
f"{case.name}: spend {row.spend} != expected {expected.spend} "
f"(breakdown {row.breakdown.model_dump() if row.breakdown is not None else None})"
)
breakdown: Final = row.breakdown
if expected.breakdown_persisted:
assert breakdown is not None, f"{case.name}: no cost_breakdown persisted"
if breakdown is not None:
_assert_breakdown(case, expected, breakdown, response)
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != expected {expected.prompt_tokens}"
)
assert row.completion_tokens == expected.completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} != expected {expected.completion_tokens}"
)
if breakdown is not None:
assert_total_is_sum_of_components(row, breakdown, case.name)
for row in rows:
_assert_exact(case, expected, row, response)
if expected.rollups:
assert deployment is not None and team_id is not None and user_id is not None
assert end_user_id is not None
target_spend: Final = expected.spend * 3
target_requests: Final = 3
rollups: Final = poll_rollups(
key,
team_id,
user_id,
end_user_id,
target_spend,
target_requests,
)
assert approx_equal(rollups.key_spend, target_spend)
assert approx_equal(rollups.team_spend, target_spend)
assert approx_equal(rollups.user_spend, target_spend)
assert approx_equal(rollups.end_user_spend, target_spend)
assert approx_equal(rollups.daily_user.spend, target_spend)
assert approx_equal(rollups.daily_team.spend, target_spend)
assert rollups.daily_user.prompt_tokens == expected.prompt_tokens * 3
assert rollups.daily_user.completion_tokens == expected.completion_tokens * 3
assert rollups.daily_user.api_requests == 3
assert rollups.daily_team.prompt_tokens == expected.prompt_tokens * 3
assert rollups.daily_team.completion_tokens == expected.completion_tokens * 3
assert rollups.daily_team.api_requests == 3