mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
The shared proxy wrapper in tests/e2e/e2e_gateway.py was misnamed: Gateway is not a gateway server, it is the client every suite uses to talk to the proxy (keys, models, chat/embed/ocr, spend read-backs, poll helpers). Rename the module to proxy_client.py and the class to ProxyClient, with build_gateway becoming build_proxy_client and the GatewayProvider protocol becoming ProxyClientProvider. The .gateway attribute suites held is now .proxy. Only identifiers changed; prose and string literals that use the word gateway for the proxy-server concept were left alone. Each suite previously built its own instance through a per-suite build_client() that called build_gateway() inside, duplicating the proxy wiring across suites. There is now one session-scoped proxy fixture in tests/e2e/conftest.py; every suite's client fixture depends on it and injects it, so the wiring lives in one place. claude_code keeps building its own client directly since it has its own harness and does not use the shared fixtures. Behavior is unchanged: shared transport, data-plane/control-plane split routing, poll budget, typed request/response models, and resource cleanup all go through the same object.
242 lines
9.1 KiB
Python
242 lines
9.1 KiB
Python
"""Live e2e: a deployment's custom per-token pricing is loaded, billed, and isolated.
|
|
|
|
Each test registers the deployment(s) it needs through /model/new (deleted on
|
|
teardown) instead of relying on a statically configured model, so the check is
|
|
self-contained and never inherits pricing another suite or a stale config left on
|
|
the shared proxy. custom-priced-flash sets input/output rates deliberately far
|
|
above the canonical gemini price; the isolation sibling shares the same
|
|
gemini/gemini-2.5-flash backend but sets no override. Three behaviors are checked
|
|
independently:
|
|
|
|
- billing: a real call's logged cost breakdown charges input and output tokens at
|
|
the custom rates, each component checked separately (a base-rate bill lands
|
|
~100x lower; a swapped input/output rate passes a total-only check but not this)
|
|
- reporting: /model/info surfaces those rates for the deployment
|
|
- isolation: the sibling keeps its own price; an override that leaks into the
|
|
shared backend cost map (LIT-3897) misprices it, making the sibling's rate match
|
|
the custom one and failing the isolation check
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, RootModel
|
|
|
|
from e2e_config import unique_marker
|
|
from proxy_client import ProxyClient
|
|
from e2e_http import Success, unwrap
|
|
from endpoints_client import EndpointsClient
|
|
from lifecycle import ResourceManager
|
|
from models import (
|
|
ChatBody,
|
|
ChatMessage,
|
|
LiteLLMParamsBody,
|
|
ModelInfoEntry,
|
|
SpendLogsParams,
|
|
)
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
BACKEND_MODEL = "gemini/gemini-2.5-flash"
|
|
GEMINI_API_KEY = "os.environ/GEMINI_API_KEY"
|
|
# Deliberately ~100x above canonical gemini-2.5-flash (input 3e-7 / output 2.5e-6)
|
|
# so an override that is ignored or under-applied bills at the base rate and fails.
|
|
CUSTOM_INPUT_RATE = 5e-05
|
|
CUSTOM_OUTPUT_RATE = 1e-04
|
|
|
|
|
|
class _CostBreakdown(BaseModel):
|
|
input_cost: float | None = None
|
|
output_cost: float | None = None
|
|
|
|
|
|
class _RowMetadata(BaseModel):
|
|
cost_breakdown: _CostBreakdown | None = None
|
|
|
|
|
|
class _SpendRow(BaseModel):
|
|
request_id: str | None = None
|
|
prompt_tokens: int | None = None
|
|
completion_tokens: int | None = None
|
|
metadata: _RowMetadata | None = None
|
|
|
|
|
|
class _SpendRows(RootModel[list[_SpendRow]]):
|
|
pass
|
|
|
|
|
|
def _approx_equal(actual: float, expected: float) -> bool:
|
|
"""Within 1% or 1e-9 absolute - spend math, not exact float identity."""
|
|
return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
|
|
|
|
|
|
def _provision(
|
|
endpoints_client: EndpointsClient,
|
|
resources: ResourceManager,
|
|
prefix: str,
|
|
*,
|
|
input_cost_per_token: float | None,
|
|
output_cost_per_token: float | None,
|
|
) -> str:
|
|
"""Register a fresh gemini/gemini-2.5-flash deployment (deleted on teardown) and
|
|
return its model name. With the cost fields set the deployment carries a custom
|
|
pricing override; with them None it is a plain sibling on the same backend. The
|
|
marker keeps the name unique so concurrent runs on the shared proxy never
|
|
collide."""
|
|
model_name = f"{prefix}-{unique_marker()}"
|
|
model_id = endpoints_client.create_model(
|
|
model_name,
|
|
LiteLLMParamsBody(
|
|
model=BACKEND_MODEL,
|
|
api_key=GEMINI_API_KEY,
|
|
input_cost_per_token=input_cost_per_token,
|
|
output_cost_per_token=output_cost_per_token,
|
|
),
|
|
)
|
|
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
|
return model_name
|
|
|
|
|
|
def _provision_custom_priced(
|
|
endpoints_client: EndpointsClient, resources: ResourceManager
|
|
) -> str:
|
|
return _provision(
|
|
endpoints_client,
|
|
resources,
|
|
"custom-priced-flash",
|
|
input_cost_per_token=CUSTOM_INPUT_RATE,
|
|
output_cost_per_token=CUSTOM_OUTPUT_RATE,
|
|
)
|
|
|
|
|
|
def _model_info_entry(entries: list[ModelInfoEntry], model_name: str) -> ModelInfoEntry:
|
|
for entry in entries:
|
|
if entry.model_name == model_name:
|
|
return entry
|
|
pytest.fail(f"{model_name} absent from /model/info; the override did not load")
|
|
|
|
|
|
def _poll_breakdown_row(proxy: ProxyClient, key: str, response_id: str | None) -> _SpendRow:
|
|
"""Poll /spend/logs until the call's row lands with a cost breakdown (rows
|
|
flush ~60s behind the call via proxy_batch_write_at)."""
|
|
deadline = time.monotonic() + proxy.poll_timeout
|
|
while time.monotonic() < deadline:
|
|
result = proxy.transport.get(
|
|
"/spend/logs",
|
|
headers=proxy.transport.master,
|
|
params=SpendLogsParams(api_key=key),
|
|
response_type=_SpendRows,
|
|
)
|
|
match result:
|
|
case Success(data=data):
|
|
rows = data.root
|
|
case _:
|
|
rows = []
|
|
priced = [
|
|
row
|
|
for row in rows
|
|
if row.metadata
|
|
and row.metadata.cost_breakdown
|
|
and row.metadata.cost_breakdown.input_cost is not None
|
|
]
|
|
for row in priced:
|
|
if response_id and row.request_id == response_id:
|
|
return row
|
|
if priced and response_id is None:
|
|
return priced[0]
|
|
time.sleep(proxy.poll_interval)
|
|
pytest.fail("no spend row with a cost breakdown landed before the deadline")
|
|
|
|
|
|
class TestCustomPricing:
|
|
def test_custom_pricing_is_billed_at_configured_rate(
|
|
self,
|
|
endpoints_client: EndpointsClient,
|
|
resources: ResourceManager,
|
|
scoped_key: str,
|
|
) -> None:
|
|
model = _provision_custom_priced(endpoints_client, resources)
|
|
|
|
chat = unwrap(
|
|
endpoints_client.proxy.chat(
|
|
scoped_key,
|
|
ChatBody(
|
|
model=model,
|
|
messages=[
|
|
ChatMessage(
|
|
role="user", content=f"reply with one word {unique_marker()}"
|
|
)
|
|
],
|
|
max_tokens=16,
|
|
),
|
|
)
|
|
)
|
|
|
|
row = _poll_breakdown_row(endpoints_client.proxy, scoped_key, chat.id)
|
|
assert row.metadata and row.metadata.cost_breakdown # guaranteed by the poll
|
|
breakdown = row.metadata.cost_breakdown
|
|
|
|
prompt = row.prompt_tokens or 0
|
|
completion = row.completion_tokens or 0
|
|
assert prompt > 0 and completion > 0, f"call tokens not logged on the row: {row}"
|
|
|
|
input_cost = breakdown.input_cost
|
|
output_cost = breakdown.output_cost
|
|
assert input_cost is not None and output_cost is not None, (
|
|
f"row cost breakdown missing input/output cost: {breakdown}"
|
|
)
|
|
assert _approx_equal(input_cost, prompt * CUSTOM_INPUT_RATE), (
|
|
f"input_cost {input_cost} != {prompt} tokens * {CUSTOM_INPUT_RATE} "
|
|
f"= {prompt * CUSTOM_INPUT_RATE}"
|
|
)
|
|
assert _approx_equal(output_cost, completion * CUSTOM_OUTPUT_RATE), (
|
|
f"output_cost {output_cost} != {completion} tokens * {CUSTOM_OUTPUT_RATE} "
|
|
f"= {completion * CUSTOM_OUTPUT_RATE}"
|
|
)
|
|
|
|
def test_model_info_reports_custom_pricing(
|
|
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
|
) -> None:
|
|
model = _provision_custom_priced(endpoints_client, resources)
|
|
entry = _model_info_entry(endpoints_client.proxy.model_info(), model)
|
|
|
|
assert entry.litellm_params.input_cost_per_token == CUSTOM_INPUT_RATE, (
|
|
f"/model/info litellm_params input rate "
|
|
f"{entry.litellm_params.input_cost_per_token} != configured {CUSTOM_INPUT_RATE}"
|
|
)
|
|
assert entry.litellm_params.output_cost_per_token == CUSTOM_OUTPUT_RATE, (
|
|
f"/model/info litellm_params output rate "
|
|
f"{entry.litellm_params.output_cost_per_token} != configured {CUSTOM_OUTPUT_RATE}"
|
|
)
|
|
|
|
def test_custom_pricing_is_isolated_from_sibling_deployment(
|
|
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
|
) -> None:
|
|
# Register the override first so its rate is in the backend cost map before
|
|
# the sibling resolves; a leak (LIT-3897) would then poison the sibling.
|
|
custom = _provision_custom_priced(endpoints_client, resources)
|
|
sibling = _provision(
|
|
endpoints_client,
|
|
resources,
|
|
"base-flash",
|
|
input_cost_per_token=None,
|
|
output_cost_per_token=None,
|
|
)
|
|
|
|
entries = {entry.model_name: entry for entry in endpoints_client.proxy.model_info()}
|
|
custom_entry = entries.get(custom)
|
|
sibling_entry = entries.get(sibling)
|
|
assert custom_entry is not None, f"{custom} absent from /model/info"
|
|
assert sibling_entry is not None, f"{sibling} absent from /model/info"
|
|
|
|
# custom-priced-flash overrides pricing; the sibling shares the same
|
|
# gemini/gemini-2.5-flash backend but sets no override, so it must keep its
|
|
# own price. Equal rates mean the override leaked into the shared cost map.
|
|
assert (
|
|
sibling_entry.model_info.input_cost_per_token
|
|
!= custom_entry.model_info.input_cost_per_token
|
|
), (
|
|
f"{sibling} input rate {sibling_entry.model_info.input_cost_per_token} matches "
|
|
f"{custom}'s override {custom_entry.model_info.input_cost_per_token}; "
|
|
f"per-deployment custom pricing is not isolated"
|
|
)
|