mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
test(e2e): retire the quiet_stack lock and batch the claude_code model registration (#49984)
* test(e2e): retire the quiet_stack lock quiet_stack held an exclusive host-wide lock for six tests, draining every other xdist worker while they ran. On the full e2e lane that left 35-38% of worker time idle. Each of those tests now lives where it can run without pausing the suite - the two memory tests move to perf/ behind the perf marker, so the perf lane runs them on its own fresh stack. Their refusing deployments use a literal key because the perf stack carries no provider keys - the stored-request size check from the memory test becomes an integration test on an owned proxy that stores prompts - the discount and margin spend-row tests are dropped from e2e. Their breakdown assertions join the existing integration pricing test, which already owns the discount and margin repricing flow - the Bedrock guardrail cost test stays in e2e without the pricing reset fixture, since nothing else on the stack writes pricing config now - the MCP semantic tool filter test turns on a proxy-wide setting, so it gets a new proxy_wide_config opt-in marker and runs after the main run * test(e2e): register the claude_code deployments as one batch The claude_code session fixture registered its 24 deployments one at a time on every xdist worker, and each one waited out the 15 s propagation window on its own, about 6 minutes per worker. ProxyClient.register_models posts the whole batch, polls each model until every replica serves it, then waits out propagation once from the last write. A model that fails to post or never becomes servable comes back as ModelNotRegistered, and an unservable one is deleted, so the fixture keeps reporting per-deployment failures the way it did
This commit is contained in:
parent
df78b16941
commit
b18188dee2
20 changed files with 359 additions and 471 deletions
File diff suppressed because one or more lines are too long
|
|
@ -597,14 +597,21 @@ def _build_control_plane_client(proxy_config: ProxyConfig):
|
|||
)
|
||||
|
||||
|
||||
def _register_deployment(proxy, deployment: CompatDeployment) -> str:
|
||||
"""Register one deployment and return its proxy-assigned model_id
|
||||
once it is servable on the data plane. The aliases are shared by every
|
||||
cell and, under xdist, by every worker, so no call to them belongs to
|
||||
one test and none is cached: the matrix exists to reach real providers."""
|
||||
return proxy.create_model(
|
||||
deployment.model_name,
|
||||
deployment.litellm_params,
|
||||
def _register_deployments(proxy, deployments: tuple[CompatDeployment, ...]):
|
||||
"""The aliases are shared by every cell and, under xdist, by every worker,
|
||||
so no call to them belongs to one test and none is cached: the matrix
|
||||
exists to reach real providers."""
|
||||
from models import ModelInfoBody, ModelNewBody
|
||||
|
||||
return proxy.register_models(
|
||||
tuple(
|
||||
ModelNewBody(
|
||||
model_name=deployment.model_name,
|
||||
litellm_params=deployment.litellm_params,
|
||||
model_info=ModelInfoBody(),
|
||||
)
|
||||
for deployment in deployments
|
||||
),
|
||||
provider_live=True,
|
||||
)
|
||||
|
||||
|
|
@ -632,18 +639,14 @@ def _compat_models_registered() -> Any:
|
|||
yield
|
||||
return
|
||||
|
||||
from proxy_client import ModelNotRegistered, ModelRegistered
|
||||
from requests import RequestException
|
||||
|
||||
proxy = _build_control_plane_client(proxy_config)
|
||||
registered_ids: list[str] = []
|
||||
failures: list[tuple[str, str]] = []
|
||||
registrations = _register_deployments(proxy, load_all_deployments())
|
||||
registered_ids = tuple(r.model_id for r in registrations if isinstance(r, ModelRegistered))
|
||||
failures = tuple((r.model_name, r.reason) for r in registrations if isinstance(r, ModelNotRegistered))
|
||||
try:
|
||||
for deployment in load_all_deployments():
|
||||
try:
|
||||
model_id = _register_deployment(proxy, deployment)
|
||||
registered_ids.append(model_id)
|
||||
except (AssertionError, RequestException) as exc:
|
||||
failures.append((deployment.model_name, str(exc)))
|
||||
if failures:
|
||||
summary = "\n".join(
|
||||
f" - {name}: {reason}" for name, reason in failures
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from e2e_config import (
|
|||
PROMPT_CACHING_OPT_IN_ENV,
|
||||
PROVIDER_EDGE_HOST_OPT_IN_ENV,
|
||||
PROXY_BASE_URL,
|
||||
PROXY_WIDE_CONFIG_OPT_IN_ENV,
|
||||
SAGEMAKER_NOVA2_LITE_OPT_IN_ENV,
|
||||
SAGEMAKER_NOVA_OPT_IN_ENV,
|
||||
SECRET_MANAGER_OPT_IN_ENV,
|
||||
|
|
@ -54,7 +55,6 @@ from models import TeamNewBody, UserNewBody, UserNewResponse
|
|||
from provider_cache_routing import LIVE_PROVIDER_REQUIRED
|
||||
from provider_edge import replay_leftover_error
|
||||
from proxy_client import ProxyClient, build_proxy_client
|
||||
from stack_lock import stack_lock
|
||||
|
||||
_E2E_TEST_RAN = pytest.StashKey[bool]()
|
||||
_CALL_PASSED = pytest.StashKey[bool]()
|
||||
|
|
@ -77,6 +77,7 @@ OPT_IN_MARKERS: Final = MappingProxyType(
|
|||
"secret_manager": SECRET_MANAGER_OPT_IN_ENV,
|
||||
"sagemaker_nova": SAGEMAKER_NOVA_OPT_IN_ENV,
|
||||
"sagemaker_nova2_lite": SAGEMAKER_NOVA2_LITE_OPT_IN_ENV,
|
||||
"proxy_wide_config": PROXY_WIDE_CONFIG_OPT_IN_ENV,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -162,11 +163,6 @@ def pytest_configure(config: pytest.Config) -> None:
|
|||
"redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from "
|
||||
"gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_PERF_TESTS is set",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; "
|
||||
"every other test waits for it to finish",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the "
|
||||
|
|
@ -200,6 +196,11 @@ def pytest_configure(config: pytest.Config) -> None:
|
|||
"sagemaker_nova2_lite: needs a deployed SageMaker Nova 2 Lite endpoint the proxy's AWS credentials can invoke; "
|
||||
"deselected unless SAGEMAKER_NOVA2_LITE_ENDPOINT names it",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"proxy_wide_config: turns on a proxy-wide setting through /config/update, so it runs on a stack no other suite "
|
||||
"is driving; deselected unless E2E_PROXY_WIDE_CONFIG_STACK is set",
|
||||
)
|
||||
|
||||
|
||||
def pytest_sessionstart(session: pytest.Session) -> None:
|
||||
|
|
@ -269,7 +270,7 @@ def pytest_collection_finish(session: pytest.Session) -> None:
|
|||
ahead of xdist's own collection-finish report, and the controller schedules no
|
||||
test until every worker has reported, so this is the idle footprint of a stack
|
||||
that just passed its readiness gate. The fixture hands the capture to the
|
||||
idle-budget test in router/test_reliability_memory_e2e.py."""
|
||||
idle-budget test in perf/test_reliability_memory_e2e.py."""
|
||||
if session.config.getoption("collectonly") or not any(_uses_idle_rss(item) for item in session.items):
|
||||
return
|
||||
session.config.stash[_IDLE_RSS] = read_rss_everywhere(build_proxy_client(), timeout=IDLE_RSS_READ_TIMEOUT_SECONDS)
|
||||
|
|
@ -299,12 +300,6 @@ def _proxy_fail_reason() -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
@pytest.hookimpl(wrapper=True)
|
||||
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None, object, object]:
|
||||
with stack_lock(exclusive=item.get_closest_marker("quiet_stack") is not None):
|
||||
return (yield)
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_runtest_setup(item: pytest.Item) -> None:
|
||||
"""Hard-fail `e2e`-marked tests unless a proxy answers its liveness probe.
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@
|
|||
- {id: quota_management.spend_tracking.messages_bridge.logs_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: messages_bridge, assertions: [logs_cost], exercised_on: [messages], source: "llms/anthropic/messages/responses_adapters/handler.py", rationale: "A streaming /v1/messages request served by an openai-provider model is bridged through the anthropic-messages -> Responses adapter and must aggregate the consumed SSE stream into one spend row with nonzero cost and token counts, attributed to custom_llm_provider openai under call_type anthropic_messages"}
|
||||
- {id: quota_management.spend_tracking.embeddings.logs_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: embeddings, assertions: [logs_cost], exercised_on: [embeddings], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Embedding calls write nonzero spend rows"}
|
||||
- {id: quota_management.spend_tracking.cache_hit.zero_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: cache_hit, assertions: [zero_cost], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "A response-cache hit logs at zero cost with the cache-hit marker"}
|
||||
- {id: quota_management.spend_tracking.discount_config.logs_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: discount_config, assertions: [logs_cost], exercised_on: [chat_completions], source: "proxy/management_endpoints/cost_tracking_settings.py + cost_calculator.py", rationale: "A persisted provider discount changes the real request's spend row"}
|
||||
- {id: quota_management.spend_tracking.margin_config.logs_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: margin_config, assertions: [logs_cost], exercised_on: [chat_completions], source: "proxy/management_endpoints/cost_tracking_settings.py + cost_calculator.py", rationale: "A persisted provider margin is added to the real request's spend row"}
|
||||
- {id: quota_management.spend_tracking.guardrail_cost.logs_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: guardrail_cost, assertions: [logs_cost], exercised_on: [chat_completions], source: "litellm_core_utils/llm_cost_calc/guardrail_cost.py + proxy/spend_tracking/spend_tracking_utils.py", rationale: "A priced Bedrock guardrail charge reaches the persisted spend row"}
|
||||
- {id: quota_management.spend_tracking.key_rollup.matches_sum_of_logs, module: quota_management, tier: P1, behavior: spend_tracking, variant: key_rollup, assertions: [matches_sum_of_logs], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "A key's rolled-up spend equals the sum of its log rows"}
|
||||
- {id: quota_management.spend_tracking.concurrent_burst.loses_no_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: concurrent_burst, assertions: [loses_no_spend], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "Concurrent calls all land as spend; no row lost to write contention"}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ OTEL_TLS_OPT_IN_ENV: Final = "E2E_OTEL_EXPORTER_ENDPOINT"
|
|||
SECRET_MANAGER_OPT_IN_ENV: Final = "E2E_SECRET_MANAGER"
|
||||
SAGEMAKER_NOVA_OPT_IN_ENV: Final = "SAGEMAKER_NOVA_ENDPOINT"
|
||||
SAGEMAKER_NOVA2_LITE_OPT_IN_ENV: Final = "SAGEMAKER_NOVA2_LITE_ENDPOINT"
|
||||
PROXY_WIDE_CONFIG_OPT_IN_ENV: Final = "E2E_PROXY_WIDE_CONFIG_STACK"
|
||||
ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6"))
|
||||
ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6"))
|
||||
ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3"))
|
||||
|
|
@ -234,7 +235,6 @@ MEMORY_RSS_SETTLE_SAMPLES = int(os.environ.get("E2E_MEMORY_RSS_SETTLE_SAMPLES",
|
|||
MEMORY_RSS_SAMPLE_INTERVAL_SECONDS = float(os.environ.get("E2E_MEMORY_RSS_SAMPLE_INTERVAL_SECONDS", "1"))
|
||||
MEMORY_RSS_CEILING_MB = float(os.environ.get("E2E_MEMORY_RSS_CEILING_MB", "200"))
|
||||
MEMORY_IDLE_RSS_BUDGET_MB = float(os.environ.get("E2E_MEMORY_IDLE_RSS_BUDGET_MB", "768"))
|
||||
MEMORY_STORED_REQUEST_BUDGET_KB = float(os.environ.get("E2E_MEMORY_STORED_REQUEST_BUDGET_KB", "64"))
|
||||
|
||||
|
||||
def ws_base_url() -> str:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ embedding deployment, and turns on `litellm_settings.mcp_semantic_tool_filter` t
|
|||
/config/update. A chat completion that attaches the server and asks to search logs must report,
|
||||
in the x-litellm-semantic-filter headers, that the catalog was cut to at most top_k tools, all
|
||||
of them tools the key lists. Requires DD_API_KEY + DD_APP_KEY and the proxy's
|
||||
OPENAI_API_KEY for the embedding deployment.
|
||||
OPENAI_API_KEY for the embedding deployment. The filter is proxy-wide, so the test is
|
||||
marked `proxy_wide_config` and runs on a stack the main suite is not driving.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,7 +24,7 @@ from mcp_client import McpClient
|
|||
from models import ChatBody, ChatMessage, LiteLLMParamsBody, McpChatTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.quiet_stack]
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.proxy_wide_config]
|
||||
|
||||
TOP_K: Final = 3
|
||||
|
||||
|
|
|
|||
|
|
@ -1262,27 +1262,6 @@ class CostMap(RootModel[dict[str, CostMapEntry]]):
|
|||
pass
|
||||
|
||||
|
||||
class ConfigPatchResponse(BaseModel):
|
||||
status: str
|
||||
values: dict[str, float | dict[str, float]]
|
||||
|
||||
|
||||
class CostDiscountConfig(RootModel[dict[str, float]]):
|
||||
pass
|
||||
|
||||
|
||||
class CostMarginConfig(RootModel[dict[str, float | dict[str, float]]]):
|
||||
pass
|
||||
|
||||
|
||||
class CostDiscountConfigResponse(BaseModel):
|
||||
values: dict[str, float]
|
||||
|
||||
|
||||
class CostMarginConfigResponse(BaseModel):
|
||||
values: dict[str, float | dict[str, float]]
|
||||
|
||||
|
||||
class BedrockGuardrailParams(BaseModel):
|
||||
guardrail: Literal["bedrock"] = "bedrock"
|
||||
mode: Literal["pre_call"] = "pre_call"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from setup.run import run_plan
|
|||
from setup.upgrade import assert_finished, monitor_for, upgrade_case, upgrade_properties
|
||||
from setup.upgrade_worker import connect, read_state
|
||||
|
||||
pytestmark = [pytest.mark.perf, pytest.mark.quiet_stack]
|
||||
pytestmark = pytest.mark.perf
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.environ.get("E2E_DATABASE_UPGRADE") != "1", reason="Dedicated disposable snapshot lane only")
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ from setup.ramp import step_properties
|
|||
from setup.run import run_plan
|
||||
from setup.telemetry import read_gateway_telemetry, telemetry_sampler
|
||||
|
||||
pytestmark = [pytest.mark.perf, pytest.mark.quiet_stack]
|
||||
pytestmark = pytest.mark.perf
|
||||
_SPEND_SAMPLE: Final = 40
|
||||
_WARMUP_SECONDS: Final = 60
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
"""Live e2e: a few hundred requests that fail before any provider answers must not
|
||||
grow the proxy's resident memory past a loose ceiling once the proxy is warm.
|
||||
grow the proxy's resident memory past a loose ceiling once the proxy is warm. It reads
|
||||
the proxy's own RSS, so it runs in the perf lane, where nothing else is hitting the stack.
|
||||
|
||||
The regression this guards shipped in v1.100.0: every retry breadcrumb copied the
|
||||
whole request and the copies nested into one router-global list, so a proxy under
|
||||
retry-heavy failing traffic grew until it was OOM-killed. The traffic here has that
|
||||
shape: a model group whose deployments refuse at the socket (an unreachable base
|
||||
URL) with cooldown_time 0 so the router keeps retrying them, per-request retries,
|
||||
and a fallback group that refuses the same way, each request carrying a long chat
|
||||
URL, with a literal key since the perf stack carries no provider keys) with
|
||||
cooldown_time 0 so the router keeps retrying them, per-request retries, and a
|
||||
fallback group that refuses the same way, each request carrying a long chat
|
||||
transcript so every whole-request copy costs hundreds of containers instead of a
|
||||
handful. Under the stack's cooldown policy a
|
||||
deployment that fails a handful of times in a row is benched (a bad-credential 401
|
||||
included), the router answers "No deployments available" without retrying, and the
|
||||
retry loop that leaks stops running; cooldown_time 0 keeps it running.
|
||||
handful. Under the stack's cooldown policy a deployment that fails a handful of
|
||||
times in a row is benched (a bad-credential 401 included), the router answers "No
|
||||
deployments available" without retrying, and the retry loop that leaks stops running;
|
||||
cooldown_time 0 keeps it running.
|
||||
|
||||
Four identical batches run back to back. The first two are the warmup that grows
|
||||
the proxy's caches and allocator arenas, the last two are the measured window, and
|
||||
|
|
@ -35,19 +37,11 @@ single load-balanced address reaches the workers behind it one answer at a time,
|
|||
a worker that answered only one checkpoint would otherwise drop out of the
|
||||
comparison, which is where a leaking worker could hide.
|
||||
|
||||
RSS alone is a coarse gauge: on the release stack (spend logs storing prompts,
|
||||
json logs, prometheus and otel callbacks) the same v1.100.0 breadcrumbs grew RSS
|
||||
by only about 15 MB per 300 failing requests, while every failing request's stored
|
||||
request snapshot carried a copy of the request per failed attempt, over 100 KB on
|
||||
the first call and a couple of MB once the copies nested, against tens of KB with
|
||||
the fix. So the first check sends one failing request before the phases, reads its
|
||||
spend log back through /spend/logs, and holds the stored request body to a fixed
|
||||
size budget: the deterministic catch for a breadcrumb that copies the whole
|
||||
request. It runs before the phases because the leaking writer drops its own rows
|
||||
under the phases' traffic (a queue budget hit, a recursion limit on the nested
|
||||
copies), which would turn the size check into a missing-row check.
|
||||
The stored-request half of that regression (every failing request's spend log carrying a
|
||||
copy of the request per failed attempt) is a deterministic size check, so it lives in
|
||||
tests/integration/spend/test_failing_request_stored_snapshot.py.
|
||||
|
||||
A second, cheaper check holds the idle footprint: every worker's RSS as the harness
|
||||
A cheaper check holds the idle footprint: every worker's RSS as the harness
|
||||
read it at collection time, before this pytest process sent any traffic (see
|
||||
conftest.pytest_collection_finish), must sit under a fixed budget. On the
|
||||
release gate that is a fresh stack right after its readiness gate, one worker per
|
||||
|
|
@ -74,7 +68,6 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from complexity_router_client import ComplexityRouterClient
|
||||
from e2e_config import (
|
||||
MEMORY_CONCURRENCY,
|
||||
MEMORY_IDLE_RSS_BUDGET_MB,
|
||||
|
|
@ -83,7 +76,6 @@ from e2e_config import (
|
|||
MEMORY_RSS_CEILING_MB,
|
||||
MEMORY_RSS_SAMPLE_INTERVAL_SECONDS,
|
||||
MEMORY_RSS_SETTLE_SAMPLES,
|
||||
MEMORY_STORED_REQUEST_BUDGET_KB,
|
||||
MEMORY_TRANSCRIPT_TURNS,
|
||||
PROXY_REPLICA_URLS,
|
||||
unique_marker,
|
||||
|
|
@ -91,11 +83,12 @@ from e2e_config import (
|
|||
from e2e_metadata import Domain, Mode, Provider, Subject, meta
|
||||
from lifecycle import ResourceManager
|
||||
from memory_readings import RssCapture, RssReading, WorkerKey, read_rss_everywhere
|
||||
from models import ChatMessage, RouterSettingsOverride, SpendLogRow
|
||||
from models import ChatMessage, LiteLLMParamsBody, ReliabilityChatBody, RouterSettingsOverride
|
||||
from proxy_client import ProxyClient
|
||||
from reliability_support import REAL_MODEL, chat_override, create_never_benched_refusing_deployment
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.quiet_stack]
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.perf]
|
||||
|
||||
REFUSING_MODEL: Final = "openai/gpt-5.5"
|
||||
|
||||
DEPLOYMENTS_PER_GROUP: Final = 2
|
||||
RSS_SAMPLE_CAP: Final = 4 * MEMORY_RSS_SETTLE_SAMPLES
|
||||
|
|
@ -110,7 +103,6 @@ class FailedCall:
|
|||
status_code: int
|
||||
seconds: float
|
||||
body_head: str
|
||||
call_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -123,8 +115,17 @@ class WorkerGrowth:
|
|||
return self.after.ram_usage_mb - self.warm.ram_usage_mb
|
||||
|
||||
|
||||
def _create_never_benched_refusing_deployment(proxy: ProxyClient, name: str) -> str:
|
||||
return proxy.create_model(
|
||||
name,
|
||||
LiteLLMParamsBody(
|
||||
model=REFUSING_MODEL, api_key="sk-unreachable", api_base="http://127.0.0.1:9/v1", cooldown_time=0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _register_refusing_group(proxy: ProxyClient, resources: ResourceManager, name: str) -> None:
|
||||
for model_id in tuple(create_never_benched_refusing_deployment(proxy, name) for _ in range(DEPLOYMENTS_PER_GROUP)):
|
||||
for model_id in tuple(_create_never_benched_refusing_deployment(proxy, name) for _ in range(DEPLOYMENTS_PER_GROUP)):
|
||||
resources.defer(lambda model_id=model_id: proxy.delete_model(model_id))
|
||||
|
||||
|
||||
|
|
@ -141,10 +142,18 @@ TRANSCRIPT: Final = _transcript(MEMORY_TRANSCRIPT_TURNS)
|
|||
|
||||
def _fail_once(proxy: ProxyClient, key: str, model: str, override: RouterSettingsOverride) -> FailedCall:
|
||||
started: Final = time.perf_counter()
|
||||
resp: Final = chat_override(
|
||||
proxy, key, model, f"memory regression {unique_marker()}", override=override, history=TRANSCRIPT
|
||||
resp: Final = proxy.transport.send(
|
||||
"/chat/completions",
|
||||
headers=proxy.transport.bearer(key),
|
||||
json=ReliabilityChatBody(
|
||||
model=model,
|
||||
messages=[*TRANSCRIPT, ChatMessage(role="user", content=f"memory regression {unique_marker()}")],
|
||||
max_tokens=512,
|
||||
router_settings_override=override,
|
||||
cache={"no-cache": True},
|
||||
),
|
||||
)
|
||||
return FailedCall(resp.status_code, time.perf_counter() - started, resp.body[:300], resp.call_id)
|
||||
return FailedCall(resp.status_code, time.perf_counter() - started, resp.body[:300])
|
||||
|
||||
|
||||
def _fail_many(proxy: ProxyClient, key: str, model: str, override: RouterSettingsOverride) -> tuple[FailedCall, ...]:
|
||||
|
|
@ -259,24 +268,6 @@ def _assert_every_call_failed_through_fallback(calls: Sequence[FailedCall], fall
|
|||
)
|
||||
|
||||
|
||||
def _stored_request_kb(proxy: ProxyClient, call: FailedCall) -> float:
|
||||
assert call.call_id, (
|
||||
f"the failing call carried no x-litellm-call-id header, so its spend log cannot be read back: {call.body_head}"
|
||||
)
|
||||
rows: Final[Sequence[SpendLogRow]] = proxy.poll_logs_for_request_id(call.call_id)
|
||||
assert rows, (
|
||||
f"no spend log row appeared for failing call {call.call_id} within the poll window: either the stack "
|
||||
"writes no spend logs or its writer dropped the row, which the v1.100.0 one did once the stored "
|
||||
"request outgrew the writer's queue budget"
|
||||
)
|
||||
snapshot: Final = rows[0].proxy_server_request
|
||||
assert snapshot, (
|
||||
f"spend log {call.call_id} stored no request body, so the stack is not running with "
|
||||
"general_settings.store_prompts_in_spend_logs and the stored-request check would pass vacuously"
|
||||
)
|
||||
return len(json.dumps(snapshot).encode()) / 1024
|
||||
|
||||
|
||||
class TestReliabilityMemory:
|
||||
@pytest.mark.covers("reliability.perf.idle_memory.under_slo")
|
||||
@meta(
|
||||
|
|
@ -309,13 +300,13 @@ class TestReliabilityMemory:
|
|||
Subject(
|
||||
domain=Domain.ROUTING,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(REAL_MODEL,),
|
||||
models=(REFUSING_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_failing_requests_do_not_grow_rss_or_stored_request(
|
||||
def test_failing_requests_do_not_grow_rss(
|
||||
self,
|
||||
client: ComplexityRouterClient,
|
||||
proxy: ProxyClient,
|
||||
resources: ResourceManager,
|
||||
scoped_key: str,
|
||||
record_property: Callable[[str, object], None],
|
||||
|
|
@ -323,35 +314,25 @@ class TestReliabilityMemory:
|
|||
marker: Final = unique_marker()
|
||||
primary: Final = f"reliability-memory-{marker}"
|
||||
fallback: Final = f"reliability-memory-fb-{marker}"
|
||||
_register_refusing_group(client.proxy, resources, primary)
|
||||
_register_refusing_group(client.proxy, resources, fallback)
|
||||
_register_refusing_group(proxy, resources, primary)
|
||||
_register_refusing_group(proxy, resources, fallback)
|
||||
override: Final = RouterSettingsOverride(
|
||||
num_retries=MEMORY_RETRIES_PER_REQUEST, fallbacks=[{primary: [fallback]}]
|
||||
)
|
||||
|
||||
probe: Final = _fail_once(client.proxy, scoped_key, primary, override)
|
||||
_assert_every_call_failed_through_fallback((probe,), fallback)
|
||||
stored_kb: Final = _stored_request_kb(client.proxy, probe)
|
||||
assert stored_kb <= MEMORY_STORED_REQUEST_BUDGET_KB, (
|
||||
f"the spend log of one failing request stored a {stored_kb:.0f} KB request body, past the "
|
||||
f"{MEMORY_STORED_REQUEST_BUDGET_KB:.0f} KB budget for a {len(TRANSCRIPT)}-message transcript with "
|
||||
f"{MEMORY_RETRIES_PER_REQUEST} retries and a fallback; the retry breadcrumbs are copying the whole "
|
||||
f"request into the stored snapshot the way the v1.100.0 ones did"
|
||||
)
|
||||
|
||||
warm: Final = _settled_rss_after_batches(
|
||||
client.proxy,
|
||||
proxy,
|
||||
scoped_key,
|
||||
primary,
|
||||
override,
|
||||
fallback,
|
||||
record_property,
|
||||
_settled_rss_per_worker(client.proxy),
|
||||
_settled_rss_per_worker(proxy),
|
||||
1,
|
||||
WARMUP_BATCHES,
|
||||
)
|
||||
after: Final = _settled_rss_after_batches(
|
||||
client.proxy,
|
||||
proxy,
|
||||
scoped_key,
|
||||
primary,
|
||||
override,
|
||||
|
|
@ -33,7 +33,6 @@ from transport import HttpTransport
|
|||
|
||||
pytestmark = [
|
||||
pytest.mark.perf,
|
||||
pytest.mark.quiet_stack,
|
||||
pytest.mark.skipif(os.environ.get("E2E_UI_PERF_DISPOSABLE") != "1", reason="Dedicated disposable database only"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
|
|
@ -155,6 +155,21 @@ class NotServableOn:
|
|||
last_result: Result[ModelsListResponse] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelRegistered:
|
||||
model_name: str
|
||||
model_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelNotRegistered:
|
||||
model_name: str
|
||||
reason: str
|
||||
|
||||
|
||||
ModelRegistration = ModelRegistered | ModelNotRegistered
|
||||
|
||||
|
||||
def await_servable(
|
||||
list_models: Callable[[float], Result[ModelsListResponse]],
|
||||
*,
|
||||
|
|
@ -461,6 +476,7 @@ class ProxyClient:
|
|||
model_servable_db_sync_seconds: float = MODEL_SERVABLE_DB_SYNC_SECONDS
|
||||
model_servable_interval: float = MODEL_SERVABLE_INTERVAL
|
||||
model_servable_request_timeout: float = MODEL_SERVABLE_REQUEST_TIMEOUT
|
||||
settle: Callable[[float], None] = settle_propagation
|
||||
|
||||
def with_caller(self, caller: Caller) -> ProxyClient:
|
||||
return replace(self, caller=caller)
|
||||
|
|
@ -694,31 +710,59 @@ class ProxyClient:
|
|||
balancer address is configured (every request opens a fresh connection, so
|
||||
the caller's next request re-rolls), so waiting out PROPAGATION_TIMEOUT is
|
||||
what makes the model safe to use anywhere."""
|
||||
model_id = unwrap(
|
||||
self.transport.post(
|
||||
"/model/new",
|
||||
headers=self.management_headers(),
|
||||
json=body.model_copy(update={"litellm_params": route_cache_model(
|
||||
body.litellm_params, provider_edge_base,
|
||||
enabled=os.environ.get("E2E_PROVIDER_CACHE", "0") == "1" and not provider_live,
|
||||
mode=body.model_info.mode,
|
||||
)}),
|
||||
response_type=ModelNewResponse,
|
||||
)
|
||||
).model_id
|
||||
model_id = unwrap(self._post_model(body, provider_live=provider_live)).model_id
|
||||
written_at = time.monotonic()
|
||||
try:
|
||||
self._await_model_servable(body.model_name, listed_for)
|
||||
except BaseException:
|
||||
self.delete_model(model_id)
|
||||
raise
|
||||
settle_propagation(written_at)
|
||||
self.settle(written_at)
|
||||
return model_id
|
||||
|
||||
@step("Add a batch of deployments, then wait once for all of them to propagate")
|
||||
def register_models(
|
||||
self, bodies: Sequence[ModelNewBody], *, provider_live: bool = False
|
||||
) -> tuple[ModelRegistration, ...]:
|
||||
posted: Final = tuple((body.model_name, self._post_model(body, provider_live=provider_live)) for body in bodies)
|
||||
written_at: Final = time.monotonic()
|
||||
registrations: Final = tuple(self._servable_registration(name, result) for name, result in posted)
|
||||
self.settle(written_at)
|
||||
return registrations
|
||||
|
||||
def _post_model(self, body: ModelNewBody, *, provider_live: bool) -> Result[ModelNewResponse]:
|
||||
return self.transport.post(
|
||||
"/model/new",
|
||||
headers=self.management_headers(),
|
||||
json=body.model_copy(update={"litellm_params": route_cache_model(
|
||||
body.litellm_params, provider_edge_base,
|
||||
enabled=os.environ.get("E2E_PROVIDER_CACHE", "0") == "1" and not provider_live,
|
||||
mode=body.model_info.mode,
|
||||
)}),
|
||||
response_type=ModelNewResponse,
|
||||
)
|
||||
|
||||
def _servable_registration(self, model_name: str, posted: Result[ModelNewResponse]) -> ModelRegistration:
|
||||
if not isinstance(posted, Success):
|
||||
return ModelNotRegistered(model_name=model_name, reason=f"/model/new failed: {posted!r}")
|
||||
match self._servable_outcome(model_name, None):
|
||||
case Servable():
|
||||
return ModelRegistered(model_name=model_name, model_id=posted.data.model_id)
|
||||
case NotServableOn() as missing:
|
||||
self.delete_model(posted.data.model_id)
|
||||
return ModelNotRegistered(model_name=model_name, reason=self._not_servable_message(model_name, missing))
|
||||
|
||||
def _await_model_servable(self, model_name: str, listed_for: str | None = None) -> None:
|
||||
"""Block until every replica lists `model_name`, or fail at model_servable_timeout."""
|
||||
match self._servable_outcome(model_name, listed_for):
|
||||
case Servable():
|
||||
return
|
||||
case NotServableOn() as missing:
|
||||
raise AssertionError(self._not_servable_message(model_name, missing))
|
||||
|
||||
def _servable_outcome(self, model_name: str, listed_for: str | None) -> Servable | NotServableOn:
|
||||
headers: Final = self.management_headers(listed_for)
|
||||
outcome: Final = await_servable_everywhere(
|
||||
return await_servable_everywhere(
|
||||
{url: self._models_poller(transport, headers) for url, transport in self.replicas.items()},
|
||||
model_name=model_name,
|
||||
timeout=self.model_servable_timeout,
|
||||
|
|
@ -728,19 +772,15 @@ class ProxyClient:
|
|||
now=time.monotonic,
|
||||
sleep=time.sleep,
|
||||
)
|
||||
match outcome:
|
||||
case Servable():
|
||||
return
|
||||
case NotServableOn(replica=replica, last_result=last_result):
|
||||
raise AssertionError(
|
||||
servable_timeout_message(
|
||||
model_name=model_name,
|
||||
replica=replica,
|
||||
timeout=self.model_servable_timeout,
|
||||
db_sync_seconds=self.model_servable_db_sync_seconds,
|
||||
last_result=last_result,
|
||||
)
|
||||
)
|
||||
|
||||
def _not_servable_message(self, model_name: str, missing: NotServableOn) -> str:
|
||||
return servable_timeout_message(
|
||||
model_name=model_name,
|
||||
replica=missing.replica,
|
||||
timeout=self.model_servable_timeout,
|
||||
db_sync_seconds=self.model_servable_db_sync_seconds,
|
||||
last_result=missing.last_result,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _models_poller(transport: Transport, headers: AuthHeaders) -> ModelsPoller:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ markers =
|
|||
prompt_caching_stack: needs a proxy running with router_settings.optional_pre_call_checks including prompt_caching; deselected unless E2E_PROMPT_CACHING_STACK is set
|
||||
cli_determinism: drives the real claude CLI for several seconds; deselected unless E2E_CLI_DETERMINISM is set
|
||||
redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_PERF_TESTS is set
|
||||
quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; every other test waits for it to finish
|
||||
provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the gateway must reach the pytest host; deselected unless E2E_PROVIDER_EDGE_HOST_REACHABLE is set
|
||||
owned_gateway: boots its own proxy from source against the stack's Postgres, so it needs DATABASE_URL on the pytest host; deselected unless E2E_OWNED_GATEWAY is set
|
||||
otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set
|
||||
|
|
@ -21,3 +20,4 @@ markers =
|
|||
secret_manager: needs a proxy booted from gateway/secret_manager_<system>_ci_config.yml against that live secret manager; deselected unless E2E_SECRET_MANAGER names the backend (see secret_manager/secret_backends.py)
|
||||
sagemaker_nova: needs a deployed SageMaker Nova endpoint the proxy's AWS credentials can invoke; deselected unless SAGEMAKER_NOVA_ENDPOINT names it
|
||||
sagemaker_nova2_lite: needs a deployed SageMaker Nova 2 Lite endpoint the proxy's AWS credentials can invoke; deselected unless SAGEMAKER_NOVA2_LITE_ENDPOINT names it
|
||||
proxy_wide_config: turns on a proxy-wide setting through /config/update, so it runs on a stack no other suite is driving; deselected unless E2E_PROXY_WIDE_CONFIG_STACK is set
|
||||
|
|
|
|||
|
|
@ -17,11 +17,6 @@ from models import (
|
|||
ChatBody,
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
ConfigPatchResponse,
|
||||
CostDiscountConfig,
|
||||
CostDiscountConfigResponse,
|
||||
CostMarginConfig,
|
||||
CostMarginConfigResponse,
|
||||
GuardrailCreateBody,
|
||||
GuardrailCreateResponse,
|
||||
KeyDeleteBody,
|
||||
|
|
@ -31,14 +26,11 @@ from models import (
|
|||
)
|
||||
from spend_e2e_client import SpendClient
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.quiet_stack]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
BACKEND_MODEL: Final = "openai/gpt-6.1-sol"
|
||||
INPUT_RATE: Final = 0.00005
|
||||
OUTPUT_RATE: Final = 0.0001
|
||||
DISCOUNT: Final = 0.25
|
||||
MARGIN_PERCENT: Final = 0.1
|
||||
MARGIN_FIXED: Final = 0.0005
|
||||
|
||||
|
||||
def _register_bedrock_guardrail(
|
||||
|
|
@ -92,52 +84,6 @@ def _guarded_chat(client: SpendClient, key: str, model: str, name: str) -> Strea
|
|||
)
|
||||
|
||||
|
||||
def _set_discount(client: SpendClient, values: dict[str, float]) -> float:
|
||||
unwrap(
|
||||
client.proxy.transport.patch(
|
||||
"/config/cost_discount_config",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CostDiscountConfig(values),
|
||||
response_type=ConfigPatchResponse,
|
||||
)
|
||||
)
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _set_margin(client: SpendClient, values: dict[str, float | dict[str, float]]) -> float:
|
||||
unwrap(
|
||||
client.proxy.transport.patch(
|
||||
"/config/cost_margin_config",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CostMarginConfig(values),
|
||||
response_type=ConfigPatchResponse,
|
||||
)
|
||||
)
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _get_discount(client: SpendClient) -> dict[str, float]:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/config/cost_discount_config",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CostDiscountConfigResponse,
|
||||
)
|
||||
).values
|
||||
|
||||
|
||||
def _get_margin(client: SpendClient) -> dict[str, float | dict[str, float]]:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/config/cost_margin_config",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CostMarginConfigResponse,
|
||||
)
|
||||
).values
|
||||
|
||||
|
||||
def _register_model(client: SpendClient, resources: ResourceManager, prefix: str) -> str:
|
||||
model: Final = f"{prefix}-{unique_marker()}"
|
||||
model_id: Final = client.proxy.create_model(
|
||||
|
|
@ -199,100 +145,7 @@ def scoped_key(client: SpendClient, strict_resources: ResourceManager) -> str:
|
|||
return key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restored_pricing_config(client: SpendClient) -> Iterator[None]:
|
||||
discount: Final = _get_discount(client)
|
||||
margin: Final = _get_margin(client)
|
||||
try:
|
||||
_set_discount(client, {})
|
||||
reset_at: Final = _set_margin(client, {})
|
||||
settle_propagation(reset_at)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
_set_discount(client, discount)
|
||||
finally:
|
||||
restored_at: Final = _set_margin(client, margin)
|
||||
settle_propagation(restored_at)
|
||||
|
||||
|
||||
class TestPricingConfigSpend:
|
||||
@pytest.mark.covers(
|
||||
"quota_management.spend_tracking.discount_config.logs_cost",
|
||||
exercised_on=["chat_completions"],
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_configured_discount_reaches_persisted_spend_row(
|
||||
self,
|
||||
client: SpendClient,
|
||||
strict_resources: ResourceManager,
|
||||
scoped_key: str,
|
||||
restored_pricing_config: None,
|
||||
) -> None:
|
||||
discount_set_at: Final = _set_discount(client, {"openai": DISCOUNT})
|
||||
settle_propagation(discount_set_at)
|
||||
model: Final = _register_model(client, strict_resources, "discount-priced")
|
||||
chat: Final = unwrap(client.chat(scoped_key, model, f"reply with one word {unique_marker()}", max_tokens=16))
|
||||
assert chat.id and chat.usage
|
||||
assert chat.usage.prompt_tokens is not None and chat.usage.prompt_tokens > 0
|
||||
assert chat.usage.completion_tokens is not None and chat.usage.completion_tokens >= 0
|
||||
|
||||
row: Final = poll_cost_row(client.proxy, chat.id)
|
||||
assert row is not None
|
||||
base_cost: Final = _base_cost(row, chat.usage.prompt_tokens, chat.usage.completion_tokens)
|
||||
breakdown: Final = row.breakdown
|
||||
assert breakdown.discount_percent is not None and approx_equal(breakdown.discount_percent, DISCOUNT)
|
||||
assert breakdown.discount_amount is not None and approx_equal(breakdown.discount_amount, base_cost * DISCOUNT)
|
||||
assert row.spend is not None and approx_equal(row.spend, base_cost * (1 - DISCOUNT))
|
||||
|
||||
@pytest.mark.covers(
|
||||
"quota_management.spend_tracking.margin_config.logs_cost",
|
||||
exercised_on=["chat_completions"],
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.SPEND_BUDGETS,
|
||||
route=Route.SPEND_REPORTING,
|
||||
providers=(Provider.OPENAI,),
|
||||
models=(BACKEND_MODEL,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_configured_margin_reaches_persisted_spend_row(
|
||||
self,
|
||||
client: SpendClient,
|
||||
strict_resources: ResourceManager,
|
||||
scoped_key: str,
|
||||
restored_pricing_config: None,
|
||||
) -> None:
|
||||
margin_set_at: Final = _set_margin(
|
||||
client, {"openai": {"percentage": MARGIN_PERCENT, "fixed_amount": MARGIN_FIXED}}
|
||||
)
|
||||
settle_propagation(margin_set_at)
|
||||
model: Final = _register_model(client, strict_resources, "margin-priced")
|
||||
chat: Final = unwrap(client.chat(scoped_key, model, f"reply with one word {unique_marker()}", max_tokens=16))
|
||||
assert chat.id and chat.usage
|
||||
assert chat.usage.prompt_tokens is not None and chat.usage.prompt_tokens > 0
|
||||
assert chat.usage.completion_tokens is not None and chat.usage.completion_tokens >= 0
|
||||
|
||||
row: Final = poll_cost_row(client.proxy, chat.id)
|
||||
assert row is not None
|
||||
base_cost: Final = _base_cost(row, chat.usage.prompt_tokens, chat.usage.completion_tokens)
|
||||
breakdown: Final = row.breakdown
|
||||
expected_margin: Final = base_cost * MARGIN_PERCENT + MARGIN_FIXED
|
||||
assert breakdown.margin_percent is not None and approx_equal(breakdown.margin_percent, MARGIN_PERCENT)
|
||||
assert breakdown.margin_fixed_amount is not None and approx_equal(breakdown.margin_fixed_amount, MARGIN_FIXED)
|
||||
assert breakdown.margin_total_amount is not None and approx_equal(breakdown.margin_total_amount, expected_margin)
|
||||
assert row.spend is not None and approx_equal(row.spend, base_cost + expected_margin)
|
||||
|
||||
class TestGuardrailCostSpend:
|
||||
@pytest.mark.covers(
|
||||
"quota_management.spend_tracking.guardrail_cost.logs_cost",
|
||||
exercised_on=["chat_completions"],
|
||||
|
|
@ -311,7 +164,6 @@ class TestPricingConfigSpend:
|
|||
client: SpendClient,
|
||||
strict_resources: ResourceManager,
|
||||
scoped_key: str,
|
||||
restored_pricing_config: None,
|
||||
) -> None:
|
||||
identifier: Final = os.environ["BEDROCK_GUARDRAIL_IDENTIFIER"]
|
||||
version: Final = os.environ["BEDROCK_GUARDRAIL_VERSION"]
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""Cross-process reader/writer lock over the proxy stack every xdist worker shares.
|
||||
Every collected test holds it shared, marker or not, since the Claude Code cells and
|
||||
other unmarked suites drive the same stack; a `quiet_stack` test holds it exclusive,
|
||||
and the `gate` file makes a waiting exclusive holder win over readers that arrive
|
||||
after it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from e2e_config import PROXY_BASE_URL
|
||||
|
||||
STACK_DIGEST: Final = hashlib.sha256(PROXY_BASE_URL.encode()).hexdigest()[:12]
|
||||
LOCK_DIR: Final = Path(tempfile.gettempdir()) / f"litellm-e2e-stack-{STACK_DIGEST}"
|
||||
GATE_FILE: Final = LOCK_DIR / "gate"
|
||||
STACK_FILE: Final = LOCK_DIR / "stack"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _flock(path: Path, operation: int) -> Generator[None]:
|
||||
with path.open("a") as handle:
|
||||
fcntl.flock(handle, operation)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stack_lock(exclusive: bool) -> Generator[None]:
|
||||
LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if exclusive:
|
||||
with _flock(GATE_FILE, fcntl.LOCK_EX), _flock(STACK_FILE, fcntl.LOCK_EX):
|
||||
yield
|
||||
return
|
||||
with ExitStack() as held:
|
||||
with _flock(GATE_FILE, fcntl.LOCK_SH):
|
||||
held.enter_context(_flock(STACK_FILE, fcntl.LOCK_SH))
|
||||
yield
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# e2e harness tests
|
||||
|
||||
Tests of the harness under `tests/e2e/` (the transport, the clients, the fixture bundle and replay edge, the stack lock, the IdP launcher, the coverage collector, the JUnit properties and the Claude Code driver), not of the product. They live outside `tests/e2e/` because the Buildkite e2e run copies that folder into the runner image and runs every test in it, so a harness test in there counts as a product test in the nightly numbers. Nothing here needs a proxy, provider keys or the network
|
||||
Tests of the harness under `tests/e2e/` (the transport, the clients, the fixture bundle and replay edge, the IdP launcher, the coverage collector, the JUnit properties and the Claude Code driver), not of the product. They live outside `tests/e2e/` because the Buildkite e2e run copies that folder into the runner image and runs every test in it, so a harness test in there counts as a product test in the nightly numbers. Nothing here needs a proxy, provider keys or the network
|
||||
|
||||
The layout mirrors `tests/e2e/`: `test_e2e_http.py` covers `tests/e2e/e2e_http.py`, `logging/test_datadog_reader.py` covers `tests/e2e/logging/datadog_reader.py`, `claude_code/` covers the driver, builder and probe, `cli_harness/` covers the version resolver, the CLI env lockdown and the rate limiter, and `codex/` covers how the Codex driver reads the CLI's JSONL events and the compactions in a thread's rollout file. Put a new harness test under the folder that mirrors the suite folder whose module it covers
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ injected clock, so nothing here monkeypatches anything.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from builtins import ExceptionGroup
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from itertools import chain, repeat
|
||||
from queue import SimpleQueue
|
||||
|
|
@ -40,7 +41,10 @@ from models import (
|
|||
LiteLLMParamsBody,
|
||||
McpServerCreateBody,
|
||||
McpServerUpdateBody,
|
||||
ModelDeleteBody,
|
||||
ModelInfoBody,
|
||||
ModelListEntry,
|
||||
ModelNewBody,
|
||||
ModelsListResponse,
|
||||
OrgNewBody,
|
||||
OrgUpdateBody,
|
||||
|
|
@ -59,6 +63,8 @@ from proxy_client import (
|
|||
ConvergeOutcome,
|
||||
CredentialKind,
|
||||
EverywhereConverged,
|
||||
ModelNotRegistered,
|
||||
ModelRegistered,
|
||||
ModelsPoller,
|
||||
NeverConvergedOn,
|
||||
NotConverged,
|
||||
|
|
@ -691,3 +697,85 @@ def test_partial_updates_preserve_explicit_null_at_the_http_boundary(operation:
|
|||
)
|
||||
assert json.loads(bodies.get_nowait()) == expected
|
||||
assert bodies.empty()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def model_registry(
|
||||
listed: frozenset[str], refused: frozenset[str]
|
||||
) -> Generator[tuple[ProxyClient, SimpleQueue[float], SimpleQueue[str]]]:
|
||||
writes: Final[SimpleQueue[float]] = SimpleQueue()
|
||||
deleted: Final[SimpleQueue[str]] = SimpleQueue()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def reply(self, status: int, body: object) -> None:
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(body).encode())
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.reply(200, {"data": [{"id": name} for name in sorted(listed)]})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
raw: Final = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
if self.path == "/model/delete":
|
||||
deleted.put(ModelDeleteBody.model_validate_json(raw).id)
|
||||
self.reply(200, {})
|
||||
return
|
||||
writes.put(time.monotonic())
|
||||
name: Final = ModelNewBody.model_validate_json(raw).model_name
|
||||
if name in refused:
|
||||
self.reply(500, {"error": "refused"})
|
||||
return
|
||||
self.reply(200, {"model_id": f"id-{name}"})
|
||||
|
||||
server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread: Final = Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True)
|
||||
thread.start()
|
||||
url: Final = f"http://127.0.0.1:{server.server_port}"
|
||||
proxy: Final = build_proxy_client(
|
||||
base_url=url, control_plane_base_url=url, replica_urls=(url,), control_replica_urls=(url,), master_key="bootstrap"
|
||||
)
|
||||
try:
|
||||
yield replace(proxy, model_servable_timeout=0.3, model_servable_interval=0.01), writes, deleted
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
class TestRegisterModels:
|
||||
def test_a_batch_waits_once_after_its_last_write_and_reports_each_model(self) -> None:
|
||||
names: Final = ("first", "refused", "ghost", "last")
|
||||
settled: Final[SimpleQueue[float]] = SimpleQueue()
|
||||
with (
|
||||
model_registry(listed=frozenset({"first", "last"}), refused=frozenset({"refused"})) as (
|
||||
proxy,
|
||||
writes,
|
||||
deleted,
|
||||
),
|
||||
without_retries(),
|
||||
):
|
||||
registrations: Final = replace(proxy, settle=settled.put).register_models(
|
||||
tuple(
|
||||
ModelNewBody(
|
||||
model_name=name, litellm_params=LiteLLMParamsBody(model="synthetic"), model_info=ModelInfoBody()
|
||||
)
|
||||
for name in names
|
||||
)
|
||||
)
|
||||
last_write: Final = max(writes.get_nowait() for _ in names)
|
||||
assert writes.empty()
|
||||
assert settled.get_nowait() >= last_write
|
||||
assert settled.empty(), "a batch must wait out propagation once, not once per model"
|
||||
assert tuple((type(r), r.model_name) for r in registrations) == (
|
||||
(ModelRegistered, "first"),
|
||||
(ModelNotRegistered, "refused"),
|
||||
(ModelNotRegistered, "ghost"),
|
||||
(ModelRegistered, "last"),
|
||||
)
|
||||
assert tuple(r.model_id for r in registrations if isinstance(r, ModelRegistered)) == ("id-first", "id-last")
|
||||
assert deleted.get_nowait() == "id-ghost"
|
||||
assert deleted.empty()
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
"""Cross-process behavior of the stack lock: readers share it, an exclusive holder waits for
|
||||
every reader and keeps them out, and a reader arriving behind a waiting exclusive holder
|
||||
queues behind it instead of starving it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import inspect
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import stack_lock
|
||||
|
||||
HARNESS_DIR: Final = Path(inspect.getfile(stack_lock)).resolve().parent
|
||||
DEADLINE_SECONDS: Final = 30.0
|
||||
SETTLE_SECONDS: Final = 0.5
|
||||
HOLDER_SCRIPT: Final = """
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
from stack_lock import stack_lock
|
||||
name, mode, release_path, log_path = sys.argv[1:]
|
||||
|
||||
|
||||
def record(event):
|
||||
with Path(log_path).open("a") as log:
|
||||
log.write(f"{name} {event}\\n")
|
||||
|
||||
|
||||
record("waiting")
|
||||
with stack_lock(exclusive=mode == "exclusive"):
|
||||
record("enter")
|
||||
while not Path(release_path).exists():
|
||||
time.sleep(0.02)
|
||||
record("exit")
|
||||
"""
|
||||
|
||||
|
||||
def _events(log_path: Path) -> tuple[str, ...]:
|
||||
return tuple(log_path.read_text().splitlines()) if log_path.exists() else ()
|
||||
|
||||
|
||||
def _wait_for_event(log_path: Path, event: str) -> None:
|
||||
deadline: Final = time.monotonic() + DEADLINE_SECONDS
|
||||
while event not in _events(log_path):
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail(f"{event!r} never appeared; events so far: {_events(log_path)}")
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def _wait_until_gate_is_held_exclusively(gate_path: Path) -> None:
|
||||
deadline: Final = time.monotonic() + DEADLINE_SECONDS
|
||||
with gate_path.open("a") as handle:
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("no exclusive holder ever took the gate")
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def _start_holder(held: ExitStack, tmp_path: Path, name: str, mode: str) -> subprocess.Popen[bytes]:
|
||||
holder: Final = held.enter_context(
|
||||
subprocess.Popen(
|
||||
(
|
||||
sys.executable,
|
||||
"-P",
|
||||
"-c",
|
||||
HOLDER_SCRIPT,
|
||||
name,
|
||||
mode,
|
||||
str(tmp_path / f"release-{name}"),
|
||||
str(tmp_path / "events"),
|
||||
),
|
||||
cwd=HARNESS_DIR,
|
||||
env={**os.environ, "TMPDIR": str(tmp_path), "PYTHONPATH": str(HARNESS_DIR)},
|
||||
)
|
||||
)
|
||||
held.callback(holder.kill)
|
||||
return holder
|
||||
|
||||
|
||||
def test_readers_share_exclusive_waits_and_a_waiting_exclusive_beats_later_readers(tmp_path: Path) -> None:
|
||||
lock_dir: Final = tmp_path / f"litellm-e2e-stack-{stack_lock.STACK_DIGEST}"
|
||||
lock_dir.mkdir()
|
||||
log_path: Final = tmp_path / "events"
|
||||
with ExitStack() as held:
|
||||
first_reader: Final = _start_holder(held, tmp_path, "A", "shared")
|
||||
_wait_for_event(log_path, "A enter")
|
||||
second_reader: Final = _start_holder(held, tmp_path, "R", "shared")
|
||||
_wait_for_event(log_path, "R enter")
|
||||
(tmp_path / "release-R").touch()
|
||||
_wait_for_event(log_path, "R exit")
|
||||
writer: Final = _start_holder(held, tmp_path, "W", "exclusive")
|
||||
_wait_until_gate_is_held_exclusively(lock_dir / "gate")
|
||||
late_reader: Final = _start_holder(held, tmp_path, "B", "shared")
|
||||
_wait_for_event(log_path, "B waiting")
|
||||
time.sleep(SETTLE_SECONDS)
|
||||
(tmp_path / "release-A").touch()
|
||||
_wait_for_event(log_path, "W enter")
|
||||
(tmp_path / "release-W").touch()
|
||||
_wait_for_event(log_path, "B enter")
|
||||
(tmp_path / "release-B").touch()
|
||||
for holder in (first_reader, second_reader, writer, late_reader):
|
||||
assert holder.wait(timeout=DEADLINE_SECONDS) == 0
|
||||
events: Final = _events(log_path)
|
||||
assert events.index("R enter") < events.index("A exit")
|
||||
assert events.index("W enter") > events.index("A exit")
|
||||
assert events.index("B enter") > events.index("W exit")
|
||||
|
|
@ -7,7 +7,7 @@ import httpx
|
|||
import pytest
|
||||
from integration._support.client import Gateway, eventually, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from pydantic import BaseModel, JsonValue
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
|
||||
|
||||
class CostEstimate(BaseModel):
|
||||
|
|
@ -110,6 +110,25 @@ def _assert_logged_spends(expectations: tuple[tuple[str, float], ...]) -> None:
|
|||
assert actual == pytest.approx(dict(expectations)), rows
|
||||
|
||||
|
||||
class LoggedBreakdown(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
original_cost: float
|
||||
discount_percent: float | None = None
|
||||
discount_amount: float | None = None
|
||||
margin_percent: float | None = None
|
||||
margin_fixed_amount: float | None = None
|
||||
margin_total_amount: float | None = None
|
||||
|
||||
|
||||
def _logged_breakdown(request_id: str) -> LoggedBreakdown:
|
||||
rows: Final = read_rows('SELECT metadata FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (request_id,))
|
||||
assert len(rows) == 1, rows
|
||||
metadata: Final = rows[0]["metadata"]
|
||||
parsed: Final = object_value(json.loads(metadata) if isinstance(metadata, str) else metadata)
|
||||
return LoggedBreakdown.model_validate(parsed["cost_breakdown"])
|
||||
|
||||
|
||||
def _estimated(gateway: Gateway, model: str) -> CostEstimate:
|
||||
response: Final = gateway.request(
|
||||
"POST", "/cost/estimate", {"model": model, "input_tokens": 20, "output_tokens": 20}
|
||||
|
|
@ -236,6 +255,17 @@ def test_discount_and_margin_reprice_a_request_and_survive_rejection(gateway: Ga
|
|||
(streamed_global_request_id, 0.12),
|
||||
)
|
||||
)
|
||||
discounted: Final = _logged_breakdown(discounted_request_id)
|
||||
assert discounted.original_cost == pytest.approx(0.06), discounted
|
||||
assert discounted.discount_percent == pytest.approx(0.05), discounted
|
||||
assert discounted.discount_amount == pytest.approx(0.06 * 0.05), discounted
|
||||
margined: Final = _logged_breakdown(provider_margin_request_id)
|
||||
assert margined.original_cost == pytest.approx(0.06), margined
|
||||
assert margined.discount_percent == pytest.approx(0.05), margined
|
||||
assert margined.discount_amount == pytest.approx(0.06 * 0.05), margined
|
||||
assert margined.margin_percent == pytest.approx(0.1), margined
|
||||
assert margined.margin_fixed_amount == pytest.approx(0.001), margined
|
||||
assert margined.margin_total_amount == pytest.approx(0.057 * 0.1 + 0.001), margined
|
||||
|
||||
rejections: Final = (
|
||||
(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually, gateway_from_environment, object_value
|
||||
from integration._support.process import owned_proxy
|
||||
from pydantic import JsonValue
|
||||
|
||||
UNREACHABLE_BASE: Final = "http://127.0.0.1:9/v1"
|
||||
TRANSCRIPT_TURNS: Final = 40
|
||||
RETRIES: Final = 2
|
||||
STORED_REQUEST_BUDGET_KB: Final = 64
|
||||
|
||||
|
||||
def _config_storing_prompts(directory: Path) -> Path:
|
||||
config: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
general_settings: Final = object_value(config["general_settings"])
|
||||
path: Final = directory / "store-prompts.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump({**config, "general_settings": {**general_settings, "store_prompts_in_spend_logs": True}})
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
|
||||
with gateway_from_environment() as parent:
|
||||
directory: Final = tmp_path_factory.mktemp("failing_request_stored_snapshot")
|
||||
with owned_proxy(parent, directory, {}, config=_config_storing_prompts(directory)) as owned:
|
||||
yield owned
|
||||
|
||||
|
||||
def _transcript(marker: str) -> list[JsonValue]:
|
||||
turns: Final[list[JsonValue]] = [
|
||||
{"role": role, "content": f"turn {turn // 2} {role}"}
|
||||
for turn, role in enumerate(("user", "assistant") * TRANSCRIPT_TURNS)
|
||||
]
|
||||
return [*turns, {"role": "user", "content": marker}]
|
||||
|
||||
|
||||
def _stored_request(gateway: Gateway, call_id: str) -> JsonValue:
|
||||
rows: Final = eventually(
|
||||
lambda: gateway.rows('SELECT proxy_server_request FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (call_id,)),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=40,
|
||||
)
|
||||
stored: Final = rows[0]["proxy_server_request"]
|
||||
return json.loads(stored) if isinstance(stored, str) else stored
|
||||
|
||||
|
||||
def test_failing_request_with_retries_and_fallback_stores_one_copy_of_the_request(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
primary: Final = f"stored-snapshot-{uuid.uuid4().hex}"
|
||||
fallback: Final = f"stored-snapshot-fb-{uuid.uuid4().hex}"
|
||||
for name in (primary, primary, fallback, fallback):
|
||||
scenario.model(name=name, api_base=UNREACHABLE_BASE, cooldown_time=0)
|
||||
key: Final = scenario.key(models=[primary, fallback])
|
||||
marker: Final = f"stored snapshot {uuid.uuid4().hex}"
|
||||
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": primary,
|
||||
"max_tokens": 16,
|
||||
"messages": _transcript(marker),
|
||||
"router_settings_override": {"num_retries": RETRIES, "fallbacks": [{primary: [fallback]}]},
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
assert response.status_code != 200, response.text
|
||||
assert fallback in response.text, response.text
|
||||
|
||||
serialized: Final = json.dumps(_stored_request(gateway, response.headers["x-litellm-call-id"]))
|
||||
stored_kb: Final = len(serialized.encode()) / 1024
|
||||
assert marker in serialized, serialized[:400]
|
||||
assert stored_kb <= STORED_REQUEST_BUDGET_KB, (
|
||||
f"one failing request stored a {stored_kb:.0f} KB request snapshot for a "
|
||||
f"{2 * TRANSCRIPT_TURNS + 1}-message transcript with {RETRIES} retries and a fallback"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue