Merge remote-tracking branch 'berri/litellm_internal_staging' into litellm_bedrock_realtime_staging
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

This commit is contained in:
mubashir1osmani 2026-08-24 18:02:15 -04:00
commit 7df92b9fbf
100 changed files with 2008 additions and 301 deletions

View file

@ -36,6 +36,7 @@ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.repositories.base_repository import SupportsModelDump
from litellm.repositories.team_repository import TeamRepository
from litellm.router_strategy.complexity_router import ComplexityRouter
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model
from litellm.types.management_endpoints.auto_router_endpoints import (
SHADOW_EVAL_TURN_VALVE,
AutoRouterBenchmarkGroup,
@ -510,6 +511,53 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
)
def _strategy_router_key(deployment: object) -> tuple[str, str] | None:
"""``(model_name, kind)`` for a deployment whose routing the session rollup records.
Kinds come from ``classify_strategy_router_model``, the same rule the Router registers a
deployment by, so this arm cannot disagree with the arm that stamped ``router_type`` onto
the session rows. Semantic auto-routers return None: they record no routing decision, so
they can never own a session row, and ``AutoRouterBenchmarkGroup.router_type`` has no
value for them. A permanent zero would read as "no traffic" rather than "not instrumented".
"""
if not isinstance(deployment, Mapping):
return None
litellm_params: Final = deployment.get("litellm_params")
router_name: Final = deployment.get("model_name")
if not (isinstance(litellm_params, Mapping) and isinstance(router_name, str) and router_name):
return None
model: Final = litellm_params.get("model")
if not isinstance(model, str):
return None
kind: Final = classify_strategy_router_model(model)
return None if kind is None or kind == "semantic" else (router_name, kind)
def _idle_router_groups(
llm_router: "Router | None", covered: frozenset[tuple[str, str]]
) -> tuple[AutoRouterBenchmarkGroup, ...]:
"""Zeroed groups for configured strategy routers the window's traffic did not cover.
The dashboard's router picker has to list a router the moment it is created rather than
once it has spent something, so the registry drives the list and the rollup only supplies
the measures. ``_summed_agg_row`` over no sessions is already the zero element of the
fold, so a group with every measure at zero costs one relabel rather than a literal that
would go stale the next time the response grows a field.
"""
if llm_router is None:
return ()
zero: Final = _summed_agg_row(())
idle: Final = frozenset(
key
for key in (_strategy_router_key(deployment) for deployment in llm_router.model_list or ())
if key is not None and key not in covered
)
return tuple(
_benchmark_group(zero.model_copy(update=MappingProxyType({"router_name": name, "router_type": kind})))
for name, kind in sorted(idle)
)
@router.get(
"/auto_router/benchmarks",
tags=("auto router",),
@ -532,8 +580,13 @@ async def get_auto_router_benchmarks(
overlaps it: its last turn is on or after start_date and its first turn is on or before
end_date. Overall hit rate is over telemetry-bearing turns; each bucket's hit rate is
over that bucket's turns.
The rollup supplies the measures, never the list. Which routers appear comes from the
model registry, so one shows up as soon as it is configured and reads zero until it
serves traffic, and `routers_in_scope` counts those too rather than only the routers the
window recorded.
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.proxy.proxy_server import llm_router, prisma_client
_require_admin_viewer(user_api_key_dict, "view auto-router benchmarks across the deployment")
if prisma_client is None:
@ -555,11 +608,14 @@ async def get_auto_router_benchmarks(
(end_day + timedelta(days=1)).isoformat(),
)
rows: Final = _SESSION_AGG_ROWS.validate_python(raw_rows or ())
groups: Final = tuple(_benchmark_group(row) for row in rows)
groups: Final = (
*(_benchmark_group(row) for row in rows),
*_idle_router_groups(llm_router, frozenset((row.router_name, row.router_type) for row in rows)),
)
return AutoRouterBenchmarksResponse(
start_date=start_day.strftime("%Y-%m-%d"),
end_date=end_day.strftime("%Y-%m-%d"),
routers_in_scope=len(rows),
routers_in_scope=len(groups),
totals=_benchmark_totals(_summed_agg_row(rows)),
groups=groups,
)

View file

@ -29,7 +29,11 @@ from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.proxy._types import *
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, user_api_key_auth_websocket
from litellm.proxy.auth.user_api_key_auth import (
_get_bearer_token,
user_api_key_auth,
user_api_key_auth_websocket,
)
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_headers,
@ -1758,6 +1762,154 @@ def _override_vertex_params_from_router_credentials(
return vertex_project, vertex_location
_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = (
"No Vertex AI credential is configured on this proxy and the request carried no upstream "
"Google credential. The LiteLLM virtual key is not forwarded to Google. Configure a Vertex "
"credential (DEFAULT_VERTEXAI_PROJECT / DEFAULT_VERTEXAI_LOCATION / DEFAULT_VERTEXAI_CREDENTIALS, "
"or a model with use_in_pass_through: true), or send your own Google OAuth token in the "
"Authorization header."
)
def _normalize_credential_value(value: str) -> str:
"""Reduce a header value to the bare token, matching how ``user_api_key_auth``
reads a caller's key.
Reuses the auth module's ``_get_bearer_token`` so the caller-key comparison
strips exactly the schemes authentication accepts (``Bearer`` / ``bearer`` /
``Basic`` / ``AWS4-HMAC-SHA256`` credential), rather than re-deriving a
narrower normalization here. ``_get_bearer_token`` returns ``""`` for a value
with no recognized scheme prefix, so a bare token (or a real Google
credential that carries no scheme) falls back to its own value.
"""
return _get_bearer_token(value) or value
_VERTEX_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-goog-api-key"})
_HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"}) | (
SpecialHeaders.litellm_credential_header_names() - _VERTEX_UPSTREAM_CREDENTIAL_HEADERS
)
_VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = (
SpecialHeaders.custom_litellm_api_key.value.lower(),
SpecialHeaders.openai_authorization.value.lower(),
SpecialHeaders.azure_authorization.value.lower(),
SpecialHeaders.anthropic_authorization.value.lower(),
SpecialHeaders.google_ai_studio_authorization.value.lower(),
SpecialHeaders.azure_apim_authorization.value.lower(),
)
_MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key"
def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Operator-configured caller-key header names, as (override, pass_through).
``user_api_key_auth`` accepts the caller's key from two runtime-configured
header sources beyond the built-in ones, at opposite ends of its precedence.
``general_settings.litellm_key_header_name`` overrides every built-in source
(it replaces the resolved key after ``get_api_key`` runs), so it is highest
precedence. Each ``general_settings.pass_through_endpoints`` entry's
``headers.litellm_user_api_key`` is checked last inside ``get_api_key``, so it
is lowest. Google never consumes either, so both are also dropped by name.
"""
from litellm.proxy.proxy_server import general_settings
custom_key_header: Final = general_settings.get("litellm_key_header_name")
override: Final = (custom_key_header.lower(),) if isinstance(custom_key_header, str) else ()
pass_through_endpoints: Final = general_settings.get("pass_through_endpoints")
endpoints: Final = pass_through_endpoints if isinstance(pass_through_endpoints, list) else ()
pass_through: Final = tuple(
dict.fromkeys(
headers["litellm_user_api_key"].lower()
for endpoint in endpoints
if isinstance(endpoint, dict)
for headers in (endpoint.get("headers"),)
if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str)
)
)
return override, pass_through
def _authenticated_caller_key_values(request: Request) -> frozenset[str]:
"""The value ``user_api_key_auth`` would accept as this caller's LiteLLM key.
The Vertex route authenticates through ``Depends(user_api_key_auth)``, which
resolves the key by precedence, matched here exactly. The ``/vertex_ai`` route
is a mapped pass-through route, so a header literally named
``litellm_user_api_key`` overrides every other source (``user_api_key_auth``
applies it last), making it highest precedence. Then an operator
``litellm_key_header_name``, then the built-in headers in ``get_api_key`` order,
then a ``pass_through_endpoints`` ``litellm_user_api_key`` header which
``get_api_key`` checks last. Some of those headers (``Authorization``,
``x-goog-api-key``) are also kept as genuine bring-your-own Google credentials,
so returning only the value that actually authenticated lets the filter strip
that value wherever it appears while leaving a real Google credential in place.
An empty set means no caller key was found, so nothing is value-stripped.
"""
incoming: Final = _safe_get_request_headers(request)
override_headers, pass_through_headers = _operator_configured_caller_key_header_names()
ordered_names: Final = (
(_MAPPED_ROUTE_CALLER_KEY_HEADER,)
+ override_headers
+ _VERTEX_CALLER_KEY_HEADER_PRECEDENCE
+ pass_through_headers
)
present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name))
authenticated_key: Final = next(
(stripped for value in present_values if (stripped := _normalize_credential_value(value))),
"",
)
return frozenset({authenticated_key}) if authenticated_key else frozenset()
def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]:
"""
Header set to forward on the bring-your-own-credentials Vertex passthrough
branch, used when the proxy has no Vertex credential configured.
No credential the proxy accepts for caller authentication is forwarded to
Google. ``user_api_key_auth`` reads the caller's key from every header in
``SpecialHeaders.litellm_credential_header_names()``, and Vertex only ever
authenticates with an OAuth token in ``Authorization`` or an API key in
``x-goog-api-key``. So the proxy-only auth headers Google never consumes
(everything in that set except those two, e.g. ``x-litellm-api-key`` /
``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus the mapped
pass-through ``litellm_user_api_key`` header and any operator-configured
``litellm_key_header_name`` / ``pass_through_endpoints`` key header) are dropped
by name. ``Authorization`` and ``x-goog-api-key`` may
instead carry a genuine bring-your-own Google credential, so they are kept
unless their value is the caller's authenticated LiteLLM key, which is dropped
by value (normalizing any ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix
the same way authentication does). Because the value that authenticated is
resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent
only in ``x-goog-api-key`` (or in an operator-configured key header) is dropped
too, while a real Google key in ``x-goog-api-key`` alongside a virtual key in a
higher-precedence header is preserved. When neither a surviving
``Authorization`` nor ``x-goog-api-key`` remains the request is rejected so the
virtual key cannot leak upstream.
"""
incoming: Final = _safe_get_request_headers(request)
caller_key_values: Final = _authenticated_caller_key_values(request)
override_headers, pass_through_headers = _operator_configured_caller_key_header_names()
never_forwarded: Final = (
_HEADERS_NEVER_FORWARDED_TO_VERTEX.union((_MAPPED_ROUTE_CALLER_KEY_HEADER,))
.union(override_headers)
.union(pass_through_headers)
)
forwarded: Final = MappingProxyType(
{
name: value
for name, value in incoming.items()
if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values
}
)
if "authorization" not in forwarded and "x-goog-api-key" not in forwarded:
raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL)
return forwarded
async def _prepare_vertex_auth_headers(
request: Request,
vertex_credentials: Any | None,
@ -1766,7 +1918,7 @@ async def _prepare_vertex_auth_headers(
vertex_location: str | None,
base_target_url: str | None,
get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler,
) -> tuple[dict, str | None, bool, str | None, str | None]:
) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]:
"""
Prepare authentication headers for Vertex AI pass-through requests.
@ -1792,11 +1944,11 @@ async def _prepare_vertex_auth_headers(
# Use headers from the incoming request if no vertex credentials are found
if (vertex_credentials is None or vertex_credentials.vertex_project is None) and router_credentials is None:
headers = _safe_get_request_headers(request).copy()
headers = _forwarded_headers_for_credentialless_vertex_passthrough(request)
headers_passed_through = True
verbose_proxy_logger.debug("default_vertex_config not set, incoming request headers %s", headers)
headers.pop("content-length", None)
headers.pop("host", None)
verbose_proxy_logger.debug(
"default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys())
)
else:
if router_credentials is not None:
vertex_credentials_str = None
@ -1882,7 +2034,7 @@ async def _base_vertex_proxy_route(
encoded_endpoint = httpx.URL(endpoint).path
verbose_proxy_logger.debug("requested endpoint %s", endpoint)
headers: dict = {}
headers: Mapping[str, str] = {}
api_key_to_use = get_litellm_virtual_key(request=request)
user_api_key_dict = await user_api_key_auth(
request=request,

View file

@ -80,6 +80,7 @@ from litellm.litellm_core_utils.request_timeout_resolver import (
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.litellm_core_utils.sensitive_data_masker import (
SensitiveDataMasker,
mask_credentials_in_payload,
mask_sensitive_structure,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
@ -375,10 +376,17 @@ def _replay_live_router_model_cost() -> None:
set_live_deployment_replay(_replay_live_router_model_cost)
# Kwargs that log_retry must not copy into a retry breadcrumb. The breadcrumbs reach spend
# logs and logging callbacks, and these carry either the request payload or router-internal
# walk state rather than anything that identifies the failed attempt.
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(("messages", "original_function", "attempted_targets"))
# Kwargs that carry no signal about the failed attempt, so log_retry drops them from a
# breadcrumb entirely: the request payload and the router-internal walk state. Credentials are
# handled separately by mask_credentials_in_payload, which scrubs credential-named values from
# whatever kwargs remain rather than trying to enumerate every credential-bearing key here.
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(
(
"messages",
"original_function",
"attempted_targets",
)
)
class Router:
@ -7347,7 +7355,8 @@ class Router:
if len(self.previous_models) > 3:
self.previous_models.pop(0)
self.previous_models.append(previous_model)
scrubbed_previous_model: Final = mask_credentials_in_payload(previous_model)
self.previous_models.append(scrubbed_previous_model)
kwargs[_metadata_var]["previous_models"] = self.previous_models
return kwargs
except Exception as e:

View file

@ -274,6 +274,7 @@ _REMINDER_CLOSE: Final = "</system-reminder>"
_DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),)
_TRUNCATION_MARKER: Final = "..."
_TRUNCATION_HEAD_FRACTION: Final = 0.3
_CJK_CHARACTER: Final = re.compile("[぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-ン\U00020000-\U0003ffff]")
@ -552,8 +553,21 @@ def _matched_plan_mode_sentinel(
def _truncate(text: str, limit: int) -> str:
"""Cap text at limit characters, marking it so the classifier can tell the turn was cut short."""
return text if len(text) <= limit else f"{text[:limit]}{_TRUNCATION_MARKER}"
"""Cap text at limit characters, keeping both ends and eliding the middle.
A chat turn states its ask at the end, so cutting the tail keeps the preamble and discards the
request the turn exists to make: a turn opening with an incident report and closing with "rewrite
the retry path and prove it cannot livelock" reached the classifier as the incident report alone.
Keeping both ends costs nothing at the same budget and is what the truncation literature finds
best for classifying long text, head+tail measuring above both head-only and tail-only in Sun et
al. 2019. The marker sits at the cut, so the turn reads as having its middle removed rather than
as trailing off mid-thought.
"""
if len(text) <= limit:
return text
head_chars: Final = max(int(limit * _TRUNCATION_HEAD_FRACTION), 0)
tail_chars: Final = max(limit - head_chars, 0)
return f"{text[:head_chars]}{_TRUNCATION_MARKER}{text[len(text) - tail_chars :]}"
def _iter_context_turns_newest_first(

View file

@ -158,9 +158,18 @@ class AutoRouterBenchmarksResponse(BaseModel):
start_date: str = Field(description="Window start day, YYYY-MM-DD UTC, inclusive")
end_date: str = Field(description="Window end day, YYYY-MM-DD UTC, inclusive")
routers_in_scope: int
routers_in_scope: int = Field(
description="How many groups this response carries. Every auto-router configured on the "
"proxy counts, whether or not it served anything in the window. To count only the routers "
"that did serve traffic, filter `groups` to the entries whose `sessions` is above zero"
)
totals: AutoRouterBenchmarkTotals
groups: tuple[AutoRouterBenchmarkGroup, ...]
groups: tuple[AutoRouterBenchmarkGroup, ...] = Field(
description="One entry per auto-router, listed from the model registry rather than from "
"the rollup, so a router appears as soon as it is configured and reads zero until it "
"serves traffic. Semantic auto-routers are absent: they record no routing decision, so no "
"session can ever be attributed to them"
)
ShadowEvalStatus: TypeAlias = Literal["running", "completed", "stopped"]

View file

@ -60,6 +60,8 @@ IGNORE_FUNCTIONS = [
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
]

View file

@ -77,7 +77,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover
The seam is `provider_edge.py`: `start_provider_edge` boots an in-process HTTP server (one shared instance per pytest process, `e2e_config.provider_edge_base` is the accessor) that mounts each supported provider under a path prefix (`EDGE_MOUNTS`: `/openai` -> `https://api.openai.com`, `/anthropic` -> `https://api.anthropic.com`). A test participates by registering its deployment with `api_base=provider_edge_base("openai")` plus the provider's path suffix; `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` is the reference. In live mode the accessor returns None and the deployment defaults to the real provider, so an edge-wired test runs in all three modes unchanged. Non-wired tests hit their providers live in every mode. The edge binds `E2E_PROVIDER_EDGE_BIND_HOST` (default 127.0.0.1) and advertises `E2E_PROVIDER_EDGE_ADVERTISE_HOST` in the api_base it hands out, for proxies running in containers
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. `fixture_bundle.py` owns the format. Record serves the proxy the same filtered stored response replay will serve later, so the two modes are byte-identical from the proxy's side of the socket
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. Responses come in two shapes told apart by a `kind` tag: an ordinary one holding a single base64 body, and, for a response the provider streamed (`content-type: text/event-stream`), one holding its transfer chunks in order plus why the stream ended early if it did, so replay reproduces the split points the provider chose instead of one coalesced body. `fixture_bundle.py` owns the format, and `BUNDLE_FORMAT_VERSION` is checked on load, so a bundle recorded under older rules is refused by name rather than partially read. Record serves the proxy the same filtered stored response replay will serve later, chunk for chunk on a stream, so the two modes are byte-identical from the proxy's side of the socket
Multipart identity is the fiddly corner, and the rules exist because each one had a collision behind it. A part counts as an upload when it carries a filename or declares its own content type, and everything else is an ordinary field. Field names get a `name[n]` suffix on repeats, with a literal `[` doubled first, so a form that repeats `purpose` never keys the same as one that literally sends `purpose[1]`. A field whose name reads as a credential is stored as `<secret>`, which stays key-preserving because the key is recomputed from the stored request rather than saved alongside it, so the live request carrying the real value still matches its redacted fixture. A field value that is not UTF-8 is stored as a base64 sha256 digest, base64 and not hex because the canonicalizer rewrites any 64-character hex run to `<sha256>` and would fold every binary value onto one key. The uploaded parts contribute a JSON list rather than a `field:filename` string, so a separator inside a filename cannot impersonate a field boundary, and their byte length is stored for a reader's benefit but deliberately left out of the key, since the canonicalizer absorbs timestamp and id drift inside a file that changes its length
@ -87,7 +87,7 @@ A replayed response carries the recorded provider response id, and `LiteLLM_Spen
The same id reuse reaches the managed-object tables. A replayed `/v1/files` or `/v1/batches` response carries the recorded provider object id, and `LiteLLM_ManagedObjectTable.model_object_id` is unique, so a unified batch create replayed against a database that still holds the record run's row fails on a Prisma unique-constraint violation, which surfaces as a 500, makes the router retry, and exhausts the recording. Replay the batches suite against a fresh database, or truncate `LiteLLM_ManagedObjectTable` and `LiteLLM_ManagedFileTable` before the run
Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` except the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up:
Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` including the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up:
```bash
E2E_FIXTURE_MODE=record E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 uv run pytest tests/e2e/llm_translation/test_chat_completions_contract_e2e.py
@ -96,7 +96,7 @@ E2E_FIXTURE_MODE=replay E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1
Point the proxy at bogus provider credentials for the replay run and it still has to pass: that is the whole proof that nothing left the process. Bundles are never committed. `tests/e2e/.fixtures` is gitignored because a bundle holds verbatim provider response bodies and hard-fails after seven days, and publishing one for CI is LIT-5748
Current limits: streaming chunk fidelity is LIT-5742 (a streamed response records as one buffered body), CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode
Current limits: CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode
## Typing

View file

@ -65,7 +65,7 @@ Bundles stay local. `tests/e2e/.fixtures` is gitignored because a bundle holds v
One sharp edge: a replayed response reuses the recorded provider response id, and that id is the primary key of `LiteLLM_SpendLogs`, so replaying against a database that still holds the record run's rows silently dedupes the spend writes and a spend assertion fails with zero rows. Run both commands above with `E2E_RESET_SPEND_LOGS=1` (and `DATABASE_URL` set in the pytest env) so each session truncates the spend log table after itself, or point replay at a fresh database
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the non-streaming Anthropic tests in `llm_translation/test_messages_e2e.py`, and the OpenAI batch deployment behind `batches/`. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (streaming, Bedrock)
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic tests in `llm_translation/test_messages_e2e.py`, streamed and not, and the OpenAI batch deployment behind `batches/`. A streamed response replays as the chunk sequence the provider sent rather than one buffered body. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (Bedrock, CI wiring)
Tests marked `@pytest.mark.e2e` hard-fail when no proxy answers `/health/liveliness`, so a run that goes red with `No live proxy` at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass

View file

@ -4,6 +4,11 @@ Enforced by tests/code_coverage_tests/check_e2e_no_raw_requests.py. Every reques
body / query / header / response is a pydantic model; outcomes are a tagged union
(``Result[R]``) so callers ``match`` on them instead of catching exceptions.
``forward`` relays one provider-bound request for the provider edge and buffers
the whole body; ``forward_stream`` relays the same request but hands back the
response head plus a lazy iterator over the upstream's own transfer chunks, which
is what lets a recording keep the split points a streamed response arrived on.
Named e2e_http (not http) so it does not shadow the stdlib ``http`` package that
requests itself imports.
"""
@ -12,7 +17,8 @@ from __future__ import annotations
import time
from collections.abc import Callable
from typing import Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast
from dataclasses import dataclass
from typing import Generator, Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast
import pytest
import requests
@ -681,3 +687,88 @@ def forward(
headers={name.lower(): value for name, value in resp.headers.items()},
body=resp.content,
)
@dataclass(frozen=True, slots=True)
class StreamChunk:
"""One transfer chunk of a response body, exactly as the upstream framed it."""
data: bytes
@dataclass(frozen=True, slots=True)
class StreamTruncation:
"""The body ended without its terminator, i.e. the upstream hung up mid-message.
Always the last step, and ``reason`` is the transport's own description of it."""
reason: str
type StreamStep = StreamChunk | StreamTruncation
@dataclass(frozen=True, slots=True)
class StreamHead:
"""An upstream response whose head has arrived and whose body has not been read.
A dataclass rather than a BaseModel because it owns a live socket: ``steps`` is
consumed once, in order, and closing it closes the underlying response."""
status_code: int
headers: dict[str, str]
steps: Generator[StreamStep, None, None]
def _stream_steps(resp: requests.Response) -> Generator[StreamStep, None, None]:
"""The body as the upstream framed it, one step per transfer chunk.
``chunk_size=None`` is the whole point: urllib3 then returns exactly one piece
per wire chunk, so the provider's split points survive into the recording. Any
integer would re-slice the body into fixed-size pieces instead. Empty pieces are
dropped because a zero-length chunk is the terminator on the wire, and a failure
part way through becomes a final truncation step rather than an exception, since
the chunks already delivered are exactly what makes a mid-stream failure
different from a request that never streamed at all."""
try:
for piece in cast("Iterator[bytes]", resp.iter_content(chunk_size=None)):
if piece:
yield StreamChunk(data=piece)
except requests.RequestException as exc:
yield StreamTruncation(reason=str(exc))
finally:
resp.close()
def forward_stream(
method: str,
url: str,
*,
headers: dict[str, str],
body: bytes | None,
timeout: float = 60.0,
) -> StreamHead | NetworkError:
"""Relay one provider-bound request for the provider edge and return as soon as
the response head arrives, with the body left unread behind ``StreamHead.steps``.
Same contract as ``forward`` otherwise: no retries, no redirects, no schema. A
failure before the head arrives is still a ``NetworkError``; one raised while the
body streams arrives as the last step. With ``stream=True`` the timeout bounds
each socket read rather than the whole body, which is the right bound for a
stream and strictly more permissive for a long generation."""
try:
resp = requests.request(
method,
url,
headers=headers,
data=body,
timeout=timeout,
allow_redirects=False,
stream=True,
)
except requests.RequestException as exc:
return NetworkError(message=str(exc))
return StreamHead(
status_code=resp.status_code,
headers={name.lower(): value for name, value in resp.headers.items()},
steps=_stream_steps(resp),
)

View file

@ -6,15 +6,17 @@ per provider-bound interaction in call order. Bundles older than
``MAX_BUNDLE_AGE`` hard-fail replay at collection time (see conftest), so a
green replay run can never certify against fixtures that have drifted more than
a week from the live providers. Bump ``BUNDLE_FORMAT_VERSION`` whenever a change
moves recorded keys: a bundle recorded under the old rules then fails naming
both versions instead of quietly missing on every call.
moves recorded keys or changes the stored shape: a bundle recorded under the old
rules then fails naming both versions instead of quietly missing on every call.
This module owns the format only. The provider-edge server that produces and
consumes it lives in provider_edge.py (LIT-5745) and the canonical match keys
it computes live in fixture_canonical.py (LIT-5741); streaming chunk fidelity
is a follow-up (LIT-5742). Every interaction file stores the full redacted
request because replay matches on its canonicalized content, and the response
as the raw HTTP status, filtered headers, and base64 body the provider sent.
consumes it lives in provider_edge.py (LIT-5745) and the canonical match keys it
computes live in fixture_canonical.py (LIT-5741). Every interaction file stores
the full redacted request because replay matches on its canonicalized content,
and a response in one of two shapes, told apart by their ``kind`` tag: an
ordinary ``RecordedHttpResponse`` holding one base64 body, or, for a response the
provider streamed, a ``RecordedStreamedResponse`` holding its transfer chunks in
order so replay reproduces the same split points (LIT-5742).
"""
from __future__ import annotations
@ -26,11 +28,11 @@ import subprocess
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Final
from typing import Annotated, Final, Literal
from pydantic import BaseModel, JsonValue
from pydantic import BaseModel, Field, JsonValue
BUNDLE_FORMAT_VERSION: Final = 3
BUNDLE_FORMAT_VERSION: Final = 4
MAX_BUNDLE_AGE: Final = timedelta(days=7)
MANIFEST_FILENAME: Final = "manifest.json"
@ -74,14 +76,38 @@ class RecordedHttpResponse(BaseModel):
volatile entries (see provider_edge.py), and the body as base64 so binary
payloads survive JSON."""
kind: Literal["http"] = "http"
status_code: int
headers: dict[str, str]
body_b64: str
class RecordedStreamedResponse(BaseModel):
"""A response the provider streamed, kept chunk by chunk instead of buffered.
``chunks_b64`` holds one entry per upstream transfer chunk, in order, so replay
reproduces the split points the provider chose rather than one coalesced body.
``truncated`` is None for a stream that reached its terminator and otherwise
says why it did not, prefixed by which side ended it (``upstream:`` for a
provider that hung up mid-stream, ``downstream:`` for a proxy that stopped
reading). Replay behaves the same for any truncation, delivering the recorded
chunks and then closing; the reason is there for whoever reads the bundle."""
kind: Literal["streamed"] = "streamed"
status_code: int
headers: dict[str, str]
chunks_b64: list[str]
truncated: str | None = None
type RecordedResponse = Annotated[
RecordedHttpResponse | RecordedStreamedResponse, Field(discriminator="kind")
]
class Interaction(BaseModel):
request: RecordedRequest
response: RecordedHttpResponse
response: RecordedResponse
def slugify(raw: str, *, limit: int = 60) -> str:
@ -128,7 +154,7 @@ class BundleRecorder:
root: Path
_ordinals: dict[str, int] = field(default_factory=dict)
def record(self, *, test_key: str, request: RecordedRequest, response: RecordedHttpResponse) -> None:
def record(self, *, test_key: str, request: RecordedRequest, response: RecordedResponse) -> None:
slug = slug_for_test(test_key)
ordinal = self._ordinals.get(slug, 0)
self._ordinals[slug] = ordinal + 1
@ -200,14 +226,27 @@ def _read_manifest(root: Path) -> Manifest | UnreadableBundle:
return UnreadableBundle(reason=f"{MANIFEST_FILENAME} is invalid: {exc}")
def check_freshness(root: Path, *, now: datetime) -> BundleFreshness:
def _supported_manifest(root: Path) -> Manifest | UnreadableBundle:
"""The manifest, refused when it was written under a different format version.
A bundle is atomic (record wipes and rewrites the whole directory and never
merges), so a foreign version is a hard reject rather than a partial read."""
manifest = _read_manifest(root)
if isinstance(manifest, UnreadableBundle):
return manifest
if manifest.format_version != BUNDLE_FORMAT_VERSION:
return UnreadableBundle(
reason=f"format_version {manifest.format_version} != supported {BUNDLE_FORMAT_VERSION}"
reason=(
f"format_version {manifest.format_version} != supported {BUNDLE_FORMAT_VERSION}; "
"re-record with E2E_FIXTURE_MODE=record"
)
)
return manifest
def check_freshness(root: Path, *, now: datetime) -> BundleFreshness:
manifest = _supported_manifest(root)
if isinstance(manifest, UnreadableBundle):
return manifest
recorded_at = (
manifest.recorded_at
if manifest.recorded_at.tzinfo is not None
@ -231,7 +270,7 @@ class LoadedBundle:
def load_bundle(root: Path) -> LoadedBundle | UnreadableBundle:
manifest = _read_manifest(root)
manifest = _supported_manifest(root)
if isinstance(manifest, UnreadableBundle):
return manifest
interactions = {

View file

@ -33,6 +33,25 @@ class _OptionalMessagesBody(BaseModel):
max_tokens: int | None = None
class _MessagesEventDelta(BaseModel):
text: str = ""
class _MessagesEventUsage(BaseModel):
output_tokens: int | None = None
class _MessagesStreamEvent(BaseModel):
"""One Anthropic SSE event, keeping only what the stream's shape is asserted on.
``delta.text`` is populated on ``content_block_delta`` and absent on the
``message_delta`` that closes the turn, which is the event carrying ``usage``."""
type: str
delta: _MessagesEventDelta | None = None
usage: _MessagesEventUsage | None = None
ANTHROPIC_BACKEND = "anthropic/claude-haiku-4-5"
WEATHER_TOOL = AnthropicCustomTool(
@ -137,13 +156,14 @@ class TestAnthropicMessages:
def test_messages_streams_completion(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
"""Stays on a live Anthropic deployment in every mode: the edge buffers a
streamed response into one body, so chunk fidelity waits on LIT-5742."""
model, key = self._register(
endpoints_client,
resources,
LiteLLMParamsBody(model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"),
)
"""Edge-wired like its non-streaming siblings, so record and replay both
carry the streamed response.
Asserts the shape of the event sequence, not just that deltas and a stop
appeared somewhere in it: the answer arrives across several deltas, and the
usage event sits between the last of them and ``message_stop``. A replay that
coalesced the response into one buffered body could not satisfy either."""
model, key = self._register(endpoints_client, resources)
result = endpoints_client.proxy.messages_stream(
key,
@ -158,11 +178,35 @@ class TestAnthropicMessages:
assert result.is_streaming, f"response was not streamed: {result.headers}"
assert not result.stream_error, f"stream errored: {result.stream_error}"
assert result.stream_events, "stream produced no SSE events"
assert any("content_block_delta" in event for event in result.stream_events), (
"stream carried no content deltas"
events = [
_MessagesStreamEvent.model_validate_json(event) for event in result.stream_events
]
types = [event.type for event in events]
delta_positions = [
index for index, event in enumerate(events) if event.type == "content_block_delta"
]
assert len(delta_positions) >= 2, (
f"stream carried {len(delta_positions)} content deltas, so it was not "
f"incremental: {types}"
)
assert any("message_stop" in event for event in result.stream_events), (
"stream never reached message_stop"
text = "".join(
event.delta.text
for event in events
if event.type == "content_block_delta" and event.delta is not None
)
assert text.strip(), f"content deltas assembled to no text: {result.stream_events[:5]}"
usage_positions = [
index
for index, event in enumerate(events)
if event.type == "message_delta" and event.usage is not None
]
assert usage_positions, f"stream never reported usage: {types}"
assert "message_stop" in types, f"stream never reached message_stop: {types}"
stop_position = types.index("message_stop")
assert delta_positions[-1] < usage_positions[0] < stop_position, (
f"usage did not land between the last content delta and message_stop: {types}"
)
@pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works")

View file

@ -19,10 +19,21 @@ headers must never touch disk. An unmatched replay call returns HTTP
``REPLAY_MISS_STATUS`` naming the closest recorded interaction, which the
proxy relays as a provider error the failing test surfaces.
A response the provider streamed (one whose content type names
``text/event-stream``) is relayed and stored chunk by chunk instead of buffered
(LIT-5742): the edge reads one piece per upstream transfer chunk, writes each
one downstream in chunked framing as it arrives, and records the sequence, so
replay hands the proxy the same number of chunks split in the same places. A
provider that hangs up mid-stream is recorded as the chunks it did deliver plus
a truncation, and replays as those chunks followed by a connection close with no
terminator, which is the same incomplete chunked read the live failure produced
rather than a clean 502 that erases it. Everything else keeps the buffered
shape, byte for byte, framed with a content-length as before.
v1 limits: only the mounts in ``EDGE_MOUNTS`` (SigV4 providers like Bedrock
sign the Host header, so a forwarding edge breaks their signatures), streaming
fidelity is LIT-5742, and CI wiring is LIT-5748. Suites that do not wire the
edge keep hitting providers live in every mode.
sign the Host header, so a forwarding edge breaks their signatures), and CI
wiring is LIT-5748. Suites that do not wire the edge keep hitting providers
live in every mode.
"""
from __future__ import annotations
@ -34,24 +45,34 @@ import hashlib
import re
import threading
from collections import deque
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from contextlib import closing
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from itertools import islice
from pathlib import Path
from types import MappingProxyType
from typing import Final, Literal, assert_never
from typing import Final, Generator, Literal, assert_never
from urllib.parse import parse_qsl, urlsplit
from pydantic import JsonValue, TypeAdapter
from e2e_http import NetworkError, RawResponse, forward
from e2e_http import (
NetworkError,
StreamChunk,
StreamHead,
StreamStep,
StreamTruncation,
forward_stream,
)
from fixture_bundle import (
BundleRecorder,
Interaction,
LoadedBundle,
RecordedHttpResponse,
RecordedRequest,
RecordedResponse,
RecordedStreamedResponse,
UnreadableBundle,
UnsafeBundleDir,
interaction_filename,
@ -479,11 +500,28 @@ type EdgeBackend = RecordEdge | ReplayEdge
@dataclass(frozen=True, slots=True)
class EdgeReply:
"""A whole response the edge already holds: written with a content-length."""
status_code: int
headers: dict[str, str]
body: bytes
@dataclass(frozen=True, slots=True)
class EdgeStream:
"""A response the edge relays chunk by chunk: written in chunked framing, one
transfer chunk per step, so the split points reach the proxy intact. Record and
replay both produce one of these, driven by different step sources, which is
what makes their framing identical by construction rather than by inspection."""
status_code: int
headers: dict[str, str]
steps: Generator[StreamStep, None, None]
type EdgeOutcome = EdgeReply | EdgeStream
def _text_reply(status_code: int, message: str) -> EdgeReply:
return EdgeReply(
status_code=status_code,
@ -492,34 +530,87 @@ def _text_reply(status_code: int, message: str) -> EdgeReply:
)
def _reply_from_recorded(response: RecordedHttpResponse) -> EdgeReply:
return EdgeReply(
status_code=response.status_code,
headers=dict(response.headers),
body=base64.b64decode(response.body_b64),
def _recorded_steps(
chunks_b64: Sequence[str], truncated: str | None
) -> Generator[StreamStep, None, None]:
"""Replay's step source: the recorded chunks in recorded order, as fast as the
socket takes them (inter-chunk delays are deliberately not reproduced), then the
recorded truncation if the stream ended without a terminator."""
for chunk in chunks_b64:
yield StreamChunk(data=base64.b64decode(chunk))
if truncated is not None:
yield StreamTruncation(reason=truncated)
def _recorded_outcome(response: RecordedResponse) -> EdgeOutcome:
match response:
case RecordedHttpResponse(status_code=status_code, headers=headers, body_b64=body_b64):
return EdgeReply(
status_code=status_code,
headers=dict(headers),
body=base64.b64decode(body_b64),
)
case RecordedStreamedResponse(
status_code=status_code, headers=headers, chunks_b64=chunks_b64, truncated=truncated
):
return EdgeStream(
status_code=status_code,
headers=dict(headers),
steps=_recorded_steps(chunks_b64, truncated),
)
case _:
assert_never(response)
def _filtered_response_headers(headers: Mapping[str, str]) -> dict[str, str]:
"""What the edge stores and serves: the provider's headers minus hop-by-hop and
volatile entries. Framing headers are in that set, so a stored header can never
contradict the framing the edge chooses when it serves the response."""
return {
name: value for name, value in headers.items() if name not in _RESPONSE_DROPPED_HEADERS
}
def _network_error_response(message: str) -> RecordedHttpResponse:
return RecordedHttpResponse(
status_code=502,
headers={"content-type": "text/plain; charset=utf-8"},
body_b64=base64.b64encode(
f"provider edge could not reach the provider: {message}".encode()
).decode("ascii"),
)
def _recorded_response(outcome: RawResponse | NetworkError) -> RecordedHttpResponse:
match outcome:
case RawResponse(status_code=status_code, headers=headers, body=body):
return RecordedHttpResponse(
status_code=status_code,
headers={
name: value
for name, value in headers.items()
if name not in _RESPONSE_DROPPED_HEADERS
},
body_b64=base64.b64encode(body).decode("ascii"),
)
case NetworkError(message=message):
return RecordedHttpResponse(
status_code=502,
headers={"content-type": "text/plain; charset=utf-8"},
body_b64=base64.b64encode(
f"provider edge could not reach the provider: {message}".encode()
).decode("ascii"),
)
def _buffered_response(
status_code: int, headers: Mapping[str, str], body: bytes
) -> RecordedHttpResponse:
return RecordedHttpResponse(
status_code=status_code,
headers=_filtered_response_headers(headers),
body_b64=base64.b64encode(body).decode("ascii"),
)
def _streamed_response(
status_code: int, headers: Mapping[str, str], chunks: Sequence[bytes], truncated: str | None
) -> RecordedStreamedResponse:
return RecordedStreamedResponse(
status_code=status_code,
headers=_filtered_response_headers(headers),
chunks_b64=[base64.b64encode(chunk).decode("ascii") for chunk in chunks],
truncated=truncated,
)
def _is_streamed(headers: Mapping[str, str]) -> bool:
"""Whether a response is one to relay incrementally, decided by content type.
``transfer-encoding: chunked`` would be the wrong signal: chunking is a
transport choice providers make freely for ordinary JSON, so keying off it would
move nearly every recording to the streamed shape for no gain. The content type
is the header that says "consume this as it arrives", and it is already how the
harness defines streaming everywhere else."""
return "text/event-stream" in _header_value(headers, "content-type").lower()
def _upstream_url(upstream_base: str, upstream_path: str, query: str) -> str:
@ -527,6 +618,75 @@ def _upstream_url(upstream_base: str, upstream_path: str, query: str) -> str:
return f"{url}?{query}" if query else url
def _persist(
backend: RecordEdge, test_key: str, request: RecordedRequest, response: RecordedResponse
) -> None:
with backend.lock:
backend.recorder.record(test_key=test_key, request=request, response=response)
def _recording_steps(
backend: RecordEdge, test_key: str, request: RecordedRequest, head: StreamHead
) -> Generator[StreamStep, None, None]:
"""Record mode's step source: hand each upstream chunk downstream and record it
only once that write has returned, then persist the whole sequence once, under
the lock. Relaying incrementally keeps record exercising the proxy's incremental
parser the way a live run does.
A chunk is appended after its ``yield`` returns, so a downstream that hangs up
mid-relay records exactly the chunks it took and never the one whose write
raised. The ``except`` covers that downstream close and the proxy hanging up
mid-stream; either way the ``finally`` persists what arrived, marked truncated,
because recording a cut-short stream as a clean one would let a later replay
serve a well-terminated fraction of the response and pass a test that should
have gone red."""
collected: list[bytes] = []
truncated: str | None = None
try:
with closing(head.steps) as steps:
for step in steps:
match step:
case StreamChunk():
pass
case StreamTruncation(reason=reason):
truncated = f"upstream: {reason}"
case _:
assert_never(step)
yield step
if isinstance(step, StreamChunk):
collected.append(step.data)
except GeneratorExit:
if truncated is None:
truncated = f"downstream: relay closed after {len(collected)} chunks"
raise
finally:
_persist(
backend,
test_key,
request,
_streamed_response(head.status_code, head.headers, collected, truncated),
)
def _drain_to_response(head: StreamHead) -> RecordedHttpResponse:
"""A response the detection rule did not call streamed: drain the same step
iterator, join the pieces, and store today's buffered shape byte for byte. A
truncation part way through degrades to the synthetic 502 exactly as the eager
read did, because storing half a JSON body under a content-length as though it
were whole would be a worse lie than failing."""
pieces: list[bytes] = []
with closing(head.steps) as steps:
for step in steps:
match step:
case StreamChunk(data=data):
pieces.append(data)
case StreamTruncation(reason=reason):
return _network_error_response(reason)
case _:
assert_never(step)
return _buffered_response(head.status_code, head.headers, b"".join(pieces))
def _handle_record(
backend: RecordEdge,
request: RecordedRequest,
@ -536,23 +696,37 @@ def _handle_record(
headers: Mapping[str, str],
body: bytes | None,
timeout: float,
) -> EdgeReply:
) -> EdgeOutcome:
test_key: Final = current_test_key()
forwarded: Final = {
name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS
}
outcome: Final = forward(method, url, headers=forwarded, body=body, timeout=timeout)
response: Final = _recorded_response(outcome)
with backend.lock:
backend.recorder.record(test_key=current_test_key(), request=request, response=response)
return _reply_from_recorded(response)
head: Final = forward_stream(method, url, headers=forwarded, body=body, timeout=timeout)
match head:
case NetworkError(message=message):
unreachable: Final = _network_error_response(message)
_persist(backend, test_key, request, unreachable)
return _recorded_outcome(unreachable)
case StreamHead() if _is_streamed(head.headers):
return EdgeStream(
status_code=head.status_code,
headers=_filtered_response_headers(head.headers),
steps=_recording_steps(backend, test_key, request, head),
)
case StreamHead():
buffered: Final = _drain_to_response(head)
_persist(backend, test_key, request, buffered)
return _recorded_outcome(buffered)
case _:
assert_never(head)
def _handle_replay(source: ReplaySource, request: RecordedRequest) -> EdgeReply:
def _handle_replay(source: ReplaySource, request: RecordedRequest) -> EdgeOutcome:
try:
interaction: Final = source.next_interaction(request)
except ReplayMiss as miss:
return _text_reply(REPLAY_MISS_STATUS, str(miss))
return _reply_from_recorded(interaction.response)
return _recorded_outcome(interaction.response)
def handle_edge_request(
@ -564,7 +738,7 @@ def handle_edge_request(
body: bytes | None,
*,
timeout: float,
) -> EdgeReply:
) -> EdgeOutcome:
"""The edge's pure core, one HTTP exchange in and out: resolve the mount
prefix, then record (forward + persist) or replay (serve from the bundle).
Socket-free so unit tests exercise every branch without a server."""
@ -618,7 +792,7 @@ class _EdgeHandler(BaseHTTPRequestHandler):
assert isinstance(edge_server, _EdgeHTTPServer)
length: Final = int(self.headers.get("content-length") or "0")
body: Final = self.rfile.read(length) if length else None
reply: Final = handle_edge_request(
outcome: Final = handle_edge_request(
edge_server.backend,
edge_server.mounts,
self.command,
@ -627,6 +801,15 @@ class _EdgeHandler(BaseHTTPRequestHandler):
body,
timeout=edge_server.forward_timeout,
)
match outcome:
case EdgeReply():
self._write_reply(outcome)
case EdgeStream():
self._write_stream(outcome)
case _:
assert_never(outcome)
def _write_reply(self, reply: EdgeReply) -> None:
self.send_response(reply.status_code)
for name, value in reply.headers.items():
self.send_header(name, value)
@ -634,6 +817,32 @@ class _EdgeHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(reply.body)
def _write_stream(self, stream: EdgeStream) -> None:
"""Write a streamed outcome in chunked framing, one transfer chunk per step.
``wbufsize`` is 0 on BaseHTTPRequestHandler, so ``wfile`` sends each write
straight down the socket and no flush is needed. A truncation step ends the
message without its terminator and closes the connection, which the stdlib
shuts down write-side first: the proxy sees a graceful close mid-message,
which is the incomplete chunked read a provider hanging up produces, and not
the reset that could discard the chunks already in flight."""
self.send_response(stream.status_code)
for name, value in stream.headers.items():
self.send_header(name, value)
self.send_header("transfer-encoding", "chunked")
self.end_headers()
with closing(stream.steps) as steps:
for step in steps:
match step:
case StreamChunk(data=data):
self.wfile.write(b"%x\r\n%s\r\n" % (len(data), data))
case StreamTruncation():
self.close_connection = True
return
case _:
assert_never(step)
self.wfile.write(b"0\r\n\r\n")
def log_message(self, format: str, *args: object) -> None:
"""Silence the per-request stderr line BaseHTTPRequestHandler emits."""

View file

@ -22,6 +22,7 @@ from fixture_bundle import (
Manifest,
RecordedHttpResponse,
RecordedRequest,
RecordedStreamedResponse,
StaleBundle,
UnreadableBundle,
UnsafeBundleDir,
@ -186,3 +187,45 @@ class TestRecordAndLoad:
slug_for_test("suite/test_a.py::test_one"),
slug_for_test("suite/test_b.py::test_two"),
}
def test_a_streamed_response_round_trips_through_the_bundle(self, tmp_path: Path) -> None:
"""LIT-5742: the two response shapes share one file format and are told apart
by their ``kind`` tag, so a streamed recording comes back with its chunk list
intact rather than as a buffered response with an empty body."""
root = tmp_path / "bundle"
recorder = prepared(root)
key = "suite/test_mod.py::test_streamed"
recorder.record(
test_key=key,
request=plain_request("/messages"),
response=RecordedStreamedResponse(
status_code=200,
headers={"content-type": "text/event-stream"},
chunks_b64=["Zmly", "c3Q="],
truncated="upstream: hung up",
),
)
loaded = load_bundle(root)
assert isinstance(loaded, LoadedBundle)
(interaction,) = loaded.interactions[slug_for_test(key)]
response = interaction.response
assert isinstance(response, RecordedStreamedResponse)
assert response.chunks_b64 == ["Zmly", "c3Q="]
assert response.truncated == "upstream: hung up"
def test_load_bundle_rejects_a_foreign_format_version(self, tmp_path: Path) -> None:
"""A bundle is written atomically, so a manifest from another format version
means every response inside it may have a shape this code cannot read. Loading
has to refuse it by name, the way the freshness gate does, rather than parse
what it happens to understand."""
root = tmp_path / "bundle"
prepared(root).record(
test_key="suite/test_mod.py::test_old",
request=plain_request("/chat"),
response=plain_response(),
)
write_manifest(root, NOW, format_version=BUNDLE_FORMAT_VERSION - 1)
loaded = load_bundle(root)
assert isinstance(loaded, UnreadableBundle)
assert f"format_version {BUNDLE_FORMAT_VERSION - 1}" in loaded.reason
assert "E2E_FIXTURE_MODE=record" in loaded.reason

View file

@ -11,12 +11,20 @@ computed and closest recorded canonical keys (LIT-5741; the pure canonicalizer
is pinned in test_fixture_canonical.py). Requests are made through
``e2e_http.forward`` so the whole HTTP surface of the edge is exercised; the
pure ``handle_edge_request`` core is pinned socket-free alongside.
Streaming fidelity (LIT-5742) is pinned at the transfer layer, because that is
the only layer where it is visible: a chunked provider sends a known list of
transfer chunks, one of which deliberately splits an SSE event mid-token, and a
raw-socket client reads the edge's own reply back as HTTP chunks. Counting SSE
events at the client would prove nothing, since a coalesced body carries the
same events as a chunk-per-event one.
"""
from __future__ import annotations
import base64
import json
import socket
import threading
from collections.abc import Generator, Mapping
from concurrent.futures import ThreadPoolExecutor
@ -28,7 +36,7 @@ from typing import Final
import pytest
from pydantic import TypeAdapter
from e2e_http import RawResponse, forward
from e2e_http import RawResponse, StreamChunk, forward
from fixture_canonical import canonicalize
from fixture_bundle import (
BundleRecorder,
@ -36,6 +44,7 @@ from fixture_bundle import (
LoadedBundle,
RecordedHttpResponse,
RecordedRequest,
RecordedStreamedResponse,
load_bundle,
prepare_bundle,
slug_for_test,
@ -44,6 +53,8 @@ from fixture_mode import current_test_key
from provider_edge import (
REPLAY_MISS_STATUS,
EdgeBackend,
EdgeReply,
EdgeStream,
ProviderEdge,
RecordEdge,
ReplayEdge,
@ -116,10 +127,175 @@ def fake_provider() -> Generator[_FakeProvider]:
server.server_close()
def provider_url(server: _FakeProvider) -> str:
def provider_url(server: ThreadingHTTPServer) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
STREAM_PATH = "/openai/v1/messages"
STREAM_BODY = json.dumps({"model": "claude", "stream": True}).encode()
MID_EVENT_HEAD = b'data: {"type":"content_bl'
MID_EVENT_TAIL = b'ock_delta","delta":{"text":" two"}}\n\n'
SSE_CHUNKS: tuple[bytes, ...] = (
b'data: {"type":"content_block_delta","delta":{"text":"one"}}\n\n',
MID_EVENT_HEAD,
MID_EVENT_TAIL,
b'data: {"type":"message_delta","usage":{"output_tokens":7}}\n\n',
b"data: [DONE]\n\n",
)
JSON_CHUNKS: tuple[bytes, ...] = (b'{"echo":"one",', b'"chunked":true}')
class _ChunkedProvider(ThreadingHTTPServer):
"""A provider that frames its response as a known list of transfer chunks, each
flushed on its own, and optionally hangs up part way through without writing the
terminating chunk. The chunk list is what the recording has to reproduce."""
daemon_threads = True
def __init__(
self,
bind: tuple[str, int],
*,
chunks: tuple[bytes, ...],
content_type: str,
abort_after: int | None,
) -> None:
super().__init__(bind, _ChunkedProviderHandler)
self.chunks = chunks
self.content_type = content_type
self.abort_after = abort_after
self.hits: list[str] = []
class _ChunkedProviderHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
provider = self.server
assert isinstance(provider, _ChunkedProvider)
length = int(self.headers.get("content-length") or "0")
if length:
self.rfile.read(length)
provider.hits.append(f"{self.command} {self.path}")
self.send_response(200)
self.send_header("content-type", provider.content_type)
self.send_header("transfer-encoding", "chunked")
self.end_headers()
limit = len(provider.chunks) if provider.abort_after is None else provider.abort_after
for chunk in provider.chunks[:limit]:
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
self.wfile.flush()
if limit < len(provider.chunks):
self.close_connection = True
return
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
def log_message(self, format: str, *args: object) -> None:
"""Silence the per-request stderr line BaseHTTPRequestHandler emits."""
@contextmanager
def chunked_provider(
*,
chunks: tuple[bytes, ...] = SSE_CHUNKS,
content_type: str = "text/event-stream",
abort_after: int | None = None,
) -> Generator[_ChunkedProvider]:
server = _ChunkedProvider(
("127.0.0.1", 0), chunks=chunks, content_type=content_type, abort_after=abort_after
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def response_header(head: str, name: str) -> str | None:
wanted = f"{name.lower()}:"
for line in head.splitlines()[1:]:
if line.lower().startswith(wanted):
return line.split(":", 1)[1].strip()
return None
def _read_chunked(sock: socket.socket, buffered: bytes) -> tuple[list[bytes], str]:
"""A chunked body read back one entry per HTTP chunk, plus how the message ended.
The framing is parsed rather than ``recv`` calls counted, because TCP is free to
coalesce two chunks into one segment or split one across two, so a read count
says nothing about how the sender framed the message."""
chunks: list[bytes] = []
try:
while True:
while b"\r\n" not in buffered:
piece = sock.recv(65536)
if not piece:
return chunks, "truncated"
buffered += piece
line, _, buffered = buffered.partition(b"\r\n")
size = int(line.split(b";")[0], 16)
if size == 0:
return chunks, "terminated"
while len(buffered) < size + 2:
piece = sock.recv(65536)
if not piece:
return chunks, "truncated"
buffered += piece
chunks.append(buffered[:size])
buffered = buffered[size + 2 :]
except ConnectionResetError:
return chunks, "reset"
def _read_fixed(sock: socket.socket, buffered: bytes, length: int) -> tuple[list[bytes], str]:
while len(buffered) < length:
piece = sock.recv(65536)
if not piece:
return ([buffered] if buffered else []), "truncated"
buffered += piece
return ([buffered[:length]] if length else []), "terminated"
def raw_stream_post(port: int, path: str, body: bytes) -> tuple[str, list[bytes], str]:
"""POST over a raw socket and read the reply at the transfer layer: the response
head, one entry per HTTP chunk (or the whole body for a content-length reply),
and how the message ended, ``terminated`` when its terminator arrived,
``truncated`` on a graceful close before it, ``reset`` on an abortive one.
``call_edge`` goes through ``forward``, which buffers, so it cannot see any of
this; the streaming tests need the framing itself, so they read the socket."""
sock = socket.create_connection(("127.0.0.1", port), timeout=15)
try:
sock.sendall(
(
f"POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
f"content-type: application/json\r\ncontent-length: {len(body)}\r\n\r\n"
).encode()
+ body
)
buffered = b""
while b"\r\n\r\n" not in buffered:
piece = sock.recv(65536)
if not piece:
break
buffered += piece
head_bytes, _, rest = buffered.partition(b"\r\n\r\n")
head = head_bytes.decode("latin-1")
if (response_header(head, "transfer-encoding") or "").lower() == "chunked":
chunks, ending = _read_chunked(sock, rest)
else:
chunks, ending = _read_fixed(
sock, rest, int(response_header(head, "content-length") or 0)
)
return head, chunks, ending
finally:
sock.close()
@contextmanager
def running_edge(backend: EdgeBackend, mounts: Mapping[str, str]) -> Generator[ProviderEdge]:
running = start_provider_edge(backend, mounts=mounts, bind_host="127.0.0.1")
@ -787,6 +963,242 @@ class TestConcurrentReplay:
assert source.leftover_error(current_test_key()) is None
def record_stream(root: Path, *, abort_after: int | None = None) -> tuple[str, list[bytes], str]:
with chunked_provider(abort_after=abort_after) as provider:
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
return raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY)
def replay_stream(root: Path) -> tuple[str, list[bytes], str]:
with running_edge(ReplayEdge(source=replay_source(root)), REPLAY_MOUNTS) as edge:
return raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY)
def only_recorded_response(root: Path) -> RecordedHttpResponse | RecordedStreamedResponse:
files = this_tests_files(root)
assert len(files) == 1, [file.name for file in files]
return Interaction.model_validate_json(files[0].read_text(encoding="utf-8")).response
def recorded_stream(root: Path) -> RecordedStreamedResponse:
response = only_recorded_response(root)
assert isinstance(response, RecordedStreamedResponse), response
return response
def stream_chunks(response: RecordedStreamedResponse) -> list[bytes]:
return [base64.b64decode(chunk) for chunk in response.chunks_b64]
class TestStreamingFidelity:
"""LIT-5742: a streamed response records and replays as the chunk sequence the
provider actually sent, not as one coalesced body. The unit of fidelity is the
HTTP transfer chunk, so every assertion here is made at the transfer layer."""
def test_a_streamed_response_records_its_chunk_boundaries(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
record_stream(root)
recorded = recorded_stream(root)
assert recorded.status_code == 200
assert stream_chunks(recorded) == list(SSE_CHUNKS)
assert recorded.truncated is None
def test_replay_reproduces_the_recorded_split_points(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
record_stream(root)
head, chunks, ending = replay_stream(root)
assert head.startswith("HTTP/1.1 200 OK")
assert response_header(head, "transfer-encoding") == "chunked"
assert response_header(head, "content-type") == "text/event-stream"
assert len(chunks) > 1
assert chunks == list(SSE_CHUNKS)
assert ending == "terminated"
def test_record_mode_relays_the_stream_chunked_like_replay_will(self, tmp_path: Path) -> None:
"""Record/replay parity at the framing level: what record serves the proxy
must be what replay serves it later, chunk for chunk."""
root = tmp_path / "bundle"
recorded_head, recorded_chunks, recorded_ending = record_stream(root)
replayed_head, replayed_chunks, replayed_ending = replay_stream(root)
assert response_header(recorded_head, "transfer-encoding") == "chunked"
assert recorded_chunks == list(SSE_CHUNKS)
assert recorded_chunks == replayed_chunks
assert recorded_ending == replayed_ending == "terminated"
assert response_header(recorded_head, "transfer-encoding") == response_header(
replayed_head, "transfer-encoding"
)
def test_a_chunk_split_inside_an_event_survives_replay(self, tmp_path: Path) -> None:
"""The anti-tautology test. One provider chunk ends mid-token, so the two
halves of that SSE event must arrive as two chunks; an implementation that
joins the body and re-splits it on event boundaries cannot pass this."""
root = tmp_path / "bundle"
record_stream(root)
_, chunks, _ = replay_stream(root)
split_at = SSE_CHUNKS.index(MID_EVENT_HEAD)
assert chunks[split_at] == MID_EVENT_HEAD
assert chunks[split_at + 1] == MID_EVENT_TAIL
assert b"content_block_delta" not in chunks[split_at]
assert b"content_block_delta" in chunks[split_at] + chunks[split_at + 1]
def test_the_usage_chunk_replays_in_its_recorded_position(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
record_stream(root)
recorded = stream_chunks(recorded_stream(root))
_, replayed, _ = replay_stream(root)
usage_positions = [
index for index, chunk in enumerate(recorded) if b"output_tokens" in chunk
]
assert usage_positions == [
index for index, chunk in enumerate(replayed) if b"output_tokens" in chunk
]
assert usage_positions == [len(replayed) - 2]
assert replayed[-1] == SSE_CHUNKS[-1]
def test_a_mid_stream_upstream_failure_records_the_delivered_chunks_and_the_truncation(
self, tmp_path: Path
) -> None:
"""The provider delivers two chunks and hangs up. The deltas it did send are
the difference between a stream that died and a request that never streamed,
so they are recorded, and the recording says the stream never terminated."""
root = tmp_path / "bundle"
head, chunks, ending = record_stream(root, abort_after=2)
assert head.startswith("HTTP/1.1 200 OK")
assert chunks == list(SSE_CHUNKS[:2])
assert ending == "truncated"
recorded = recorded_stream(root)
assert recorded.status_code == 200
assert stream_chunks(recorded) == list(SSE_CHUNKS[:2])
assert recorded.truncated is not None
assert recorded.truncated.startswith("upstream: ")
def test_a_downstream_disconnect_mid_relay_records_only_the_delivered_chunks(
self, tmp_path: Path
) -> None:
"""The provider keeps sending, but the proxy the edge relays to hangs up after
two chunks. The chunk whose downstream write never landed must stay out of the
recording, or replay would hand back a byte the record run never delivered.
Driven through the pure ``handle_edge_request`` core because a socket client
cannot force these tiny chunks to block mid-write, so closing the relay
generator is the faithful stand-in for the downstream write raising: it lands
the generator on the same suspended yield a broken pipe would."""
root = tmp_path / "bundle"
with chunked_provider() as provider:
outcome = handle_edge_request(
record_backend(root),
{"openai": provider_url(provider)},
"POST",
STREAM_PATH,
{"content-type": "application/json"},
STREAM_BODY,
timeout=10.0,
)
assert isinstance(outcome, EdgeStream)
steps = outcome.steps
first = next(steps)
second = next(steps)
assert isinstance(first, StreamChunk) and isinstance(second, StreamChunk)
assert (first.data, second.data) == (SSE_CHUNKS[0], SSE_CHUNKS[1])
steps.close()
recorded = recorded_stream(root)
assert recorded.status_code == 200
assert stream_chunks(recorded) == [SSE_CHUNKS[0]]
assert recorded.truncated == "downstream: relay closed after 1 chunks"
def test_a_truncated_recording_replays_as_a_truncated_stream(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
record_stream(root, abort_after=2)
head, chunks, ending = replay_stream(root)
assert head.startswith("HTTP/1.1 200 OK")
assert response_header(head, "transfer-encoding") == "chunked"
assert chunks == list(SSE_CHUNKS[:2])
assert ending == "truncated"
def test_a_non_streamed_response_keeps_the_buffered_shape(self, tmp_path: Path) -> None:
"""No-churn guard: an ordinary JSON response records and is framed exactly as
it was before streaming existed."""
root = tmp_path / "bundle"
with fake_provider() as provider:
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
head, chunks, ending = raw_stream_post(edge.port, CHAT_PATH, chat_body("hi"))
response = only_recorded_response(root)
assert isinstance(response, RecordedHttpResponse)
assert response_header(head, "transfer-encoding") is None
assert response_header(head, "content-length") is not None
assert ending == "terminated"
assert json_object(b"".join(chunks))["echo"] == chat_body("hi").decode()
def test_a_chunked_non_sse_response_stays_buffered(self, tmp_path: Path) -> None:
"""Detection keys off the content type, not the transfer encoding: providers
chunk ordinary JSON freely, and treating that as streamed would move nearly
every recording to the chunk-list shape for no gain."""
root = tmp_path / "bundle"
with chunked_provider(chunks=JSON_CHUNKS, content_type="application/json") as provider:
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
head, chunks, _ = raw_stream_post(edge.port, CHAT_PATH, chat_body("hi"))
response = only_recorded_response(root)
assert isinstance(response, RecordedHttpResponse)
assert base64.b64decode(response.body_b64) == b"".join(JSON_CHUNKS)
assert response_header(head, "transfer-encoding") is None
assert b"".join(chunks) == b"".join(JSON_CHUNKS)
def test_replay_of_a_stream_makes_no_provider_connection(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
with chunked_provider() as provider:
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY)
hits_after_record = list(provider.hits)
with running_edge(
ReplayEdge(source=replay_source(root)), {"openai": provider_url(provider)}
) as edge:
_, chunks, ending = raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY)
assert provider.hits == hits_after_record == ["POST /v1/messages"]
assert chunks == list(SSE_CHUNKS)
assert ending == "terminated"
def test_concurrent_streams_each_record_their_own_chunks(self, tmp_path: Path) -> None:
"""The edge relays streams on concurrent threads and each one takes the
recorder lock once, at the end, so neither recording loses or borrows a chunk
from the other."""
root = tmp_path / "bundle"
bodies = [
json.dumps({"model": "claude", "stream": True, "n": index}).encode()
for index in range(2)
]
barrier = threading.Barrier(len(bodies))
with chunked_provider() as provider:
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
def consume(body: bytes) -> tuple[list[bytes], str]:
barrier.wait()
_, chunks, ending = raw_stream_post(edge.port, STREAM_PATH, body)
return chunks, ending
with ThreadPoolExecutor(max_workers=len(bodies)) as executor:
served = list(executor.map(consume, bodies))
assert served == [(list(SSE_CHUNKS), "terminated")] * len(bodies)
files = this_tests_files(root)
assert len(files) == len(bodies)
for file in files:
response = Interaction.model_validate_json(
file.read_text(encoding="utf-8")
).response
assert isinstance(response, RecordedStreamedResponse), response
assert stream_chunks(response) == list(SSE_CHUNKS)
class TestHandleEdgeRequestPure:
def test_unknown_mount_404s_naming_the_known_mounts(self, tmp_path: Path) -> None:
root = tmp_path / "bundle"
@ -800,6 +1212,7 @@ class TestHandleEdgeRequestPure:
b"{}",
timeout=1.0,
)
assert isinstance(reply, EdgeReply)
assert reply.status_code == 404
assert b"unknown provider mount 'bedrock'" in reply.body
assert b"anthropic, openai" in reply.body
@ -824,6 +1237,7 @@ class TestHandleEdgeRequestPure:
json.dumps({"prompt": "x"}).encode(),
timeout=1.0,
)
assert isinstance(reply, EdgeReply)
assert reply.status_code == 201
assert reply.body == b"ok"
assert reply.headers == {"x-upstream": "fake"}

View file

@ -2,6 +2,7 @@
Unit tests for auto router management endpoints
"""
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Final
@ -22,11 +23,22 @@ from litellm.proxy.management_endpoints.auto_router_endpoints import (
from litellm.router import Router
from litellm.types.utils import Choices, Message, ModelResponse
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarksResponse,
AutoRouterRoutingTestRequest,
)
ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="admin")
def _deployment(model_name: str, model: str, *, db_model: bool) -> dict[str, object]:
"""One entry as `Router.model_list` holds it, for either origin."""
return {
"model_name": model_name,
"litellm_params": {"model": model},
"model_info": {"id": f"{model_name}-{int(db_model)}", "db_model": db_model},
}
TIERS = {
"SIMPLE": ["cheap-model"],
"MEDIUM": ["mid-model"],
@ -295,6 +307,34 @@ def test_classifier_plugin_is_not_settable_over_http():
class TestAutoRouterBenchmarks:
from litellm.proxy.management_endpoints.auto_router_endpoints import _SessionAggRow
@pytest.fixture(autouse=True)
def _pin_the_router_global(self, monkeypatch: pytest.MonkeyPatch):
"""Every test here reads proxy_server.llm_router, so no test may inherit a sibling's."""
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", None)
@staticmethod
async def _benchmarks(
monkeypatch: pytest.MonkeyPatch,
rows: Sequence[Mapping[str, object]],
model_list: Sequence[object],
) -> AutoRouterBenchmarksResponse:
from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_benchmarks
class _DB:
async def query_raw(self, sql: str, *params: object):
return rows
monkeypatch.setattr(proxy_server, "prisma_client", type("P", (), {"db": _DB()})())
monkeypatch.setattr(proxy_server, "llm_router", type("R", (), {"model_list": model_list})())
return await get_auto_router_benchmarks(
user_api_key_dict=ADMIN,
start_date="2026-07-01",
end_date="2026-08-01",
)
ROW = _SessionAggRow(
router_name="live-auto",
router_type="complexity",
@ -471,6 +511,113 @@ class TestAutoRouterBenchmarks:
)
assert response.groups[0].tier_turns == expected
@pytest.mark.asyncio
async def test_the_picker_lists_configured_routers_before_they_have_traffic(self, monkeypatch: pytest.MonkeyPatch):
"""A router must be selectable the moment it exists, from either origin.
`live-auto` is the only router the rollup knows about, so before this it was the only
thing the dropdown could offer. Both a config.yaml router and a DB-created one now
arrive zeroed, and neither moves the totals or duplicates the router that has traffic.
"""
response = await self._benchmarks(
monkeypatch,
rows=[self.ROW.model_dump()],
model_list=[
_deployment("live-auto", "auto_router/complexity_router", db_model=False),
_deployment("idle-from-config", "auto_router/complexity_router", db_model=False),
_deployment("idle-from-db", "auto_router/complexity_router", db_model=True),
],
)
by_name = {group.router_name: group for group in response.groups}
assert sorted(by_name) == ["idle-from-config", "idle-from-db", "live-auto"]
assert len(response.groups) == 3
assert response.routers_in_scope == 3
assert by_name["live-auto"].spend == 10.0
assert response.totals.spend == 10.0
assert response.totals.sessions == 4
for name in ("idle-from-config", "idle-from-db"):
idle = by_name[name]
assert idle.router_type == "complexity"
assert (idle.sessions, idle.turns, idle.spend, idle.saved_spend, idle.baseline_spend) == (
0,
0,
0.0,
0.0,
0.0,
)
assert (idle.saved_pct, idle.saved_per_session, idle.avg_turns_per_session) == (0.0, 0.0, 0.0)
assert (idle.cache.hit_rate_pct, idle.cache.coverage_pct) == (0.0, 0.0)
assert idle.cache.same_model.turns == idle.cache.return_to_tier.hits == 0
assert idle.tier_turns == {}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model, listed_as",
[
("auto_router/complexity_router", "complexity"),
("auto_router/adaptive_router", "adaptive"),
("auto_router/quality_router", "quality"),
("auto_router/my-semantic-router", None),
("openai/gpt-5", None),
],
)
async def test_only_kinds_whose_routing_the_rollup_records_are_listed(
self, model: str, listed_as: str | None, monkeypatch: pytest.MonkeyPatch
):
"""A semantic auto-router records no routing decision, so it can never own a session
row; listing it would show $0 forever even while it serves traffic."""
response = await self._benchmarks(
monkeypatch, rows=[], model_list=[_deployment("candidate", model, db_model=True)]
)
assert [group.router_type for group in response.groups] == ([listed_as] if listed_as else [])
@pytest.mark.asyncio
async def test_a_malformed_deployment_is_skipped_rather_than_failing_the_dashboard(
self, monkeypatch: pytest.MonkeyPatch
):
response = await self._benchmarks(
monkeypatch,
rows=[self.ROW.model_dump()],
model_list=[
"not-a-mapping",
{},
{"model_name": "no-params"},
{"model_name": "", "litellm_params": {"model": "auto_router/complexity_router"}},
{"model_name": 7, "litellm_params": {"model": "auto_router/complexity_router"}},
{"model_name": "no-model", "litellm_params": {}},
{"model_name": "unreadable-model", "litellm_params": {"model": None}},
],
)
assert [group.router_name for group in response.groups] == ["live-auto"]
@pytest.mark.asyncio
async def test_two_deployments_of_one_router_are_listed_once(self, monkeypatch: pytest.MonkeyPatch):
"""Tagged variants share a model_name, and the picker selects by name and type."""
response = await self._benchmarks(
monkeypatch,
rows=[],
model_list=[
_deployment("tagged", "auto_router/complexity_router", db_model=True),
_deployment("tagged", "auto_router/complexity_router", db_model=True),
],
)
assert [group.router_name for group in response.groups] == ["tagged"]
def test_the_listed_kinds_match_the_router_types_traffic_can_record(self):
"""The one reason semantic is excluded, pinned against both declarations: a kind the
rollup can record must be listable, and a kind it cannot must not be."""
from typing import get_args, get_type_hints
from litellm.router_utils.auto_router_model_naming import StrategyRouterKind
from litellm.types.utils import StandardLoggingRoutingDecision
recorded = set(get_args(get_type_hints(StandardLoggingRoutingDecision)["router_type"]))
assert set(get_args(StrategyRouterKind)) - {"semantic"} == recorded
# ---------------------------------------------------------------------------
# Shadow eval endpoints

View file

@ -36,7 +36,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
vertex_proxy_route,
vllm_proxy_route,
)
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy._types import LitellmUserRoles, SpecialHeaders, UserAPIKeyAuth
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
@ -554,10 +554,9 @@ class TestVertexAIPassThroughHandler:
@pytest.mark.asyncio
async def test_vertex_passthrough_with_no_default_credentials(self, monkeypatch):
"""
Test that when no default credentials are set, the request fails
"""
"""
Test that when passthrough credentials are set, they are correctly used in the request
With no Vertex credential matching the request, the only Authorization present
is the caller's own virtual key. It must not be forwarded to Google; the
request fails with a clean 401 instead (LIT-5997).
"""
from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import (
PassthroughEndpointRouter,
@ -620,31 +619,25 @@ class TestVertexAIPassThroughHandler:
mock_get_token.return_value = (test_token, "")
mock_auth.return_value = MagicMock()
# Call the route
try:
with pytest.raises(HTTPException) as exc_info:
await vertex_proxy_route(
endpoint=endpoint,
request=mock_request,
fastapi_response=mock_response,
)
except Exception as e:
traceback.print_exc()
print(f"Error: {e}")
# Verify create_pass_through_route was called with correct arguments
mock_create_route.assert_called_once_with(
endpoint=endpoint,
target=f"https://{test_location}-aiplatform.googleapis.com/v1/projects/{test_project}/locations/{test_location}/publishers/google/models/gemini-1.5-flash:generateContent",
custom_headers={"authorization": f"Bearer {test_token}"},
is_streaming_request=False,
)
assert exc_info.value.status_code == 401
mock_create_route.assert_not_called()
@pytest.mark.asyncio
async def test_async_vertex_proxy_route_api_key_auth(self):
"""
Critical
This is how Vertex AI JS SDK will Auth to Litellm Proxy
This is how Vertex AI JS SDK will Auth to Litellm Proxy: the virtual key
arrives in x-litellm-api-key and must reach user_api_key_auth. With no Vertex
credential configured, that virtual key must not be forwarded to Google, so
the request fails with a clean 401 (LIT-5997).
"""
# Mock dependencies
mock_request = Mock()
@ -664,14 +657,15 @@ class TestVertexAIPassThroughHandler:
return_value={"status": "success"}
)
# Call the function
result = await vertex_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
request=mock_request,
fastapi_response=mock_response,
)
with pytest.raises(HTTPException) as exc_info:
await vertex_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
request=mock_request,
fastapi_response=mock_response,
)
# Verify user_api_key_auth was called with the correct Bearer token
assert exc_info.value.status_code == 401
mock_pass_through.assert_not_called()
mock_auth.assert_called_once()
call_args = mock_auth.call_args[1]
assert call_args["api_key"] == "Bearer test-key-123"
@ -1339,7 +1333,9 @@ class TestVertexAIDiscoveryPassThroughHandler:
@pytest.mark.asyncio
async def test_vertex_discovery_proxy_route_api_key_auth(self):
"""
Test that the route correctly handles API key authentication
The virtual key arrives in x-litellm-api-key and must reach user_api_key_auth.
With no Vertex credential configured, that virtual key must not be forwarded to
Google, so the request fails with a clean 401 (LIT-5997).
"""
# Mock dependencies
mock_request = Mock()
@ -1359,14 +1355,15 @@ class TestVertexAIDiscoveryPassThroughHandler:
return_value={"status": "success"}
)
# Call the function
result = await vertex_discovery_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search",
request=mock_request,
fastapi_response=mock_response,
)
with pytest.raises(HTTPException) as exc_info:
await vertex_discovery_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search",
request=mock_request,
fastapi_response=mock_response,
)
# Verify user_api_key_auth was called with the correct Bearer token
assert exc_info.value.status_code == 401
mock_pass_through.assert_not_called()
mock_auth.assert_called_once()
call_args = mock_auth.call_args[1]
assert call_args["api_key"] == "Bearer test-key-123"
@ -3313,7 +3310,11 @@ class TestVertexRawPredictStreamingClassification:
"type": "http",
"method": "POST",
"path": f"/vertex_ai/{endpoint}",
"headers": [(b"content-type", b"application/json")],
"headers": [
(b"content-type", b"application/json"),
(b"x-litellm-api-key", b"test-key"),
(b"authorization", b"Bearer ya29.byo-google-oauth"),
],
"query_string": b"",
},
receive=receive,
@ -3446,6 +3447,327 @@ def test_is_passthrough_request_streaming_tolerates_non_object_bodies(request_bo
assert is_passthrough_request_streaming(request_body) is expected
class TestVertexCredentiallessPassthroughVirtualKeyLeak:
"""Regression coverage for LIT-5997.
With no Vertex credential configured, the passthrough took the
bring-your-own-credentials branch and forwarded the whole incoming header set
to Google, including whichever header carried the caller's LiteLLM virtual key.
LiteLLM accepts that key from several headers (``Authorization``,
``x-litellm-api-key``, ``x-goog-api-key``, ``api-key``, ``x-api-key``), and
``x-goog-api-key`` doubles as a genuine Google credential, so any of them could
leak the proxy's own secret to an upstream provider.
A credential-less request that carries no upstream Google credential must now
fail with a clean 401 and never reach ``create_pass_through_route``. The
proxy-only auth headers Google never consumes (``x-litellm-api-key``,
``api-key``, ``x-api-key``) are dropped by name, and the virtual key is dropped
by value from ``Authorization`` / ``x-goog-api-key``, which may instead carry a
genuine bring-your-own Google credential that must still pass through. The
by-value strip also covers a virtual key sent in the operator-configured
``general_settings.litellm_key_header_name``, whatever that header is named.
"""
VKEY = "sk-litellm-victim-key"
ENDPOINT = (
"v1/projects/my-proj/locations/us-central1/publishers/google/models/"
"gemini-2.5-flash:generateContent"
)
async def _run(
self, monkeypatch, headers: list[tuple[bytes, bytes]]
) -> tuple[HTTPException | None, dict | None]:
from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import (
PassthroughEndpointRouter,
)
async def receive():
return {"type": "http.request", "body": b"{}", "more_body": False}
request = Request(
{
"type": "http",
"method": "POST",
"path": f"/vertex_ai/{self.ENDPOINT}",
"headers": headers,
"query_string": b"",
},
receive=receive,
)
captured: dict = {}
def fake_create_pass_through_route(**kwargs):
captured.update(kwargs)
return AsyncMock(return_value={"status": "success"})
mock_handler = Mock()
mock_handler.get_default_base_target_url.return_value = "https://us-central1-aiplatform.googleapis.com/"
module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints"
monkeypatch.setattr(f"{module}.passthrough_endpoint_router", PassthroughEndpointRouter())
raised: HTTPException | None = None
with (
mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route),
mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=UserAPIKeyAuth(token="hashed"))),
mock.patch(f"{module}.get_vertex_pass_through_handler", return_value=mock_handler),
):
try:
await vertex_proxy_route(
endpoint=self.ENDPOINT,
request=request,
fastapi_response=Response(),
user_api_key_dict=UserAPIKeyAuth(token="hashed"),
)
except HTTPException as exc:
raised = exc
return raised, (captured.get("custom_headers") if captured else None)
@pytest.mark.asyncio
async def test_authorization_bearer_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", f"Bearer {self.VKEY}".encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_x_litellm_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"x-litellm-api-key", self.VKEY.encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_x_goog_api_key_carrying_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-goog-api-key", self.VKEY.encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "the virtual key in x-goog-api-key must not satisfy the gate nor be forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_virtual_key_authenticated_solely_via_x_goog_api_key_is_rejected(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-goog-api-key", self.VKEY.encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "a virtual key that authenticated via x-goog-api-key must be stripped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_byo_google_oauth_token_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", b"Bearer ya29.google-oauth-token"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("authorization") == "Bearer ya29.google-oauth-token"
assert "x-litellm-api-key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ["Bearer", "bearer", "Basic"])
async def test_virtual_key_echoed_in_authorization_with_any_scheme_is_stripped(self, monkeypatch, scheme):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", f"{scheme} {self.VKEY}".encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, f"a virtual key echoed as '{scheme} <key>' in Authorization must be stripped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_byo_x_goog_api_key_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-goog-api-key", b"AIza-google-api-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-google-api-key"
assert "x-litellm-api-key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
async def test_alternate_proxy_auth_headers_are_never_forwarded_to_google(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", b"Bearer ya29.google-oauth-token"),
(b"api-key", b"azure-style-caller-secret"),
(b"x-api-key", b"anthropic-style-caller-secret"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("authorization") == "Bearer ya29.google-oauth-token"
assert "api-key" not in forwarded
assert "x-api-key" not in forwarded
assert "x-litellm-api-key" not in forwarded
forwarded_blob = " ".join(f"{name}:{value}" for name, value in forwarded.items())
assert self.VKEY not in forwarded_blob
assert "azure-style-caller-secret" not in forwarded_blob
assert "anthropic-style-caller-secret" not in forwarded_blob
@pytest.mark.asyncio
@pytest.mark.parametrize(
"credential_header",
sorted(
SpecialHeaders.litellm_credential_header_names()
- {"authorization", "x-goog-api-key", "x-litellm-api-key"}
),
)
async def test_every_non_google_credential_header_is_dropped_by_name(self, monkeypatch, credential_header):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-goog-api-key", b"AIza-real-google-api-key"),
(credential_header.encode(), b"some-distinct-caller-secret-value"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key"
assert credential_header not in forwarded
assert "x-litellm-api-key" not in forwarded
forwarded_blob = " ".join(f"{name}:{value}" for name, value in forwarded.items())
assert self.VKEY not in forwarded_blob
assert "some-distinct-caller-secret-value" not in forwarded_blob
@pytest.mark.asyncio
async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"litellm_key_header_name": "x-company-key"},
):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-company-key", f"Bearer {self.VKEY}".encode()),
(b"x-goog-api-key", b"AIza-real-google-api-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key"
assert "x-company-key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
async def test_virtual_key_in_operator_configured_header_alone_is_rejected(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"litellm_key_header_name": "x-company-key"},
):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-company-key", f"Bearer {self.VKEY}".encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "a virtual key in the custom auth header must not satisfy the gate nor be forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_virtual_key_in_pass_through_configured_header_is_dropped_and_rejected(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "x-company-key"}}]},
):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-company-key", f"Bearer {self.VKEY}".encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "a virtual key in the pass-through key header must be dropped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_authenticated_authorization_is_stripped_over_a_lower_precedence_pass_through_header(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "x-company-key"}}]},
):
raised, forwarded = await self._run(
monkeypatch,
[
(b"authorization", f"Bearer {self.VKEY}".encode()),
(b"x-company-key", b"sk-decoy-lower-precedence-value"),
(b"x-goog-api-key", b"AIza-real-google-api-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key"
assert "authorization" not in forwarded, "Authorization authenticated (higher precedence) so its key must be stripped"
assert "x-company-key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
async def test_virtual_key_in_mapped_route_litellm_user_api_key_header_is_stripped(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"litellm_user_api_key", self.VKEY.encode()),
(b"authorization", b"Bearer ya29.byo-google-oauth"),
(b"x-goog-api-key", b"AIza-real-google-api-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key"
assert forwarded.get("authorization") == "Bearer ya29.byo-google-oauth"
assert "litellm_user_api_key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
async def test_virtual_key_in_mapped_route_litellm_user_api_key_header_alone_is_rejected(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"litellm_user_api_key", self.VKEY.encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "a virtual key in the mapped-route litellm_user_api_key header must be dropped, not forwarded"
assert raised is not None and raised.status_code == 401
class TestGetAzureAISearchIndexFromEndpoint:
"""The operable index is only the segment right after ``indexes``.

View file

@ -5988,6 +5988,77 @@ class TestContextAwareClassifier:
assert _strip_reminder_blocks(text, pairs) == "<<<begin_main>>> why is my tag stripped?"
@pytest.mark.parametrize(
"text,limit,expected",
[
pytest.param("short", 10, "short", id="under-the-limit-is-untouched"),
pytest.param("exact", 5, "exact", id="exactly-the-limit-is-untouched"),
pytest.param(
"Second request with more details and longer text",
30,
"Second re...tails and longer text",
id="over-the-limit-keeps-both-ends",
),
pytest.param("abcdefghij", 4, "a...hij", id="tiny-limit-still-splits"),
pytest.param("abcdefghij", 1, "...j", id="limit-too-small-for-a-head-keeps-the-tail"),
pytest.param("abcdefghij", 0, "...", id="zero-limit-quotes-nothing"),
pytest.param("日本語のテキストと最後の質問", 6, "日...最後の質問", id="cjk-slices-by-character"),
],
)
def test_truncate_keeps_the_end_of_an_over_long_turn(self, text, limit, expected):
"""A cut turn keeps its tail, because that is where a chat turn puts its ask.
Head-only truncation was the shipped behavior and it discarded exactly the part that carries
the difficulty. The degenerate limits are here because the budget hands this function whatever
space is left rather than a configured constant, so it must stay total: a limit too small to
hold a head degrades to tail-only rather than raising or slicing with a negative index.
"""
from litellm.router_strategy.complexity_router.complexity_router import _truncate
assert _truncate(text, limit) == expected
def test_truncate_holds_its_length_budget(self):
"""Cutting to N spends N characters plus the marker, at every N including the degenerate ones.
The marker is the cost of having cut at all, so it is charged uniformly rather than only once
the limit is large enough to hold a head; a caller sizing a cut against a remaining budget can
therefore price it as limit plus marker without special-casing the small end.
"""
from litellm.router_strategy.complexity_router.complexity_router import _TRUNCATION_MARKER, _truncate
text = "x" * 500
assert all(
len(_truncate(text, limit)) == limit + len(_TRUNCATION_MARKER) for limit in (0, 1, 2, 4, 30, 200, 499)
)
def test_clipped_prior_turn_still_carries_the_ask_it_closes_on(self):
"""The reported defect, at the level the classifier sees it.
A prior turn that opens with an incident report and closes with the request routed to the
cheapest tier, because the 200-character cut kept the report and dropped the request. The
quoted turn must carry both ends.
"""
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
turn = (
"We run a multi-region gateway and last night the eu-west pod returned 502s on the "
"streaming path only, for thirty minutes, while non-streaming stayed healthy the whole "
"window and the cooldown map was mid-failover. " + "Filler sentence to push past the cap. " * 4
+ "Now rewrite the streaming retry path and prove it cannot livelock."
)
quoted = _extract_prior_turns(
[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
"go ahead",
3,
200,
False,
)
assert "multi-region gateway" in quoted[0][1]
assert "prove it cannot livelock" in quoted[0][1]
@pytest.mark.parametrize(
"messages,current_ask,window,per_turn_chars,include_assistant,expected",
[
@ -6002,7 +6073,7 @@ class TestContextAwareClassifier:
2,
30,
False,
(("user", "First request"), ("user", "Second request with more detai...")),
(("user", "First request"), ("user", "Second re...tails and longer text")),
id="current-ask-excluded-and-long-turn-marked-as-clipped",
),
pytest.param(
@ -6111,7 +6182,7 @@ class TestContextAwareClassifier:
1,
20,
True,
(("assistant", "a very long plan tha..."),),
(("assistant", "a very...l past the cap"),),
id="assistant-reply-is-clipped-at-per-turn-chars",
),
pytest.param(
@ -6134,7 +6205,8 @@ class TestContextAwareClassifier:
The current ask is excluded by matching it rather than by position, since `aclassify` takes
`prompt` and `messages` separately and a caller may classify other than the newest turn. A turn
cut at per_turn_chars is marked so a clip does not read as an abandoned thought.
over per_turn_chars keeps both ends with its middle elided, so the ask it closes on survives the
cut and the marker does not read as an abandoned thought.
With assistant turns enabled the window is the last N turns of the conversation rather than the
last N asks, which is what makes a plan the assistant called complex visible under a bare "yes".

View file

@ -8565,6 +8565,49 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state():
assert "attempted_targets" not in breadcrumb
_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip"
@pytest.mark.parametrize(
"container_key, request_kwargs",
[
(
"provider_specific_header",
{
"provider_specific_header": {
"custom_llm_provider": "openai",
"extra_headers": {"authorization": _BREADCRUMB_CREDENTIAL_CANARY},
}
},
),
(
"extra_headers",
{"extra_headers": {"authorization": _BREADCRUMB_CREDENTIAL_CANARY}},
),
(
"api_key",
{"api_key": _BREADCRUMB_CREDENTIAL_CANARY},
),
],
)
@pytest.mark.asyncio
async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_key, request_kwargs):
"""log_retry copies kwargs into previous_models, which reaches spend logs and logging callbacks.
Any of these kwargs can carry a client's forwarded Authorization token or a provider key, and a
breadcrumb has no diagnostic use for the raw secret. A denylist of key names is always one new
credential kwarg behind, so log_retry scrubs credential-named values by pattern instead: the
container still reaches the breadcrumb, but the raw secret never does, whatever key holds it."""
router = _cyclic_fallback_router(num_retries=1)
capture = _LogCapture(logging.ERROR)
await _drive_cyclic_fallback(router, capture, **request_kwargs)
assert router.previous_models, "no retry breadcrumbs were recorded"
dumped = json.dumps(router.previous_models, default=str)
assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak"
assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped
@pytest.mark.asyncio
async def test_fallback_traceback_stays_available_at_debug_level():
"""Dropping the stack from the ERROR line is only safe because the fallback path still

View file

@ -2018,11 +2018,6 @@
"count": 1
}
},
"src/components/shared/form/field.tsx": {
"local/filename-pascal-case": {
"count": 1
}
},
"src/components/shared/numerical_input.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2198,6 +2193,11 @@
"count": 1
}
},
"src/components/ui/field.tsx": {
"local/filename-pascal-case": {
"count": 1
}
},
"src/components/ui/hover-card.tsx": {
"local/filename-pascal-case": {
"count": 1

View file

@ -7,7 +7,7 @@ import { z } from "zod/v4";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View file

@ -9,7 +9,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";

View file

@ -29,7 +29,7 @@ import {
} from "@/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm";
import UIAccessControlForm from "@/components/UIAccessControlForm";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { useZodForm } from "@/lib/forms/useZodForm";

View file

@ -26,7 +26,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
export interface AgentSkillFormValue {
id?: string;

View file

@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { SearchSelect } from "@/components/shared/SearchSelect";
import {
createAgentCall,

View file

@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Field, FieldGroup, FieldTitle } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldTitle } from "@/components/ui/field";
import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config";
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
import {

View file

@ -8,7 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TooltipProvider } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { toast } from "@/lib/toast";
import { ArrowLeft } from "lucide-react";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking";

View file

@ -2,7 +2,7 @@ import React from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { AGENT_FORM_CONFIG } from "./agent_config";

View file

@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { useCreateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { applyBudgetPrecision } from "./budgetPrecision";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";

View file

@ -5,7 +5,7 @@ import { useUpdateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { applyBudgetPrecision } from "./budgetPrecision";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";

View file

@ -6,7 +6,7 @@ import { z } from "zod/v4";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -2,7 +2,7 @@ import React from "react";
import { CircleHelp } from "lucide-react";
import { Providers, provider_map } from "@/components/provider_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
import { Field, FieldLabel, FieldTitle } from "@/components/shared/form/field";
import { Field, FieldLabel, FieldTitle } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import {
Combobox,

View file

@ -3,7 +3,7 @@ import { CircleHelp } from "lucide-react";
import { Logo } from "@/components/molecules/logo/Logo";
import { Providers, provider_map } from "@/components/provider_info_helpers";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import {
Combobox,

View file

@ -3,7 +3,7 @@
import { CircleHelp } from "lucide-react";
import React, { useId } from "react";
import { useController, type Control, type ControllerRenderProps, type RegisterOptions } from "react-hook-form";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

View file

@ -30,7 +30,7 @@ import TeamDropdown from "@/components/common_components/team_dropdown";
import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View file

@ -25,7 +25,7 @@ import {
} from "./guardrail_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import {
Combobox,

View file

@ -1,7 +1,7 @@
import React, { useEffect, useId, useState } from "react";
import { getMajorAirlines } from "@/components/networking";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";

View file

@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useState } from "react"
import { useForm } from "react-hook-form";
import { toast } from "@/lib/toast";
import { Logo } from "@/components/molecules/logo/Logo";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View file

@ -9,7 +9,7 @@ import { getGuardrailProviderSpecificParams } from "@/components/networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { PasswordInput } from "@/components/shared/PasswordInput";
import NumericalInput from "@/components/shared/numerical_input";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";

View file

@ -3,7 +3,7 @@
import { Plus, X } from "lucide-react";
import React from "react";
import { useController } from "react-hook-form";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import {
Combobox,

View file

@ -16,7 +16,7 @@ import {
type MountedFormValues,
} from "@/components/common_components/MountedFormField";
import { requiredRule } from "@/components/common_components/formRules";
import { Field, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldLabel } from "@/components/ui/field";
import { invertedSwitchControl, switchControl, tagsControl, textControl } from "./mcpFieldRules";
import { listControl } from "./mcpFormStore";

View file

@ -14,7 +14,7 @@ import {
getProxyBaseUrl,
} from "@/components/networking";
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField, type FormFieldControlProps } from "@/components/shared/form/FormField";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import type { InputSchemaProperty } from "@/components/mcp_tools/types";

View file

@ -5,7 +5,7 @@ import { z } from "zod/v4";
import { MCPServer, MCPUserEnvVarsStatus, MCPUserEnvVarSpec } from "@/components/mcp_tools/types";
import { getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";

View file

@ -5,7 +5,7 @@ import React, { useEffect, useState } from "react";
import { z } from "zod/v4";
import type { MemoryRow } from "@/components/networking";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

View file

@ -5,7 +5,7 @@ import { Policy } from "@/components/policies/types";
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup, FieldLabel, FieldTitle } from "@/components/shared/form/field";
import { FieldGroup, FieldLabel, FieldTitle } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";

View file

@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/shared/table_cells/status_badge";

View file

@ -4,7 +4,7 @@ import { CircleAlert, Inbox } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View file

@ -14,7 +14,7 @@ import { getGuardrailsList } from "@/components/networking";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";

View file

@ -3,7 +3,7 @@ import { Upload as UploadIcon, X } from "lucide-react";
import { z } from "zod/v4";
import { convertPromptFileToJson, createPromptCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -8,7 +8,7 @@ import { Logo } from "@/components/molecules/logo/Logo";
import { toast } from "@/lib/toast";
import { createSearchTool, fetchAvailableSearchProviders } from "@/components/networking";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import {

View file

@ -11,7 +11,7 @@ import {
updateSearchTool,
} from "@/components/networking";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -3,7 +3,7 @@ import { CircleHelp } from "lucide-react";
import { z } from "zod/v4";
import { toast } from "@/lib/toast";
import { registerClaudeCodePlugin } from "@/components/networking";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";

View file

@ -4,7 +4,7 @@ import { ChevronRight, CircleHelp } from "lucide-react";
import React from "react";
import { z } from "zod/v4";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";

View file

@ -10,7 +10,7 @@ import { Tag, TagUpdateRequest } from "@/components/tag_management/types";
import { toast } from "@/lib/toast";
import NumericalInput from "@/components/shared/numerical_input";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { Badge } from "@/components/ui/badge";

View file

@ -8,7 +8,7 @@ import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect";
import { toast } from "@/lib/toast";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { Button } from "@/components/ui/button";

View file

@ -10,7 +10,7 @@ import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelec
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
import type { ObjectPermission } from "@/components/object_permission_types";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";

View file

@ -18,7 +18,7 @@ import {
Member,
} from "@/components/networking";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
Combobox,
ComboboxContent,

View file

@ -14,7 +14,7 @@ import {
VectorStoreFieldConfig,
} from "@/components/vector_store_providers";
import { Logo } from "@/components/molecules/logo/Logo";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import { CircleHelp, Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import {
Combobox,
ComboboxContent,

View file

@ -15,7 +15,7 @@ import {
import { Logo } from "@/components/molecules/logo/Logo";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import {

View file

@ -13,7 +13,7 @@ import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_pro
import { Logo } from "@/components/molecules/logo/Logo";
import VectorStoreTester from "./VectorStoreTester";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View file

@ -6,7 +6,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { exchangeLoginCode, getProxyBaseUrl, switchToWorkerUrl } from "@/components/networking";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

View file

@ -3,7 +3,7 @@ import { CircleAlert, Info } from "lucide-react";
import { z } from "zod/v4";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Field, FieldLabel, FieldGroup } from "@/components/shared/form/field";
import { Field, FieldLabel, FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

View file

@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { TooltipProvider } from "@/components/ui/tooltip";

View file

@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { TooltipProvider } from "@/components/ui/tooltip";

View file

@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";

View file

@ -6,7 +6,7 @@ import { CircleAlert, CirclePlus, Copy, Info, KeyRound, Link } from "lucide-reac
import { parseErrorMessage } from "./shared/errorUtils";
import { toast } from "@/lib/toast";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";

View file

@ -4,7 +4,7 @@ import { getSSOSettings, updateSSOSettings } from "./networking";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "./shared/errorUtils";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import {
GroupClaimField,
MappingToggleField,

View file

@ -6,7 +6,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { toast } from "@/lib/toast";
import React, { useMemo } from "react";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Button } from "@/components/ui/button";

View file

@ -12,7 +12,7 @@ import {
} from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -9,7 +9,7 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/s
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Button } from "@/components/ui/button";

View file

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react";
import { getConfigFieldSetting, updateConfigFieldSetting } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";

View file

@ -5,7 +5,7 @@ import { FormProvider, useFormContext, useWatch, type UseFormReturn } from "reac
import { z } from "zod/v4";
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
import { Logo } from "@/components/molecules/logo/Logo";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Checkbox } from "@/components/ui/checkbox";

View file

@ -9,7 +9,7 @@ import { Input as UIInput } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint";

View file

@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
import { useWatch } from "react-hook-form";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -4,7 +4,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { modelCreationScope } from "@/utils/modelPermissions";
import { Switch } from "@/components/ui/switch";
import { Field, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldLabel } from "@/components/ui/field";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";

View file

@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useWatch } from "react-hook-form";
import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

View file

@ -5,7 +5,7 @@ import { getGlobalLitellmHeaderName } from "@/components/networking";
import { toast } from "@/lib/toast";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -9,7 +9,7 @@ import {
type UseFormGetValues,
} from "react-hook-form";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
export type MountedFormValues = Record<string, unknown>;

View file

@ -4,7 +4,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import GuardrailSelector from "../guardrails/GuardrailSelector";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { Field, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

View file

@ -5,7 +5,7 @@ import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { useForm } from "react-hook-form";
import { userFilterUICall } from "@/components/networking";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import {

View file

@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
import { toast } from "@/lib/toast";
import { CircleHelp } from "lucide-react";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -1,5 +1,5 @@
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Field, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View file

@ -1,7 +1,7 @@
import React, { forwardRef, useImperativeHandle, useMemo } from "react";
import { CircleHelp } from "lucide-react";
import { useForm, type Resolver } from "react-hook-form";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View file

@ -1,6 +1,6 @@
import React from "react";
import { z } from "zod/v4";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -4,7 +4,7 @@ import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/
import { StoreModelInDBParams, useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

View file

@ -10,7 +10,7 @@ import { z } from "zod/v4";
import { KeyResponse } from "../key_team_helpers/key_list";
import { toast } from "@/lib/toast";
import { regenerateKeyCall } from "../networking";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

View file

@ -11,7 +11,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Field, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldLabel } from "@/components/ui/field";
import { Badge } from "@/components/ui/badge";
import {
Combobox,

View file

@ -7,7 +7,7 @@ import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrgan
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";

View file

@ -8,7 +8,7 @@ import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import { toast } from "@/lib/toast";
import type { Organization } from "@/components/networking";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View file

@ -3,7 +3,7 @@
import React, { useEffect, useMemo } from "react";
import { useWatch } from "react-hook-form";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import {
Combobox,

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {

View file

@ -9,7 +9,7 @@ import {
type FieldValues,
} from "react-hook-form";
import { Field, FieldDescription, FieldError, FieldLabel } from "./field";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
export type FormFieldControlProps<
TFieldValues extends FieldValues,

View file

@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
import NumericalInput from "../shared/numerical_input";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { Button } from "@/components/ui/button";

View file

@ -31,7 +31,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SimpleTooltip, TooltipProvider } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint";
import { MultiSelect } from "@/components/shared/MultiSelect";

View file

@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TooltipProvider } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import React, { useEffect, useRef, useState } from "react";
import { hasCapability } from "../../utils/capabilities";

View file

@ -123,3 +123,38 @@ describe("field primitives forward refs to their DOM node", () => {
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
});
describe("FieldLabel wrapping a nested Field", () => {
const NESTED_FIELD_CUES = [
"has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50",
"has-[>[data-slot=field]]:has-[:focus-visible]:border-ring",
"has-[>[data-slot=field]]:has-[:focus-visible]:ring-3",
"has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50",
];
it("carries the hover, focus-visible and disabled cues the card layout depends on", () => {
render(
<FieldLabel data-testid="card-label">
<Field>
<input />
</Field>
</FieldLabel>,
);
expect(screen.getByTestId("card-label")).toHaveClass(...NESTED_FIELD_CUES);
});
it("keeps the cues addressable when a caller passes its own className", () => {
render(
<FieldLabel data-testid="card-label" className="mt-2">
<Field>
<input />
</Field>
</FieldLabel>,
);
const label = screen.getByTestId("card-label");
expect(label).toHaveClass(...NESTED_FIELD_CUES);
expect(label).toHaveClass("mt-2");
});
});

View file

@ -1,17 +1,15 @@
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cva.config";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
const FieldSet = React.forwardRef<HTMLFieldSetElement, React.ComponentPropsWithoutRef<"fieldset">>(
({ className, ...props }, ref) => (
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
ref={ref}
data-slot="field-set"
className={cn(
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
@ -19,28 +17,27 @@ const FieldSet = React.forwardRef<HTMLFieldSetElement, React.ComponentPropsWitho
)}
{...props}
/>
),
);
FieldSet.displayName = "FieldSet";
);
}
const FieldLegend = React.forwardRef<
HTMLLegendElement,
React.ComponentPropsWithoutRef<"legend"> & { variant?: "legend" | "label" }
>(({ className, variant = "legend", ...props }, ref) => (
<legend
ref={ref}
data-slot="field-legend"
data-variant={variant}
className={cn("mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...props}
/>
));
FieldLegend.displayName = "FieldLegend";
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...props}
/>
);
}
const FieldGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
ref={ref}
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
@ -48,9 +45,8 @@ const FieldGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutR
)}
{...props}
/>
),
);
FieldGroup.displayName = "FieldGroup";
);
}
const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
variants: {
@ -67,53 +63,49 @@ const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:tex
},
});
const Field = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div"> & VariantProps<typeof fieldVariants>
>(({ className, orientation = "vertical", ...props }, ref) => (
<div
ref={ref}
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
));
Field.displayName = "Field";
const FieldContent = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
ref={ref}
data-slot="field-content"
className={cn("group/field-content flex flex-1 flex-col gap-1 leading-snug", className)}
{...props}
/>
),
);
FieldContent.displayName = "FieldContent";
);
}
const FieldLabel = React.forwardRef<HTMLLabelElement, React.ComponentPropsWithoutRef<typeof Label>>(
({ className, ...props }, ref) => (
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
ref={ref}
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-3 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
),
);
FieldLabel.displayName = "FieldLabel";
);
}
const FieldTitle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
ref={ref}
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
@ -121,14 +113,12 @@ const FieldTitle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutR
)}
{...props}
/>
),
);
FieldTitle.displayName = "FieldTitle";
);
}
const FieldDescription = React.forwardRef<HTMLParagraphElement, React.ComponentPropsWithoutRef<"p">>(
({ className, ...props }, ref) => (
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
ref={ref}
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
@ -138,14 +128,18 @@ const FieldDescription = React.forwardRef<HTMLParagraphElement, React.ComponentP
)}
{...props}
/>
),
);
FieldDescription.displayName = "FieldDescription";
);
}
const FieldSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ children, className, ...props }, ref) => (
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
ref={ref}
data-slot="field-separator"
data-content={!!children}
className={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)}
@ -161,15 +155,18 @@ const FieldSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWith
</span>
)}
</div>
),
);
FieldSeparator.displayName = "FieldSeparator";
);
}
const FieldError = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div"> & { errors?: Array<{ message?: string } | undefined> }
>(({ className, children, errors, ...props }, ref) => {
const content = React.useMemo(() => {
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
@ -180,7 +177,7 @@ const FieldError = React.forwardRef<
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
if (uniqueErrors.length === 1) {
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
@ -197,7 +194,6 @@ const FieldError = React.forwardRef<
return (
<div
ref={ref}
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
@ -206,18 +202,17 @@ const FieldError = React.forwardRef<
{content}
</div>
);
});
FieldError.displayName = "FieldError";
}
export {
Field,
FieldContent,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View file

@ -3,7 +3,7 @@ import { useState } from "react";
import { z } from "zod/v4";
import { modelPatchUpdateCall } from "./networking";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/shared/form/field";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";

View file

@ -777,6 +777,11 @@ export interface paths {
* overlaps it: its last turn is on or after start_date and its first turn is on or before
* end_date. Overall hit rate is over telemetry-bearing turns; each bucket's hit rate is
* over that bucket's turns.
*
* The rollup supplies the measures, never the list. Which routers appear comes from the
* model registry, so one shows up as soon as it is configured and reads zero until it
* serves traffic, and `routers_in_scope` counts those too rather than only the routers the
* window recorded.
*/
get: operations["get_auto_router_benchmarks_auto_router_benchmarks_get"];
put?: never;
@ -21965,9 +21970,15 @@ export interface components {
* @description Window end day, YYYY-MM-DD UTC, inclusive
*/
end_date: string;
/** Groups */
/**
* Groups
* @description One entry per auto-router, listed from the model registry rather than from the rollup, so a router appears as soon as it is configured and reads zero until it serves traffic. Semantic auto-routers are absent: they record no routing decision, so no session can ever be attributed to them
*/
groups: components["schemas"]["AutoRouterBenchmarkGroup"][];
/** Routers In Scope */
/**
* Routers In Scope
* @description How many groups this response carries. Every auto-router configured on the proxy counts, whether or not it served anything in the window. To count only the routers that did serve traffic, filter `groups` to the entries whose `sessions` is above zero
*/
routers_in_scope: number;
/**
* Start Date