diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index 46aac82473c..1322f50d4af 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -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, ) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 55d6b88363f..850b50227f4 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -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, diff --git a/litellm/router.py b/litellm/router.py index 045fd32847c..6ee474730c9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -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: diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index cbaba69f696..4c07679c5ca 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -274,6 +274,7 @@ _REMINDER_CLOSE: Final = "" _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( diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py index e2469d4c78f..a88ffeec6b5 100644 --- a/litellm/types/management_endpoints/auto_router_endpoints.py +++ b/litellm/types/management_endpoints/auto_router_endpoints.py @@ -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"] diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 5bd6326d8f2..b15a16ffc23 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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). ] diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 15bd2c19ca9..e0ee40a65c5 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -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 ``, 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 `` 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 diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 29778b06d7a..75261b301bd 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -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 diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 03f201e946e..bc76eb3ea7a 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -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), + ) diff --git a/tests/e2e/fixture_bundle.py b/tests/e2e/fixture_bundle.py index aa0ba100b6c..7c9dab1a687 100644 --- a/tests/e2e/fixture_bundle.py +++ b/tests/e2e/fixture_bundle.py @@ -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 = { diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index 7f81a5e3946..d43446e4b5a 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -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") diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index 25a1e8043ed..ceb695ffcd6 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -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.""" diff --git a/tests/e2e/test_fixture_bundle.py b/tests/e2e/test_fixture_bundle.py index b49ab565e39..c01d0b34fb5 100644 --- a/tests/e2e/test_fixture_bundle.py +++ b/tests/e2e/test_fixture_bundle.py @@ -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 diff --git a/tests/e2e/test_provider_edge.py b/tests/e2e/test_provider_edge.py index 14a9fd53393..8ab389ee43c 100644 --- a/tests/e2e/test_provider_edge.py +++ b/tests/e2e/test_provider_edge.py @@ -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"} diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py index 805168c84ac..3a0279ab0fa 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 22843ceae40..a1725005dbf 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -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} ' 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``. diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index cbaac4d89a7..d4333fde163 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -5988,6 +5988,77 @@ class TestContextAwareClassifier: assert _strip_reminder_blocks(text, pairs) == "<<>> 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". diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d00fbf589e3..910b874c2ac 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -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 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 03de475c8c3..a647eedc354 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx index c9ba082f7a6..f8ec3b5e1e7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx index 624c85cc818..a7f2ee18521 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 976fb94acea..7dc3f57bd6f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx index e0152358f8c..15b85001cdc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 9e8ea64fc45..51445ec1bdb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx index 622e5ea7c5c..816df4d805c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx index a8d78f9f53a..eddeeec674b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx index 85d066f4233..04a8b0df9d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx index 5761d7308cd..50f9c7cda3c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx index 3f9de881710..f4597a72fbb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx index 23700470683..eb0d1ada42e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx index c307c176122..4b5c3e77252 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx index 4f4edf6ff30..05748652180 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx index 0edce78b8fd..0afd9aab0cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index c9de758f50d..fae21f8dfc4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 47326786890..29df7c8bf3d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index d77dd216c2a..8445495246d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index 07e75511ecb..f1b5a0c61ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx index 632c2a6b0df..76c91d4c176 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx index a7a247e1495..256049975d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx index da264063839..cb423b435ae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index c077a513757..c637655d665 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx index 14d69c358ba..90d38642a5d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx index 66b33ff2db8..5d871664314 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx index cd238406237..33b75286969 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx index 83dee5c7075..06b11701b2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 403562bc7a3..097a6389721 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx index b32a1b6a6e7..a89182d0a0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index a18b5ecfb98..fed1a88fe98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx index 2deda4b30ee..65079a55f85 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx index 57148ee3a21..c094cd903fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx index e171ed9969b..eddee602f0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index b3e47a63dc6..70669ffc0cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx index 55b8752aab0..6f6bdcb6fe4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx index 6397d3e2819..e8cb358c0cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx index d3eef4dcbbf..269f3b0af39 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx index ed0c08adf38..96771dc6dc4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index fdaa1ed3508..1b3f2c050b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index eae4a3fd799..9d447090381 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx index 7243291d740..db549601212 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 0c7d8cebb4b..be0954a7d24 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index 638122a89e7..60e6154a951 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 3a1d120c8a5..11cf5ffad3d 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx index 77fae186c90..96eb165d43c 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx index 5d89386493d..5b3c4b52b19 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx index ceee32df4aa..34cf1b87717 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 52db83b5dc6..e052a9e0818 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/SCIM.tsx b/ui/litellm-dashboard/src/components/SCIM.tsx index 190887352a5..12b47aeee6a 100644 --- a/ui/litellm-dashboard/src/components/SCIM.tsx +++ b/ui/litellm-dashboard/src/components/SCIM.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index e20d6ab3093..609c0efcd99 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx index 41d41e64da0..33aac24a1ba 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx index a9702451035..3ec18a537c9 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx index f7ec5238a9d..390684d3739 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx index 90a253b9ebe..e468fc2d91a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx index 26d0785529c..5216da382d0 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index a93a8545375..e2eda4adb23 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx index 8d85d46d011..ed502e8d761 100644 --- a/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx +++ b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 7e084a532f4..b6884327a3c 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 46ab0bc2c9f..9be5391040a 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx index 81d62fc398e..91ba75552dc 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx index f0efa885d92..65f603b7efd 100644 --- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx index c210ab3f9fd..a98dca885ca 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 2977a9e4941..3e2d3693d4f 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index 811ff69642a..8e5317d9c1f 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx index c6e6a1a1495..ef5a3e15b52 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index f0b211aecb3..ca8c5697e6e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx index f67413ebee7..772e52dd0a5 100644 --- a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx index 65f607a897f..699d77ccc47 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index a2658985f32..84724d01a1d 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 011cdf33f3a..e1e6dcfa442 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx b/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx index e9d9ba0dcf8..93d552bebe0 100644 --- a/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx +++ b/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx b/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx index a1b42493b77..926b8943a79 100644 --- a/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx index 8b7361fb572..442275c32f6 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 1b1bad88d51..e6337cef9bd 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/shared/form/FormField.tsx b/ui/litellm-dashboard/src/components/shared/form/FormField.tsx index 3b9783333cc..cb8ef5375fd 100644 --- a/ui/litellm-dashboard/src/components/shared/form/FormField.tsx +++ b/ui/litellm-dashboard/src/components/shared/form/FormField.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.tsx index 96ebb5fe0fd..036c4f1cc0b 100644 --- a/ui/litellm-dashboard/src/components/team/EditMembership.tsx +++ b/ui/litellm-dashboard/src/components/team/EditMembership.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 88a893fd3e4..5750add95e3 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 59d003690d3..8f3f6e19ae8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/shared/form/field.test.tsx b/ui/litellm-dashboard/src/components/ui/field.test.tsx similarity index 79% rename from ui/litellm-dashboard/src/components/shared/form/field.test.tsx rename to ui/litellm-dashboard/src/components/ui/field.test.tsx index 54b589ce2f4..4bb3d0ec665 100644 --- a/ui/litellm-dashboard/src/components/shared/form/field.test.tsx +++ b/ui/litellm-dashboard/src/components/ui/field.test.tsx @@ -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( + + + + + , + ); + + expect(screen.getByTestId("card-label")).toHaveClass(...NESTED_FIELD_CUES); + }); + + it("keeps the cues addressable when a caller passes its own className", () => { + render( + + + + + , + ); + const label = screen.getByTestId("card-label"); + + expect(label).toHaveClass(...NESTED_FIELD_CUES); + expect(label).toHaveClass("mt-2"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/form/field.tsx b/ui/litellm-dashboard/src/components/ui/field.tsx similarity index 59% rename from ui/litellm-dashboard/src/components/shared/form/field.tsx rename to ui/litellm-dashboard/src/components/ui/field.tsx index a5bf896313a..ff9f73ceecf 100644 --- a/ui/litellm-dashboard/src/components/shared/form/field.tsx +++ b/ui/litellm-dashboard/src/components/ui/field.tsx @@ -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>( - ({ className, ...props }, ref) => ( +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return (
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", @@ -19,28 +17,27 @@ const FieldSet = React.forwardRef - ), -); -FieldSet.displayName = "FieldSet"; + ); +} -const FieldLegend = React.forwardRef< - HTMLLegendElement, - React.ComponentPropsWithoutRef<"legend"> & { variant?: "legend" | "label" } ->(({ className, variant = "legend", ...props }, ref) => ( - -)); -FieldLegend.displayName = "FieldLegend"; +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} -const FieldGroup = React.forwardRef>( - ({ className, ...props }, ref) => ( +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return (
- ), -); -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 ->(({ className, orientation = "vertical", ...props }, ref) => ( -
-)); -Field.displayName = "Field"; - -const FieldContent = React.forwardRef>( - ({ className, ...props }, ref) => ( +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return (
- ), -); -FieldContent.displayName = "FieldContent"; + ); +} -const FieldLabel = React.forwardRef>( - ({ className, ...props }, ref) => ( +function FieldLabel({ className, ...props }: React.ComponentProps) { + return (