mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
* test(e2e): out-of-process mock OpenAI, Anthropic, Bedrock and guardrail servers for the perf cases Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): coding-agent request corpus and real provider captures for the perf mocks Request bodies come from a deterministic coding-agent session (system prompt, Read/Edit/Write/Bash/Grep/Glob tools, tool_use and tool_result turns carrying real source files picked by the request marker) in OpenAI chat, Responses and Anthropic formats. captures/ holds one real response per provider, route, shape and stream mode (gpt-6-luna, claude-sonnet-5, us.anthropic.claude-sonnet-5), cold prompt cache, body only, raw stream chunks with time offsets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): replay module for the perf mocks, captured provider responses picked by route, mode and request size Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf mocks replay the captured provider responses, knobs shrink to flake and speed The OpenAI, Anthropic and Bedrock mocks answer with the captured body or stream chunks for their route, mode and nearest request size, ids swapped per request, on the captured clock scaled by the p knob. The synthetic reply builders, the per-token stream pacing and the hand-rolled Bedrock event-stream encoder are gone Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf mock request log names its provider Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf guardrail mock remembers the request marker by call id for the response hook Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf mock captures retaken cold, one marker and system-prompt prefix per cell Run 2 shared one marker across cells, so every stream capture was served from the provider's prompt cache (cache_read_input_tokens up to 523946) and the json cells had small prefix hits. Every cell now carries its own marker, also prepended to the system prompt, and all 30 captures report zero cached tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf corpus reads tests/e2e files so the lane's runner image has them * test(e2e): perf long_context json captures for Claude retaken with a 16k output cap, no longer cut mid-thinking * test(e2e): perf mocks keep only what the replay path and the test read * test(e2e): perf mocks interpolate the provider's time to first byte from the captures around the request size * test(e2e): perf captures at 100k (short) and 500k (long) tokens with tools, the fixed-size set removed * test(e2e): PerformanceTestCase schema, deterministic request plan and wire records for the perf cases RequestShape names a corpus session (input_tokens, tools, output_tokens) and wire.request_body builds it with setup/corpus/session.py, so a 300k request is 85 file reads of real source in the endpoint's own format. The plan no longer predicts reply text, the mocks replay captured provider responses and the runner joins what the client saw against the mock's log by marker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf Metric gains time_to_first_token_seconds Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf User loses the unused role field, every JWT user is an internal_user * test(e2e): perf RequestShape sizes requests from an input-token range, short and long stock shapes * test(e2e): perf case runner (provision, Locust plan replay, observe) and the 50 rps mixed-provider case provision.py checks the deployed config against the case and builds the callers, run.py replays the plan from a separate Locust process (locustfile.py) recording status, header, first-byte, first-token and total time per request, observe.py joins the records with what the mocks received and the workers' RSS to score every Limit, and compares each served body with the capture the mock replayed for it as a diagnostic. The case sends 15,000 requests at 50 rps over three model groups, two guardrails and the three request shapes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf case asserts time_to_first_token_seconds at percentile 50 and 99 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf provision creates every JWT user as internal_user * test(e2e): perf observe reads only the mock log fields that remain * test(e2e): 50 rps case sends short and long coding-agent sessions sized per request * test(e2e): perf run keeps the per-request records as perf-records.jsonl under E2E_ARTIFACT_DIR * test(e2e): the mixed small-gateway case runs at 25 rps (50 saturated one 4-worker pod on main and on v1.105.1) * test(e2e): small_bodies_high_rate perf case, the github issue 46276 shape (11 to 17 KB chat bodies, keys only, 200 rps) * test(e2e): the mixed small-gateway case runs at 10 rps for 6,000 requests (25 rps still saturated one pod on v1.105.1) * ci: python lint job gets 20 minutes, the e2e harness phase (625 tests plus the replay harness suite) pushes a run past 15 when tests/e2e changes * test(e2e): small-bodies perf case runs at 5 rps so gateway_added_seconds is per-request overhead, not queue wait * test(e2e): mixed-provider perf case runs at 5 rps x 3,000 requests from 100 users * test(e2e): mixed-provider perf case sends happy-path traffic only (no flaky deployment, no guardrail interventions or blocks) * test(e2e): perf gateway model_list is three happy-path deployments, one per provider * test(e2e): Postgres and Redis work per request as perf case limits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): perf mocks wait a case-level latency with jitter instead of replaying captured timings, cpu_seconds_per_request limit from metrics-server * test(e2e): perf suite PR carries the small_bodies_5rps case only, the mixed-provider case moves to its own PR * test(e2e): small_bodies_5rps limits from the ten v1.105.1 baselines (1.5x on datastore counts, 2x on latency and cpu, absolute on errors and memory) * test(e2e): perf mocks keep only the short captures the small case reads, captured timings and the Bedrock invoke route removed * test(e2e): perf mocks generate each reply from the request (provider schema, usage, ids, event order, chunk sizes) instead of replaying captures --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
227 lines
7.6 KiB
Python
227 lines
7.6 KiB
Python
"""Shared behaviour of the perf mock providers.
|
|
|
|
Every reply is generated for the request just received (see `replies.py`): sized from its prompt and output
|
|
cap, a tool call when tools were offered, in the provider's own wire format with its usage fields, ids,
|
|
event order and chunk sizes, so the gateway parses, prices and streams it as it would the real thing. The
|
|
model id stays a real one (`gpt-6-luna`, `anthropic.claude-sonnet-5`) so every name-based branch in the
|
|
gateway runs, and one mock pod per provider serves any number of deployments. How long a mock waits before answering is the one setting a case
|
|
controls, `POST /mock/latency {"seconds": 1.0, "jitter_seconds": 0.25}`, set by the test's setup directly
|
|
on the pod, never through the gateway. The wait, the reply's size and its ids are pure functions of the request marker (`perfreq-000123-n`, put in the prompt by the plan
|
|
generator), so the test can join what each provider received against what each client saw.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from typing import Final, Literal, cast
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import Response, StreamingResponse
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from replies import Route, render_chunks, render_json, reply_for
|
|
from reply_common import Provider, Reply, chunks_after, unit
|
|
|
|
MARKER: Final = re.compile(r"perfreq-\d{6}-[nib]")
|
|
DELAY_HEADER: Final = "x-mock-delay-s"
|
|
Flag = Literal["n", "i", "b"]
|
|
|
|
|
|
def marker_in(text: str) -> str | None:
|
|
match: Final = MARKER.search(text)
|
|
return None if match is None else match.group(0)
|
|
|
|
|
|
def flag_of(marker: str) -> Flag:
|
|
last: Final = marker[-1]
|
|
assert last in ("n", "i", "b"), marker
|
|
return "n" if last == "n" else "i" if last == "i" else "b"
|
|
|
|
|
|
def collect_text(value: object) -> list[str]:
|
|
"""Every prompt string, whatever the wire format nests it in: `text` and `content` parts, the
|
|
Responses API's bare `input`, and `system` prompts."""
|
|
if isinstance(value, dict):
|
|
items: Final = cast(dict[object, object], value).items()
|
|
return [
|
|
part
|
|
for key, item in items
|
|
for part in (
|
|
[item]
|
|
if key in ("text", "content", "input", "system") and isinstance(item, str)
|
|
else collect_text(item)
|
|
)
|
|
] # comprehension-ok: flattening one nesting level of the request body
|
|
if isinstance(value, list):
|
|
return [part for item in cast(list[object], value) for part in collect_text(item)] # comprehension-ok: flatten
|
|
return []
|
|
|
|
|
|
def token_count(text: str) -> int:
|
|
return max(1, len(text) // 4)
|
|
|
|
|
|
def str_field(body: object, key: str, default: str) -> str:
|
|
if isinstance(body, dict):
|
|
value: object = cast(dict[object, object], body).get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
return default
|
|
|
|
|
|
def bool_field(body: object, key: str) -> bool:
|
|
if isinstance(body, dict):
|
|
value: object = cast(dict[object, object], body).get(key)
|
|
return value is True
|
|
return False
|
|
|
|
|
|
class MockRequest(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
provider: Provider
|
|
marker: str | None
|
|
route: str
|
|
stream: bool
|
|
output_tokens: int
|
|
text_sha1: str
|
|
delay_s: float
|
|
|
|
|
|
class MockRequestsResponse(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
requests: list[MockRequest]
|
|
|
|
|
|
class ResetResponse(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
cleared: int
|
|
|
|
|
|
class Latency(BaseModel):
|
|
"""Seconds a mock waits before its first byte: `seconds` plus or minus up to `jitter_seconds`, drawn
|
|
from the request marker so the same request always waits the same."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
seconds: float = Field(default=0.0, ge=0.0)
|
|
jitter_seconds: float = Field(default=0.0, ge=0.0)
|
|
|
|
def for_seed(self, seed: str) -> float:
|
|
return max(0.0, self.seconds + self.jitter_seconds * (2 * unit(f"{seed}:latency") - 1))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Decision:
|
|
"""What this mock does with one request: the reply it generates, on which route, after how long."""
|
|
|
|
marker: str | None
|
|
reply: Reply
|
|
route: Route
|
|
delay_s: float
|
|
"""Seconds before the first byte, what `gateway_added_seconds` subtracts from the client's time."""
|
|
|
|
@property
|
|
def seed(self) -> str:
|
|
return self.marker or "unmarked"
|
|
|
|
@property
|
|
def headers(self) -> dict[str, str]:
|
|
return {DELAY_HEADER: f"{self.delay_s:.4f}"}
|
|
|
|
|
|
class RequestLog:
|
|
"""What one mock pod received, in order, readable by the test at GET /mock/requests."""
|
|
|
|
def __init__(self, provider: Provider, capacity: int = 100_000) -> None:
|
|
self._provider: Final = provider
|
|
self._entries: deque[MockRequest] = deque(maxlen=capacity)
|
|
self._latency = Latency()
|
|
|
|
@property
|
|
def latency(self) -> Latency:
|
|
return self._latency
|
|
|
|
def set_latency(self, latency: Latency) -> Latency:
|
|
self._latency = latency
|
|
return self._latency
|
|
|
|
def decide(self, *, route: Route, stream: bool, body: object, model: str) -> Decision:
|
|
prompt_text: Final = " ".join(collect_text(body))
|
|
marker: Final = marker_in(prompt_text)
|
|
seed: Final = marker or "unmarked"
|
|
reply: Final = reply_for(
|
|
provider=self._provider, body=body, seed=seed, model=model, prompt_tokens=token_count(prompt_text)
|
|
)
|
|
decision: Final = Decision(marker=marker, reply=reply, route=route, delay_s=self._latency.for_seed(seed))
|
|
self._entries.append(
|
|
MockRequest(
|
|
provider=self._provider,
|
|
marker=marker,
|
|
route=route,
|
|
stream=stream,
|
|
output_tokens=reply.output_tokens,
|
|
text_sha1=hashlib.sha1(reply.text.encode()).hexdigest(),
|
|
delay_s=decision.delay_s,
|
|
)
|
|
)
|
|
return decision
|
|
|
|
def snapshot(self) -> list[MockRequest]:
|
|
return list(self._entries)
|
|
|
|
def reset(self) -> int:
|
|
count: Final = len(self._entries)
|
|
self._entries.clear()
|
|
return count
|
|
|
|
|
|
async def json_reply(decision: Decision) -> Response:
|
|
"""The generated body after the case's latency."""
|
|
if decision.delay_s > 0:
|
|
await asyncio.sleep(decision.delay_s)
|
|
return Response(
|
|
content=render_json(decision.reply, decision.route),
|
|
media_type="application/json",
|
|
headers=decision.headers,
|
|
)
|
|
|
|
|
|
def stream_reply(decision: Decision, media_type: str) -> Response:
|
|
"""The generated chunks back to back after the case's latency."""
|
|
return StreamingResponse(
|
|
chunks_after(render_chunks(decision.reply, decision.route), decision.delay_s),
|
|
media_type=media_type,
|
|
headers=decision.headers,
|
|
)
|
|
|
|
|
|
def parse_json(raw: bytes) -> object:
|
|
parsed: object = json.loads(raw) if raw else {}
|
|
return parsed
|
|
|
|
|
|
def mount_control(app: FastAPI, log: RequestLog) -> None:
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
async def requests_seen() -> MockRequestsResponse:
|
|
return MockRequestsResponse(requests=log.snapshot())
|
|
|
|
async def reset() -> ResetResponse:
|
|
return ResetResponse(cleared=log.reset())
|
|
|
|
async def latency(request: Latency) -> Latency:
|
|
return log.set_latency(request)
|
|
|
|
app.add_api_route("/health", health, methods=["GET"])
|
|
app.add_api_route("/mock/requests", requests_seen, methods=["GET"])
|
|
app.add_api_route("/mock/reset", reset, methods=["POST"])
|
|
app.add_api_route("/mock/latency", latency, methods=["POST"])
|