Merge branch 'litellm_internal_staging' of github.com:BerriAI/litellm into litellm_mcp_graph_a

This commit is contained in:
Yuneng Jiang 2026-08-18 21:49:58 -07:00
commit f20443b5e0
No known key found for this signature in database
93 changed files with 5618 additions and 2320 deletions

View file

@ -20,6 +20,7 @@ from litellm.integrations.opentelemetry_utils.gen_ai_semconv import (
OTELSemconvCategory,
parse_semconv_opt_in,
)
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
from litellm.integrations.otel.model.semconv import Metric
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.secret_redaction import redact_string
@ -718,6 +719,28 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
self._handle_failure(kwargs, response_obj, start_time, end_time)
def _start_service_span(self, payload: ServiceLoggerPayload, parent_otel_span: Span, start_time_ns: int) -> Span:
"""Open a service span, named and classified by what the service is.
A datastore call is an outbound CLIENT span carrying ``db.*`` semconv.
Without those a Postgres span says only ``service=postgres``, so the
backend falls back to the transport peer, which for Prisma is the local
query engine on loopback. Everything else stays an INTERNAL span.
"""
from opentelemetry import trace
from opentelemetry.trace import SpanKind
attributes: Final = db_span_attributes(payload.service.value, payload.call_type)
span: Final = self.tracer.start_span(
name=payload.service,
context=trace.set_span_in_context(parent_otel_span),
start_time=start_time_ns,
kind=SpanKind.CLIENT if attributes else SpanKind.INTERNAL,
)
for key, value in attributes.items():
self.safe_set_attribute(span=span, key=key, value=value)
return span
async def async_service_success_hook(
self,
payload: ServiceLoggerPayload,
@ -726,7 +749,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
end_time: datetime | float | None = None,
event_metadata: dict | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
@ -743,12 +765,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = payload.service
service_logging_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
service_logging_span: Final = self._start_service_span(payload, parent_otel_span, _start_time_ns)
self.safe_set_attribute(
span=service_logging_span,
key="call_type",
@ -786,7 +803,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
end_time: float | datetime | None = None,
event_metadata: dict | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
@ -803,12 +819,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = payload.service
service_logging_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
service_logging_span: Final = self._start_service_span(payload, parent_otel_span, _start_time_ns)
self.safe_set_attribute(
span=service_logging_span,
key="call_type",

View file

@ -18,6 +18,7 @@ from litellm.integrations.otel.mappers.utils import (
serialize_messages,
tool_definition_attrs,
)
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
from litellm.integrations.otel.model.payloads import (
GuardrailSpanData,
LLMCallSpanData,
@ -27,7 +28,6 @@ from litellm.integrations.otel.model.payloads import (
ToolDefinition,
)
from litellm.integrations.otel.model.semconv import (
DB,
MCP,
Error,
GenAI,
@ -36,7 +36,6 @@ from litellm.integrations.otel.model.semconv import (
RpcSystem,
Server,
)
from litellm.integrations.otel.model.spans import db_system
class GenAIMapper:
@ -182,12 +181,8 @@ class GenAIMapper:
def _service(cls, data: ServiceSpanData) -> AttributeMap:
attrs: Final = collect(cls._SERVICE_ATTRS, data)
# An outbound datastore call (DB_CALL / CLIENT span) also carries db.*
# semconv. Internal services (router, budget jobs, …) have no db.system,
# so they get only the litellm.service.* keys above.
system: Final = db_system(data.service_name)
if system is not None:
attrs[DB.SYSTEM_NAME] = system
if data.call_type:
attrs[DB.OPERATION_NAME] = data.call_type
# semconv naming the server it reached. Internal services (router, budget
# jobs, …) have no db.system, so they get only the litellm.service.* keys.
attrs.update(db_span_attributes(data.service_name, data.call_type))
attrs.update({f"{LiteLLM.METADATA_PREFIX}{key}": value for key, value in data.event_metadata.items()})
return attrs

View file

@ -0,0 +1,164 @@
"""OTel ``db.*`` / ``server.*`` attributes naming the database litellm talks to.
Prisma reaches PostgreSQL through a query engine listening on loopback, so
transport-level instrumentation attributes the work to ``localhost`` and an
operator cannot tell it is a PostgreSQL call or correlate it with the database's
own metrics. These attributes name the real server on litellm's DB spans.
Only the host, port, database and schema of the DSN are read, so no credential
can reach an exporter.
"""
from __future__ import annotations
import os
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from urllib.parse import ParseResult, parse_qs, unquote, urlparse
from litellm.integrations.otel.model.semconv import DB, Server
from litellm.integrations.otel.model.spans import POSTGRESQL, db_system
_DATABASE_URL_ENV: Final = "DATABASE_URL"
_READ_REPLICA_ENV: Final = "DATABASE_URL_READ_REPLICA"
_DEFAULT_POSTGRES_PORT: Final = 5432
_DEFAULT_POSTGRES_SCHEMA: Final = "public"
_POSTGRES_SCHEMES: Final = frozenset({"postgres", "postgresql"})
_EMPTY_ATTRIBUTES: Final[Mapping[str, str | int]] = MappingProxyType({})
@dataclass(frozen=True, slots=True)
class DatabaseEndpoint:
"""The non-sensitive identity of a PostgreSQL server, parsed from a DSN."""
address: str | None
port: int | None
namespace: str | None
def parse_database_endpoint(url: str | None) -> DatabaseEndpoint | None:
"""Parse a PostgreSQL DSN into its exportable endpoint identity.
Returns ``None`` for an absent, malformed or non-PostgreSQL URL rather than
raising: an unparseable DSN must degrade to a span without endpoint
attributes, never break the request that emitted it.
"""
if not url:
return None
try:
parsed: Final = urlparse(url)
if parsed.scheme not in _POSTGRES_SCHEMES:
return None
query: Final = parse_qs(parsed.query)
raw_database: Final = (parsed.path or "").lstrip("/")
if _is_misparsed_authority(parsed, url, raw_database):
return None
# ``host=`` beats the netloc: it is how libpq names a Unix socket
# directory and how the Cloud SQL connector sits behind a localhost
# netloc, where the netloc is the very answer this module replaces.
address: Final = _first(query.get("host")) or parsed.hostname
# ``port=`` accompanies ``host=`` in a libpq URI, so honour it the same way.
port: Final = _port(_first(query.get("port")), parsed.port) if address else None
namespace: Final = _namespace(unquote(raw_database), _first(query.get("schema")))
except ValueError:
return None
if address is None and namespace is None:
return None
return DatabaseEndpoint(address=address, port=port, namespace=namespace)
def _is_misparsed_authority(parsed: ParseResult, url: str, raw_database: str) -> bool:
"""Whether the URL authority may have been truncated by an unencoded character.
``/``, ``#`` or ``?`` in a password ends the netloc early, so urlparse hands
back the username as the host, the leading digits of the password as the
port, and the rest of the credential as the path, query or fragment. The
stranded userinfo ``@`` is the only surviving evidence.
A database name cannot hold an unencoded slash either, so a second path
segment is the same evidence.
A DSN that carries the at-sign in a query parameter instead, such as
``?application_name=svc@prod``, is indistinguishable from a mis-split by any
property of the parse: both leave no userinfo, a host, a port and a path.
Since guessing wrong publishes a credential fragment to a tracing backend,
that ambiguity resolves to refusing the endpoint. Such a DSN loses
``server.address`` and ``db.namespace`` and keeps the rest of the span,
which is the cheaper error of the two. Percent-encode the at-sign to keep
them.
"""
if "/" in raw_database:
return True
return "@" in url and "@" not in parsed.netloc
def _first(values: Sequence[str] | None) -> str:
return values[0] if values else ""
def _port(from_query: str, from_netloc: int | None) -> int:
return int(from_query) if from_query.isdigit() else (from_netloc or _DEFAULT_POSTGRES_PORT)
def _namespace(database: str, schema: str) -> str | None:
"""``{database}|{schema}`` per the PostgreSQL semconv, dropping absent halves.
Only Prisma's literal default schema stays implicit. The match is
case-sensitive because Prisma quotes the name, so ``?schema=PUBLIC`` builds
a second schema alongside ``public`` and the two must not collapse to one
namespace.
"""
qualifier: Final = "" if schema == _DEFAULT_POSTGRES_SCHEMA else schema
return "|".join(part for part in (database, qualifier) if part) or None
def postgres_endpoint() -> DatabaseEndpoint | None:
"""The PostgreSQL endpoint the process is currently connected to.
Read from ``os.environ`` on every span, deliberately, on both counts.
The environment is what Prisma itself connects with, so the span cannot
disagree with the connection; ``get_secret_str`` would consult a configured
secret manager first and could name a different server than the one serving
the query. And the value is not static: the RDS IAM refresh rebuilds the URL
from ``DATABASE_HOST``/``PORT``/``NAME``/``SCHEMA`` every rotation, the
reconnect path re-reads ``DATABASE_URL``, and the DB-backed
``environment_variables`` config overlay can rewrite any of them after
startup, so a value cached for the process lifetime goes stale against a
connection that has genuinely moved. Nothing is memoized either: a cache
keyed on the URL would hold a rotated credential past its rotation, and the
parse is a single ``urlparse`` on a short string.
A configured read replica yields ``None``: ``RoutingPrismaWrapper`` picks
reader or writer per Prisma call, underneath the span, so naming the writer
would attribute replica reads to the primary.
"""
if os.environ.get(_READ_REPLICA_ENV):
return None
return parse_database_endpoint(os.environ.get(_DATABASE_URL_ENV, ""))
def db_span_attributes(service_name: str, call_type: str | None = None) -> Mapping[str, str | int]:
"""The ``db.*``/``server.*`` attributes for a datastore service call.
Empty for services that are not outbound datastore calls. Endpoint
attributes are PostgreSQL-only: ``DATABASE_URL`` says nothing about where
the redis-backed services point. ``db.system`` rides alongside the current
``db.system.name`` because Datadog's OTLP intake still types a database span
from the older key.
"""
system: Final = db_system(service_name)
if system is None:
return _EMPTY_ATTRIBUTES
endpoint: Final = postgres_endpoint() if system == POSTGRESQL else None
pairs: Final[tuple[tuple[str, str | int | None], ...]] = (
(DB.SYSTEM_NAME, system),
(DB.SYSTEM_LEGACY, system),
(DB.OPERATION_NAME, call_type),
(Server.ADDRESS, endpoint.address if endpoint is not None else None),
(Server.PORT, endpoint.port if endpoint is not None else None),
(DB.NAMESPACE, endpoint.namespace if endpoint is not None else None),
)
return MappingProxyType({key: value for key, value in pairs if value})

View file

@ -238,7 +238,11 @@ class DB:
"""
SYSTEM_NAME: Final = "db.system.name"
# Superseded by SYSTEM_NAME, dual-emitted because Datadog's OTLP intake
# still infers a span's database type from this key.
SYSTEM_LEGACY: Final = "db.system"
OPERATION_NAME: Final = "db.operation.name"
NAMESPACE: Final = "db.namespace"
class HTTP:

View file

@ -115,10 +115,12 @@ SPAN_REGISTRY: Final[dict[SpanRole, SpanSpec]] = {
# redis-backed spend queues. Any service not mapped here is litellm-internal work
# and stays an INTERNAL ``SERVICE`` span. This table is the single source of
# datastore knowledge — both the role classifier and the mapper read it.
POSTGRESQL: Final = "postgresql"
_DB_SYSTEM_BY_SERVICE: Final[dict[str, str]] = {
"redis": "redis",
"postgres": "postgresql",
"batch_write_to_db": "postgresql",
"postgres": POSTGRESQL,
"batch_write_to_db": POSTGRESQL,
}

View file

@ -1816,16 +1816,19 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
This helper uses ``json.JSONDecoder.raw_decode()`` to walk the string
and extract each JSON object individually.
The walk degrades gracefully: if the string is malformed or truncated
(e.g. a stream that ended mid-tool-call), whatever complete objects were
parsed before the bad tail are returned and the remainder is discarded
with a warning, rather than raising. The sole caller
(``_convert_to_bedrock_tool_call_invoke``) treats an empty result as
``input={}`` so the conversation can continue instead of hard-failing.
Returns
-------
list[dict]
A list of parsed dicts one per JSON object found. If *raw* is
empty or whitespace-only, an empty list is returned.
Raises
------
json.JSONDecodeError
If the string contains text that cannot be parsed as JSON at all.
empty, whitespace-only, or wholly unparseable, an empty list is
returned.
"""
import json
@ -1845,7 +1848,17 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
if idx >= length:
break
obj, end_idx = decoder.raw_decode(raw, idx)
try:
obj, end_idx = decoder.raw_decode(raw, idx)
except json.JSONDecodeError as e:
verbose_logger.warning(
"split_concatenated_json_objects: discarding unparseable tool-call "
"arguments tail after %d complete object(s); decode_start=%d error=%s",
len(results),
idx,
e,
)
break
if isinstance(obj, dict):
results.append(obj)
else:

View file

@ -3712,7 +3712,13 @@ def _convert_to_bedrock_tool_call_invoke(
_parts_list.append(cache_point_block)
return _parts_list
except Exception as e:
raise Exception(f"Unable to convert openai tool calls={tool_calls} to bedrock tool calls. Received error={e}")
tool_call_ids: Final = tuple(tool.get("id") for tool in tool_calls if isinstance(tool, dict))
raise litellm.BadRequestError(
message=f"Unable to convert openai tool calls with ids={tool_call_ids} to bedrock tool calls. "
f"Received error={e}",
model=model or "",
llm_provider="bedrock",
) from e
def _append_bedrock_tool_result_media_block(

View file

@ -14,6 +14,7 @@ import httpx
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.search.transformation import (
@ -22,7 +23,7 @@ from litellm.llms.base_llm.search.transformation import (
)
from litellm.secret_managers.main import get_secret_str
_UrlEncodableParams: Final = TypeAdapter(dict[str, str | int | bool])
_UrlEncodableParams: Final = TypeAdapter(dict[str, str | int | float | bool])
_StrList: Final = TypeAdapter(list[str])
_StrFrozenSet: Final = TypeAdapter(frozenset[str])
@ -94,16 +95,16 @@ class TinyfishSearchConfig(BaseSearchConfig):
TinyFish equivalents:
- ``query`` (str or list[str]) ``query`` (list joined by spaces)
- ``country`` ``location``
- ``search_domain_filter`` (list[str]) folded into the query as
``(<query>) (site:a OR site:b ...)`` (TinyFish has no first-class
field today; see ML-2084 for the planned ``include_domains``)
- ``search_domain_filter`` (list[str]) folded into the query using
search operators
- ``max_results`` not sent on the wire; stashed on
``self._caller_max_results`` for client-side response truncation
(TinyFish doesn't honor it server-side)
- ``max_tokens_per_page`` silently dropped (no TinyFish equivalent)
Any other ``optional_params`` keys are forwarded to TinyFish as-is.
dict/list values are JSON-encoded so they survive ``urlencode``.
dict and list values are JSON-encoded so structured payloads survive
``urlencode``.
Returns:
``{_TINYFISH_PARAMS_KEY: <dict of querystring entries>}``.
@ -144,14 +145,12 @@ class TinyfishSearchConfig(BaseSearchConfig):
supported_perplexity: Final = _StrFrozenSet.validate_python(raw_supported)
for param, value in optional_params.items():
if param not in supported_perplexity and param not in request_data:
# `fetch` expects a JSON-encoded object on the wire; accept the
# natural Python dict form and serialize here so callers don't
# have to pre-stringify.
if isinstance(value, dict):
# Serialize dicts/lists as JSON so structured params survive urlencode.
if isinstance(value, (dict, list)):
value = json.dumps(value, separators=(",", ":"))
# `urlencode` would render Python bool as "True"/"False"
# (capitalized). ux-labs validators require lowercase
# "true"/"false" (e.g. `include_thumbnail`); normalize here.
# (capitalized). TinyFish Search's bool params require lowercase
# "true"/"false" strings on the wire; normalize here.
elif isinstance(value, bool):
value = "true" if value else "false"
request_data[param] = value
@ -167,17 +166,35 @@ class TinyfishSearchConfig(BaseSearchConfig):
"""
Transform a TinyFish response to LiteLLM's unified ``SearchResponse``.
Mappings (per-result):
- ``title`` ``SearchResult.title`` (defaults to ``""`` if missing/null)
- ``url`` ``SearchResult.url`` (defaults to ``""``)
- ``snippet`` ``SearchResult.snippet`` (defaults to ``""``)
- all other per-result fields (``position``, ``site_name``,
``thumbnail_url``, ``fetch``, ``fetch_error``, ...) ride through as
extras on ``SearchResult`` via its ``extra="allow"`` config.
Per-result field handling:
- ``title``, ``url``, ``snippet`` are declared on ``SearchResult`` and
populated by ``SearchResponse.model_validate`` when present. Missing
or ``None`` values are defaulted to ``""`` beforehand by
``_default_missing_result_fields`` so a degraded result flows through
instead of failing the whole call.
- All undeclared per-result fields (``position``, ``site_name``, and
any others TinyFish returns) ride through as extras via
``SearchResult``'s ``extra="allow"`` config — accessible as
attributes on the result object or enumerable via
``result.model_extra``.
Top-level ``parameter_warnings`` (see ML-2085) is read when present and
each entry is re-fired via ``verbose_logger.warning``. Absent or
malformed entries are silently skipped never throws.
Top-level ``parameter_warnings`` is read when present and each entry
is re-fired via ``verbose_logger.warning``. Absent or malformed
entries are silently skipped never throws.
Top-level extras (``query``, ``total_results``, ``page``, and any
future TinyFish additions) ride through via
``SearchResponse.extra="allow"``. The validated response is returned
in place after truncating ``results`` to the caller's ``max_results``,
so every field pydantic populated survives regardless of which
storage bucket (declared attribute or ``__pydantic_extra__``) holds it.
TinyFish response headers (e.g. ``x-request-id``, ``retry-after``,
``x-ratelimit-limit`` httpx normalizes header names to lowercase)
are stashed on ``response._hidden_params["headers"]`` (raw) and
``response._hidden_params["additional_headers"]`` (sanitized via
``process_response_headers``) so callers can correlate a search with
server-side logs.
Error paths routed through ``self._wrap_error`` for uniform
``"TinyFish Search: <msg>. See <docs> for details."`` wrapping:
@ -223,7 +240,12 @@ class TinyfishSearchConfig(BaseSearchConfig):
_emit_parameter_warnings(parsed)
max_results: Final = self._caller_max_results or _TINYFISH_RESULT_CAP
return SearchResponse(results=list(parsed.results[:max_results]))
parsed.results = parsed.results[:max_results]
raw_headers: Final = dict(raw_response.headers)
hidden: Final = parsed._hidden_params # pyright: ignore[reportPrivateUsage] # sole hidden-params channel
hidden["headers"] = raw_headers
hidden["additional_headers"] = process_response_headers(raw_headers)
return parsed
def _wrap_error(
self,
@ -243,9 +265,9 @@ class TinyfishSearchConfig(BaseSearchConfig):
carry the ``TinyFish Search:`` prefix the bare error already names
the host in the URL, so attribution is implicit there.
"""
# ux-labs frontend wraps every error body as {"error": {"code", "message", "details"?}}.
# TinyFish Search wraps every error body as {"error": {"code", "message", "details"?}}.
# Best-effort unwrap to surface the inner message; fall back to the raw body
# for non-ux-labs responses (CDN HTML pages, other JSON envelopes, plain text).
# for other envelope shapes (CDN HTML pages, other JSON envelopes, plain text).
inner_message = error_message
try:
body: Final[object] = json.loads(error_message) # any-ok: json.loads -> Any
@ -290,7 +312,7 @@ def _default_missing_result_fields(raw_json: object) -> None:
def _emit_parameter_warnings(parsed: SearchResponse) -> None:
"""Re-fire TinyFish-side ``parameter_warnings`` (see ML-2085) as warnings.
"""Re-fire TinyFish-side ``parameter_warnings`` as warnings.
Defensive: skip silently on any shape we don't recognize so a malformed
entry (or an early/partial rollout of the field) never throws.

View file

@ -594,13 +594,16 @@ def _build_aggregated_where_clause(
sql_params.append(adjusted_end)
p += 1
# Optional entity filter
# Optional entity filter; an empty list must match nothing, not everything
if entity_id is not None:
if isinstance(entity_id, list):
placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id)))
sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})')
sql_params.extend(entity_id)
p += len(entity_id)
if entity_id:
placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id)))
sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})')
sql_params.extend(entity_id)
p += len(entity_id)
else:
sql_conditions.append("FALSE")
else:
sql_conditions.append(f'"{entity_id_field}" = ${p}')
sql_params.append(entity_id)

View file

@ -229,6 +229,25 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
verbose_proxy_logger.warning("Error calculating image editing cost: %s", e)
return 0.0
@staticmethod
def _calculate_embeddings_cost(
litellm_model_response: EmbeddingResponse,
model: str,
custom_llm_provider: str,
) -> float:
try:
return litellm.completion_cost(
completion_response=litellm_model_response,
model=model,
custom_llm_provider=custom_llm_provider,
call_type="aembedding",
)
except Exception as e: # noqa: BLE001 # completion_cost raises bare Exception for unmapped models; cost failure must never drop the spend log
verbose_proxy_logger.warning(
"Error calculating embeddings cost for model %s, logging spend with cost 0: %s", model, e
)
return 0.0
@staticmethod
def _build_responses_api_response_and_cost(
model: str,
@ -351,11 +370,10 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
model_response_object=EmbeddingResponse(),
response_type="embedding",
)
response_cost = litellm.completion_cost(
completion_response=litellm_model_response,
response_cost = OpenAIPassthroughLoggingHandler._calculate_embeddings_cost(
litellm_model_response=litellm_model_response,
model=model,
custom_llm_provider=custom_llm_provider,
call_type="aembedding",
)
litellm_model_response._hidden_params["response_cost"] = response_cost
elif is_image_generation:
@ -471,6 +489,12 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
except Exception as e:
verbose_proxy_logger.error("Error in OpenAI passthrough cost tracking: %s", e)
if not is_chat_completions:
unbilled_result: Final[PassThroughEndpointLoggingTypedDict] = {
"result": None,
"kwargs": kwargs,
}
return unbilled_result
# Fall back to base handler without cost tracking
base_handler = OpenAIPassthroughLoggingHandler()
return base_handler.passthrough_chat_handler(

View file

@ -11463,8 +11463,10 @@ class Router:
deployment the strategy was registered from via its (model_name, tags)
pair.
With tag filtering enabled, strategies that all carry real tags matching
none of the request's do not capture it when the name also has plain
With tag filtering enabled, router-wide or by the request's
enable_tag_filtering (which the proxy sets from key/team
router_settings), strategies that all carry real tags matching none of
the request's do not capture it when the name also has plain
deployments: returning None hands the request to ordinary tag-aware
deployment selection.
"""
@ -11487,8 +11489,9 @@ class Router:
for tagged in candidates:
if "default" in tagged.tags:
return tagged
request_scoped_filtering: Final = request_kwargs.get("enable_tag_filtering") is True
if (
self.enable_tag_filtering
(self.enable_tag_filtering or request_scoped_filtering)
and all(tagged.tags for tagged in candidates)
and self._model_name_has_plain_deployments(model)
):

View file

@ -253,6 +253,7 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li
PROVIDER_SCOPED_RESOURCE_KEYS: Final = ("input_file_id", "training_file")
PROVIDER_SCOPED_CREATION_FUNCTION_NAMES: Final = frozenset({"_acreate_file"})
def _get_fallback_target_model_group(fallback_entry: str | Mapping[str, object]) -> str | None:
@ -274,6 +275,18 @@ def references_provider_scoped_resource(kwargs: Mapping[str, object]) -> bool:
return any(kwargs.get(key) for key in PROVIDER_SCOPED_RESOURCE_KEYS)
def creates_provider_scoped_resource(kwargs: Mapping[str, object]) -> bool:
"""
True when the request creates a resource that will live under one provider's credentials.
A file uploaded for batches or fine-tuning is stored in the account of the deployment
that handled it, and its id is only usable against the model group the caller named.
Letting the upload fall back to a different model group silently stores the file with
the wrong provider, and every later use of the returned id fails.
"""
return getattr(kwargs.get("original_function"), "__name__", None) in PROVIDER_SCOPED_CREATION_FUNCTION_NAMES
async def run_async_fallback(
*args: tuple[Any],
litellm_router: LitellmRouter,
@ -322,7 +335,9 @@ async def run_async_fallback(
metadata_variable_name: Final = _get_router_metadata_variable_name(
function_name=getattr(kwargs.get("original_function"), "__name__", None)
)
same_model_group_only: Final = references_provider_scoped_resource(kwargs)
same_model_group_only: Final = references_provider_scoped_resource(kwargs) or creates_provider_scoped_resource(
kwargs
)
# Read out of kwargs and narrowed here rather than declared as a parameter: every caller
# reaches this function by spreading a loosely-typed kwargs dict, so a declared parameter
# would carry an annotation that no call site can actually be checked against.

View file

@ -1631,8 +1631,20 @@ class PromptTokensDetailsWrapper(
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
extra_fields: Final = self.model_extra
nested_cache_creation_input_tokens: Final = (
extra_fields.get("cache_creation_input_tokens") if extra_fields is not None else None
)
self.cache_write_tokens = (
self.cache_write_tokens if self.cache_write_tokens is not None else self.cache_creation_tokens
self.cache_write_tokens
if self.cache_write_tokens is not None
else (
self.cache_creation_tokens
if self.cache_creation_tokens is not None
else (
nested_cache_creation_input_tokens if isinstance(nested_cache_creation_input_tokens, int) else None
)
)
)
if self.character_count is None:
del self.character_count

View file

@ -27,6 +27,16 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import (
from litellm.proxy.utils import InternalUsageCache
def _build_batch_limiter() -> _PROXY_BatchRateLimiter:
internal_usage_cache = InternalUsageCache(dual_cache=DualCache())
return _PROXY_BatchRateLimiter(
internal_usage_cache=internal_usage_cache,
parallel_request_limiter=_PROXY_MaxParallelRequestsHandler_v3(
internal_usage_cache=internal_usage_cache
),
)
def get_expected_batch_file_usage(file_path: str) -> tuple[int, int]:
"""
Helper function to calculate expected request count and token count from a batch JSONL file.
@ -69,10 +79,7 @@ async def test_batch_rate_limits():
"""
litellm._turn_on_debug()
CUSTOM_LLM_PROVIDER = "openai"
BATCH_LIMITER = _PROXY_BatchRateLimiter(
internal_usage_cache=None,
parallel_request_limiter=None,
)
BATCH_LIMITER = _build_batch_limiter()
file_name = "openai_batch_completions.jsonl"
_current_dir = os.path.dirname(os.path.abspath(__file__))
@ -580,10 +587,7 @@ async def test_batch_rate_limiter_without_user_context(tmp_path):
CUSTOM_LLM_PROVIDER = "openai"
# Setup
BATCH_LIMITER = _PROXY_BatchRateLimiter(
internal_usage_cache=None,
parallel_request_limiter=None,
)
BATCH_LIMITER = _build_batch_limiter()
# Create a simple batch file
batch_content = """{"custom_id": "request-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]}}"""

View file

@ -18,6 +18,16 @@
- {id: reliability.routing.usage_based.picks_under_tpm, module: reliability, tier: P0, behavior: routing, variant: usage_based, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_tpm_rpm_v2.py", rationale: "Routes to lowest-TPM deployment; prevents over-allocation"}
- {id: reliability.routing.least_busy.picks_lowest_traffic, module: reliability, tier: P1, behavior: routing, variant: least_busy, assertions: [picks_lowest_traffic], exercised_on: [chat_completions, messages], source: "router_strategy/least_busy.py", rationale: "Fewest in-flight requests"}
- {id: reliability.routing.complexity_llm_classifier.routes_by_llm_tier, module: reliability, tier: P1, behavior: routing, variant: complexity_llm_classifier, assertions: [routes_by_llm_tier], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py", fail_before_fix: proven, rationale: "v2 auto-router LLM complexity classifier runs over the proxy and routes by semantic tier instead of silently crashing on absent litellm_metadata and falling back to heuristic scoring"}
- {id: reliability.routing.tagged_marker.request_tag_selects_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [request_tag_selects_marker], exercised_on: [chat_completions], source: "litellm/router.py:11445", rationale: "Tagged request selects the tagged strategy marker under a shared model_name instead of the plain deployment registered first (GitHub issue #36619)"}
- {id: reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [untagged_request_served_by_plain_deployment], exercised_on: [chat_completions, messages, responses], source: "litellm/router.py:11445", rationale: "Untagged requests to a shared model_name are served by the plain deployment on every call, never captured or errored by the tagged marker (GitHub issue #36620)"}
- {id: reliability.routing.tagged_marker.header_tag_selects_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [header_tag_selects_marker], exercised_on: [messages], source: "litellm/router.py:11445", rationale: "A request tagged only via the x-litellm-tags header selects the tagged marker on Anthropic-native /v1/messages (GitHub issue #36621)"}
- {id: reliability.routing.tagged_marker.untagged_tier_deployments_still_served, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [untagged_tier_deployments_still_served], exercised_on: [chat_completions, messages], source: "litellm/router_strategy/tag_based_routing.py:433", rationale: "Routing tags the marker consumed no longer constrain deployment selection inside the routed tier group, so untagged tier deployments serve the rewrite (GitHub issue #36621)"}
- {id: reliability.routing.tagged_marker.tag_semantics_stay_strict, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [tag_semantics_stay_strict], exercised_on: [chat_completions], source: "litellm/router_strategy/tag_based_routing.py:299", rationale: "Tag consumption must not loosen strict semantics: a tagged call aimed straight at an untagged deployment still gets the 401 tags-configuration denial"}
- {id: reliability.routing.tagged_marker.responses_input_routes_through_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [responses_input_routes_through_marker], exercised_on: [responses], source: "litellm/router.py:11489", rationale: "Tagged /v1/responses (header or litellm_metadata.tags, string or list input) routes through the marker to its tier, extending the GitHub issues #36620/#36621 tag split to the Responses surface"}
- {id: reliability.routing.tagged_marker.alias_connection_params_stay_with_tier, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [alias_connection_params_stay_with_tier], exercised_on: [chat_completions], source: "litellm/router.py:11567", rationale: "An api_key or api_base on the marker alias is never forwarded onto the routed request; the tier deployment calls its provider with its own credential (GitHub PR #36626)"}
- {id: reliability.routing.semantic_auto_router.responses_input_routed, module: reliability, tier: P0, behavior: routing, variant: semantic_auto_router, assertions: [responses_input_routed], exercised_on: [responses], source: "litellm/router_strategy/auto_router/auto_router.py:131", fail_before_fix: proven, rationale: "/v1/responses input is resolved into messages for the semantic auto-router pre-routing hook instead of failing 400 Unmapped LLM provider auto_router (GitHub PR #37333)"}
- {id: reliability.routing.strategy_alias.custom_pricing_ignored, module: reliability, tier: P1, behavior: routing, variant: strategy_alias, assertions: [custom_pricing_ignored], exercised_on: [chat_completions], source: "litellm/router.py:11489", rationale: "Custom pricing on a strategy-router alias never prices the routed request; spend logs at the routed tier deployment's own rate (GitHub PR #36691)"}
- {id: reliability.routing.complexity_heuristic.scores_current_ask_only, module: reliability, tier: P1, behavior: routing, variant: complexity_heuristic, assertions: [scores_current_ask_only], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py:942", rationale: "The heuristic complexity classifier scores the caller's current ask only, so a keyword-heavy agent system prompt cannot inflate the tier (GitHub PR #36721)"}
- {id: reliability.cache.exact.returns_cached, module: reliability, tier: P1, behavior: cache, variant: exact, assertions: [returns_cached], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/caching.py", rationale: "Response cache returns cached on exact match"}
- {id: reliability.cache.prompt_caching_model_select.returns_cached, module: reliability, tier: P1, behavior: cache, variant: prompt_caching_model_select, assertions: [returns_cached], exercised_on: [chat_completions], source: "router_utils/prompt_caching_cache.py", rationale: "Selects model supporting prompt caching for cacheable prefix"}
- {id: reliability.circuit_breaker.redis.trips_then_recovers, module: reliability, tier: P0, behavior: circuit_breaker, variant: redis, assertions: [trips_then_recovers], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/redis_cache.py:99", rationale: "Redis breaker CLOSED->OPEN->HALF_OPEN; guards all cache/rate-limit ops"}

View file

@ -73,6 +73,7 @@ class KeyGenerateBody(BaseModel):
allowed_passthrough_routes: list[str] | None = None
metadata: KeyMetadata | None = None
object_permission: ObjectPermission | None = None
router_settings: "RouterSettingsOverride | None" = None
class KeyGenerateResponse(BaseModel):
@ -234,16 +235,18 @@ class ChatBody(BaseModel):
class RouterSettingsOverride(BaseModel):
"""Per-request `router_settings_override` in a /chat/completions body: the
reliability knobs (fallbacks by trigger, retry count) the reliability suite
drives per call instead of via static router config. Serialized exclude_none, so
an override sets only the strategies a test exercises. Each fallbacks map is
model_name -> the ordered fallback model_names to try."""
"""Router settings a test scopes below the global config: sent per request as
`router_settings_override` in a /chat/completions body (the reliability suite's
fallback and retry knobs) or stored on a key as `router_settings` at
/key/generate (the auto-router suite's tag filtering switch). Serialized
exclude_none, so an override sets only the knobs a test exercises. Each
fallbacks map is model_name -> the ordered fallback model_names to try."""
fallbacks: list[dict[str, list[str]]] | None = None
context_window_fallbacks: list[dict[str, list[str]]] | None = None
content_policy_fallbacks: list[dict[str, list[str]]] | None = None
num_retries: int | None = None
enable_tag_filtering: bool | None = None
class ReliabilityChatBody(ChatBody):
@ -744,6 +747,10 @@ class LiteLLMParamsBody(BaseModel):
extra_headers: dict[str, str] | None = None
use_in_pass_through: bool | None = None
complexity_router_config: dict[str, object] | None = None
auto_router_config: str | None = None
auto_router_default_model: str | None = None
auto_router_embedding_model: str | None = None
tags: list[str] | None = None
mock_response: str | None = None
timeout: float | None = None
tpm: int | None = None

View file

@ -0,0 +1,616 @@
"""Live e2e regression pins for strategy-router (auto-router) routing.
A strategy marker (an ``auto_router/complexity_router`` deployment) and a plain
deployment can share one ``model_name``, split by tags once
``enable_tag_filtering`` is on: tagged requests route through the marker to its
tier models, untagged requests go to the plain deployment. That split, and the
strategy-router alias behaviors around it, regressed repeatedly; each test here
pins one fixed behavior:
- GitHub issue #36619: a tagged request selects the tagged marker under a
shared name even when a plain deployment was registered first.
- GitHub issue #36620: untagged requests keep being served by the plain
deployment on every call, never captured or 400'd by the tagged marker.
- GitHub issue #36621: a request tagged via the ``x-litellm-tags`` header
routes through the marker even when the tier deployments carry no tags
(the marker consumes the routing tags before deployment selection), while a
tagged call aimed straight at an untagged deployment stays denied.
- GitHub issues #36620/#36621 on /v1/responses: the same tag split holds for
string and list input, whether the tag arrives in litellm_metadata or the
x-litellm-tags header.
- GitHub PR #37333: /v1/responses input is resolved into messages for a
semantic ``auto_router`` deployment's pre-routing hook; such requests used
to fail with 400 "Unmapped LLM provider auto_router" because only chat
messages fed the route matcher.
- GitHub PR #36691: custom pricing on the marker alias never prices the routed
request; spend logs at the routed tier deployment's own rate.
- GitHub PR #36721: the heuristic complexity classifier scores the caller's
current ask only, so a large agent system prompt cannot inflate the tier.
- GitHub PR #36626: connection params on the marker alias (``api_key``,
``api_base``) stay with the alias; the routed tier calls its provider with
its own credentials.
Every deployment is registered via /model/new (stage has no static config for
these) and ``enable_tag_filtering`` is enabled through key-level
``router_settings`` on the keys the tag tests mint, so the switch rides only
this module's own requests and the rest of the suite is never filtered.
The served deployment is always read back from the spend log's ``model``,
which stores either the registered alias or the provider-prefixed form.
"""
import json
import os
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Final
import pytest
from pydantic import BaseModel, ConfigDict, Field
from e2e_config import unique_marker
from e2e_http import AnthropicHeaders, AuthHeaders, UnauthorizedError, unwrap
from lifecycle import ResourceManager
from models import (
AnthropicMessagesBody,
AnthropicMessagesResponse,
ChatBody,
ChatMessage,
ChatMetadata,
KeyGenerateBody,
LiteLLMParamsBody,
RouterSettingsOverride,
SpendLogRow,
)
from proxy_client import ProxyClient
pytestmark = pytest.mark.e2e
PLAIN_MODEL = "anthropic/claude-sonnet-5"
CHEAP_MODEL = "anthropic/claude-haiku-4-5"
STRONG_MODEL = "openai/gpt-5.6"
MAX_TOKENS = 16
PLAIN_SERVED = frozenset({PLAIN_MODEL, "claude-sonnet-5"})
CHEAP_SERVED = frozenset({CHEAP_MODEL, "claude-haiku-4-5"})
EMBEDDING_MODEL = "openai/text-embedding-3-small"
SEMANTIC_ROUTE_UTTERANCE = "summarize this quarterly revenue report into three bullet points"
KEYWORD_HEAVY_SYSTEM_PROMPT = (
"You are the principal architecture assistant for a distributed systems platform. "
"Analyze every request step by step: design the algorithm, prove its correctness, "
"evaluate time and space complexity, and reason about concurrency, consistency, and "
"fault tolerance tradeoffs. When asked, refactor and debug multi-threaded code, "
"optimize database query plans, derive mathematical proofs, and explain the theorem "
"or lemma behind each optimization. Think through edge cases rigorously before answering. "
) * 4
class TaggedAuthHeaders(AuthHeaders):
x_litellm_tags: str | None = Field(default=None, serialization_alias="x-litellm-tags")
class TaggedAnthropicHeaders(AnthropicHeaders):
x_litellm_tags: str | None = Field(default=None, serialization_alias="x-litellm-tags")
class ResponsesTagMetadata(BaseModel):
tags: list[str]
class ResponsesInputItem(BaseModel):
role: str
content: str
class ResponsesBody(BaseModel):
model: str
input: str | list[ResponsesInputItem]
max_output_tokens: int | None = None
litellm_metadata: ResponsesTagMetadata | None = None
class ResponsesApiResponse(BaseModel):
"""Minimal /v1/responses answer shape; routing is proven from spend logs,
so only the fields the assertions read are modeled."""
model_config = ConfigDict(extra="allow")
id: str | None = None
status: str | None = None
model: str | None = None
@dataclass(frozen=True, slots=True)
class TagSplitDeployments:
"""Scenario A mirrors the customer-shaped config from GitHub issue #36619:
plain deployment registered first, tier deployment and marker both tagged.
Scenario B flips both axes for GitHub issue #36621: marker registered first
and its tier deployment left untagged, so routing depends neither on
registration order nor on tier deployments carrying tags."""
tag_a: str
shared_a: str
tier_a: str
tag_b: str
shared_b: str
tier_b: str
@dataclass(frozen=True, slots=True)
class ZeroPricedAlias:
alias: str
tier: str
@dataclass(frozen=True, slots=True)
class HeuristicSplit:
alias: str
cheap: str
strong: str
@dataclass(frozen=True, slots=True)
class SemanticAutoRouter:
marker: str
target: str
fallback: str
embedding: str
@dataclass(frozen=True, slots=True)
class CredentialedAlias:
alias: str
tier: str
def _provider_key(env_var: str) -> str:
return os.environ.get(env_var) or f"os.environ/{env_var}"
def _uniform_tier_config(tier_model: str) -> dict[str, object]:
return {
"classifier_type": "heuristic",
"tiers": {"SIMPLE": tier_model, "MEDIUM": tier_model, "COMPLEX": tier_model, "REASONING": tier_model},
}
def _key_for(
proxy: ProxyClient, resources: ResourceManager, models: list[str], tag_filtering: bool = False
) -> str:
key: Final = proxy.generate_key(
KeyGenerateBody(
models=models,
user_id="e2e-auto-router-regressions",
router_settings=RouterSettingsOverride(enable_tag_filtering=True) if tag_filtering else None,
)
)
resources.defer(lambda: proxy.delete_key(key))
return key
def _hello_chat_body(model: str, tags: list[str] | None = None) -> ChatBody:
return ChatBody(
model=model,
messages=[ChatMessage(role="user", content=f"say hello {unique_marker()}")],
max_tokens=MAX_TOKENS,
metadata=ChatMetadata(tags=tags) if tags is not None else None,
)
def _hello_messages_body(model: str) -> AnthropicMessagesBody:
return AnthropicMessagesBody(
model=model,
messages=[ChatMessage(role="user", content=f"say hello {unique_marker()}")],
max_tokens=MAX_TOKENS,
)
def _assert_served_only_by(rows: list[SpendLogRow], allowed: frozenset[str], context: str) -> None:
served: Final = tuple(row.model for row in rows)
assert served and all(model in allowed for model in served), (
f"{context}: expected every request to be served by one of {sorted(allowed)}, spend logs show {served}"
)
@pytest.fixture(scope="module")
def split(proxy: ProxyClient) -> Iterator[TagSplitDeployments]:
marker: Final = unique_marker()
deployments: Final = TagSplitDeployments(
tag_a=f"e2e-split-a-{marker}",
shared_a=f"e2e-autoroute-a-{marker}",
tier_a=f"e2e-tier-a-{marker}",
tag_b=f"e2e-split-b-{marker}",
shared_b=f"e2e-autoroute-b-{marker}",
tier_b=f"e2e-tier-b-{marker}",
)
anthropic_key: Final = _provider_key("ANTHROPIC_API_KEY")
marker_params_a: Final = LiteLLMParamsBody(
model="auto_router/complexity_router",
complexity_router_config=_uniform_tier_config(deployments.tier_a),
tags=[deployments.tag_a],
)
marker_params_b: Final = LiteLLMParamsBody(
model="auto_router/complexity_router",
complexity_router_config=_uniform_tier_config(deployments.tier_b),
tags=[deployments.tag_b],
)
registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = (
(deployments.shared_a, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=anthropic_key)),
(deployments.tier_a, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=anthropic_key, tags=[deployments.tag_a])),
(deployments.shared_a, marker_params_a),
(deployments.shared_b, marker_params_b),
(deployments.tier_b, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=anthropic_key)),
(deployments.shared_b, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=anthropic_key)),
)
created: Final = tuple(proxy.create_model(name, params) for name, params in registrations)
try:
yield deployments
finally:
for model_id in created:
proxy.delete_model(model_id)
@pytest.fixture(scope="module")
def zero_priced_alias(proxy: ProxyClient) -> Iterator[ZeroPricedAlias]:
marker: Final = unique_marker()
named: Final = ZeroPricedAlias(alias=f"e2e-priced-alias-{marker}", tier=f"e2e-priced-tier-{marker}")
alias_params: Final = LiteLLMParamsBody(
model="auto_router/complexity_router",
complexity_router_config=_uniform_tier_config(named.tier),
input_cost_per_token=0.0,
output_cost_per_token=0.0,
)
registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = (
(named.tier, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))),
(named.alias, alias_params),
)
created: Final = tuple(proxy.create_model(name, params) for name, params in registrations)
try:
yield named
finally:
for model_id in created:
proxy.delete_model(model_id)
@pytest.fixture(scope="module")
def heuristic_split(proxy: ProxyClient) -> Iterator[HeuristicSplit]:
marker: Final = unique_marker()
named: Final = HeuristicSplit(
alias=f"e2e-heuristic-router-{marker}",
cheap=f"e2e-heuristic-cheap-{marker}",
strong=f"e2e-heuristic-strong-{marker}",
)
config: Final[dict[str, object]] = {
"classifier_type": "heuristic",
"token_thresholds": {"simple": 15, "complex": 400},
"tiers": {"SIMPLE": named.cheap, "MEDIUM": named.strong, "COMPLEX": named.strong, "REASONING": named.strong},
}
registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = (
(named.cheap, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))),
(named.strong, LiteLLMParamsBody(model=STRONG_MODEL, api_key=_provider_key("OPENAI_API_KEY"))),
(named.alias, LiteLLMParamsBody(model="auto_router/complexity_router", complexity_router_config=config)),
)
created: Final = tuple(proxy.create_model(name, params) for name, params in registrations)
try:
yield named
finally:
for model_id in created:
proxy.delete_model(model_id)
@pytest.fixture(scope="module")
def semantic_auto_router(proxy: ProxyClient) -> Iterator[SemanticAutoRouter]:
marker: Final = unique_marker()
named: Final = SemanticAutoRouter(
marker=f"e2e-semantic-router-{marker}",
target=f"e2e-semantic-target-{marker}",
fallback=f"e2e-semantic-fallback-{marker}",
embedding=f"e2e-semantic-embedding-{marker}",
)
router_config: Final = json.dumps(
{"routes": [{"name": named.target, "utterances": [SEMANTIC_ROUTE_UTTERANCE], "score_threshold": 0.3}]}
)
marker_params: Final = LiteLLMParamsBody(
model=f"auto_router/{named.marker}",
auto_router_config=router_config,
auto_router_default_model=named.fallback,
auto_router_embedding_model=named.embedding,
)
registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = (
(named.embedding, LiteLLMParamsBody(model=EMBEDDING_MODEL, api_key=_provider_key("OPENAI_API_KEY"))),
(named.target, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))),
(named.fallback, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))),
(named.marker, marker_params),
)
created: Final = tuple(proxy.create_model(name, params) for name, params in registrations)
try:
yield named
finally:
for model_id in created:
proxy.delete_model(model_id)
@pytest.fixture(scope="module")
def credentialed_alias(proxy: ProxyClient) -> Iterator[CredentialedAlias]:
marker: Final = unique_marker()
named: Final = CredentialedAlias(alias=f"e2e-cred-alias-{marker}", tier=f"e2e-cred-tier-{marker}")
alias_params: Final = LiteLLMParamsBody(
model="auto_router/complexity_router",
complexity_router_config=_uniform_tier_config(named.tier),
api_key=f"sk-alias-never-used-{marker}",
)
registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = (
(named.tier, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))),
(named.alias, alias_params),
)
created: Final = tuple(proxy.create_model(name, params) for name, params in registrations)
try:
yield named
finally:
for model_id in created:
proxy.delete_model(model_id)
class TestTagSplitRouting:
@pytest.mark.covers("reliability.routing.tagged_marker.request_tag_selects_marker")
def test_body_tagged_chat_routes_through_the_marker_to_its_tier(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins GitHub issue #36619: with tag filtering on, a chat request whose
body metadata tags match the tagged marker under a shared model name is
answered by the marker's tier deployment, not by the plain deployment
that was registered under the name first."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a, tags=[split.tag_a])))
assert chat.choices, "tagged chat through the shared name returned no choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "body-tagged chat on the shared name")
@pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment")
def test_untagged_chat_is_always_served_by_the_plain_deployment(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins GitHub issue #36620: untagged chat requests to the shared name
succeed on every call and are all served by the plain deployment; the
tagged marker never captures them, so no intermittent auto-router
errors and no tier hijacking."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
for _ in range(5):
chat = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a)))
assert chat.choices, "untagged chat through the shared name returned no choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=5)
_assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged chat on the shared name")
@pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment")
def test_untagged_messages_is_served_by_the_plain_deployment(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins GitHub issue #36620 on the /v1/messages surface: an untagged
Anthropic-native request to the shared name is served by the plain
deployment, not captured by the tagged marker."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
answer: Final = unwrap(proxy.messages(key, _hello_messages_body(split.shared_a)))
assert answer.content or answer.choices, "untagged /v1/messages returned neither content nor choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged /v1/messages on the shared name")
class TestUntaggedTierDeployments:
@pytest.mark.covers("reliability.routing.tagged_marker.header_tag_selects_marker")
def test_header_tagged_messages_routes_through_the_marker_to_an_untagged_tier(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins GitHub issue #36621: a /v1/messages request tagged only via the
x-litellm-tags header selects the tagged marker, and the rewrite still
lands on the tier deployment even though that deployment carries no
tags, because the marker consumed the routing tags."""
key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b], tag_filtering=True)
headers: Final = TaggedAnthropicHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_b)
answer: Final = unwrap(
proxy.transport.post(
"/v1/messages",
headers=headers,
json=_hello_messages_body(split.shared_b),
response_type=AnthropicMessagesResponse,
)
)
assert answer.content or answer.choices, "header-tagged /v1/messages returned neither content nor choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {split.tier_b}, "header-tagged /v1/messages on the shared name")
@pytest.mark.covers("reliability.routing.tagged_marker.untagged_tier_deployments_still_served")
def test_body_tagged_chat_reaches_the_untagged_tier_after_marker_rewrite(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins the tag-consumption half of GitHub issue #36621: after the
tagged marker rewrites the request to its tier model, the consumed
routing tags no longer constrain deployment selection, so the untagged
tier deployment serves the request instead of a strict-tag denial."""
key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b], tag_filtering=True)
chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_b, tags=[split.tag_b])))
assert chat.choices, "body-tagged chat through the marker-first shared name returned no choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {split.tier_b}, "body-tagged chat with untagged tier")
@pytest.mark.covers("reliability.routing.tagged_marker.tag_semantics_stay_strict")
def test_tagged_call_straight_at_an_untagged_deployment_stays_denied(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""The tag-consumption fix must not loosen strict tag semantics: a
tagged request aimed directly at an untagged deployment (no marker
involved) is still rejected with the 401 tags-configuration error."""
key: Final = _key_for(proxy, resources, [split.tier_b], tag_filtering=True)
result: Final = proxy.chat(key, _hello_chat_body(split.tier_b, tags=[split.tag_b]))
assert isinstance(result, UnauthorizedError), (
f"expected the tagged direct call to an untagged deployment to be denied with 401, got {result}"
)
class TestResponsesApiTagRouting:
@pytest.mark.covers("reliability.routing.tagged_marker.responses_input_routes_through_marker")
def test_header_tagged_responses_with_string_input_routes_to_the_tier(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins the /v1/responses surface of the tag split (GitHub issues
#36620/#36621): a /v1/responses request with string input, tagged via
the x-litellm-tags header, succeeds and routes through the tagged
marker to its tier."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
headers: Final = TaggedAuthHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_a)
body: Final = ResponsesBody(
model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64
)
answer: Final = unwrap(
proxy.transport.post("/v1/responses", headers=headers, json=body, response_type=ResponsesApiResponse)
)
assert answer.id, "header-tagged /v1/responses returned no response id"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "header-tagged /v1/responses string input")
@pytest.mark.covers("reliability.routing.tagged_marker.responses_input_routes_through_marker")
def test_body_tagged_responses_with_list_input_routes_to_the_tier(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins the body-tag and list-input combination of the same split:
/v1/responses with litellm_metadata.tags and structured input items
routes through the tagged marker to its tier."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
body: Final = ResponsesBody(
model=split.shared_a,
input=[ResponsesInputItem(role="user", content=f"say hello {unique_marker()}")],
max_output_tokens=64,
litellm_metadata=ResponsesTagMetadata(tags=[split.tag_a]),
)
answer: Final = unwrap(
proxy.transport.post(
"/v1/responses",
headers=proxy.transport.bearer(key),
json=body,
response_type=ResponsesApiResponse,
)
)
assert answer.id, "body-tagged /v1/responses returned no response id"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "body-tagged /v1/responses list input")
@pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment")
def test_untagged_responses_is_served_by_the_plain_deployment(
self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments
) -> None:
"""Pins the untagged half of the /v1/responses tag split: an untagged
request to the shared name is served by the plain deployment, matching
the chat and messages surfaces."""
key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True)
body: Final = ResponsesBody(
model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64
)
answer: Final = unwrap(
proxy.transport.post(
"/v1/responses",
headers=proxy.transport.bearer(key),
json=body,
response_type=ResponsesApiResponse,
)
)
assert answer.id, "untagged /v1/responses returned no response id"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged /v1/responses on the shared name")
class TestStrategyAliasPricing:
@pytest.mark.covers("reliability.routing.strategy_alias.custom_pricing_ignored")
def test_zero_priced_alias_still_logs_spend_at_the_tier_rate(
self, proxy: ProxyClient, resources: ResourceManager, zero_priced_alias: ZeroPricedAlias
) -> None:
"""Pins GitHub PR #36691: custom pricing registered on a strategy-router
alias never prices the routed request. The alias here carries explicit
zero pricing, so any zero-spend row would prove the alias pricing was
applied; the routed tier deployment's real rate must produce spend > 0."""
key: Final = _key_for(proxy, resources, [zero_priced_alias.alias, zero_priced_alias.tier])
chat: Final = unwrap(proxy.chat(key, _hello_chat_body(zero_priced_alias.alias)))
assert chat.choices, "chat through the zero-priced alias returned no choices"
rows: Final = proxy.poll_logs_for_key(
key, min_rows=1, predicate=lambda logged: all((row.spend or 0.0) > 0.0 for row in logged)
)
_assert_served_only_by(rows, CHEAP_SERVED | {zero_priced_alias.tier}, "chat through the zero-priced alias")
priced: Final = tuple((row.model, row.spend) for row in rows)
assert all((row.spend or 0.0) > 0.0 for row in rows), (
f"expected spend at the tier deployment's own rate, got zero-spend rows: {priced}"
)
class TestComplexityHeuristicScope:
@pytest.mark.covers("reliability.routing.complexity_heuristic.scores_current_ask_only")
def test_trivial_ask_behind_keyword_heavy_system_prompt_stays_on_the_cheap_tier(
self, proxy: ProxyClient, resources: ResourceManager, heuristic_split: HeuristicSplit
) -> None:
"""Pins GitHub PR #36721: the heuristic complexity classifier scores the
caller's current ask alone. The trivial ask scores SIMPLE on its own,
while the accompanying ~2KB agent system prompt is packed with enough
reasoning and complexity keywords that scoring the combined text lands
in REASONING; only ask-only scoring keeps this on the cheap tier."""
key: Final = _key_for(
proxy, resources, [heuristic_split.alias, heuristic_split.cheap, heuristic_split.strong]
)
body: Final = ChatBody(
model=heuristic_split.alias,
messages=[
ChatMessage(role="system", content=KEYWORD_HEAVY_SYSTEM_PROMPT),
ChatMessage(role="user", content=f"hi {unique_marker()}"),
],
max_tokens=MAX_TOKENS,
)
chat: Final = unwrap(proxy.chat(key, body))
assert chat.choices, "chat through the heuristic router returned no choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(
rows, CHEAP_SERVED | {heuristic_split.cheap}, "trivial ask behind a keyword-heavy system prompt"
)
class TestSemanticAutoRouterResponses:
@pytest.mark.covers("reliability.routing.semantic_auto_router.responses_input_routed")
def test_responses_input_reaches_the_semantic_auto_router(
self, proxy: ProxyClient, resources: ResourceManager, semantic_auto_router: SemanticAutoRouter
) -> None:
"""Pins GitHub PR #37333: /v1/responses input is resolved into messages
for the semantic auto-router's pre-routing hook, so the marker embeds
the input, matches its route, and the target deployment serves the
request; before the fix the hook saw no messages and the request
failed with 400 "Unmapped LLM provider auto_router"."""
key: Final = _key_for(
proxy,
resources,
[semantic_auto_router.marker, semantic_auto_router.target, semantic_auto_router.fallback],
)
body: Final = ResponsesBody(
model=semantic_auto_router.marker, input=SEMANTIC_ROUTE_UTTERANCE, max_output_tokens=64
)
answer: Final = unwrap(
proxy.transport.post(
"/v1/responses",
headers=proxy.transport.bearer(key),
json=body,
response_type=ResponsesApiResponse,
)
)
assert answer.id, "/v1/responses through the semantic auto-router returned no response id"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(
rows, CHEAP_SERVED | {semantic_auto_router.target}, "semantic auto-router /v1/responses string input"
)
class TestAliasParamForwarding:
@pytest.mark.covers("reliability.routing.tagged_marker.alias_connection_params_stay_with_tier")
def test_alias_api_key_never_overrides_the_tier_credential(
self, proxy: ProxyClient, resources: ResourceManager, credentialed_alias: CredentialedAlias
) -> None:
"""Pins GitHub PR #36626: an api_key set on the marker alias entry is
never forwarded onto the routed request, so the tier deployment calls
its provider with its own credential. Before the fix the alias's key
was copied into the request, overriding the tier's credential, and
every routed call failed provider auth."""
key: Final = _key_for(proxy, resources, [credentialed_alias.alias, credentialed_alias.tier])
chat: Final = unwrap(proxy.chat(key, _hello_chat_body(credentialed_alias.alias)))
assert chat.choices, "chat through the credentialed alias returned no choices"
rows: Final = proxy.poll_logs_for_key(key, min_rows=1)
_assert_served_only_by(rows, CHEAP_SERVED | {credentialed_alias.tier}, "chat through the credentialed alias")

View file

@ -63,7 +63,7 @@ test.describe("MCP Tools", () => {
// The form is generated from the tool's inputSchema, so `repoName` proves the schema
// round-tripped through the proxy instead of the panel falling back to a generic field.
const repoInput = page.locator('input[id="repoName"]');
const repoInput = page.getByLabel(/repoName/);
await expect(repoInput).toBeVisible();
await repoInput.fill(TOOL_ARG_REPO);

View file

@ -338,8 +338,8 @@ test.describe("Add Model", () => {
await page.getByRole("button", { name: "Add Model" }).last().click();
// Scope to antd's notification container so a stale toast can't satisfy this.
await expect(page.locator(".ant-notification").getByText("created successfully").last()).toBeVisible({
// Scope to the toast container so a stale toast can't satisfy this.
await expect(page.locator("[data-sonner-toast]").getByText("created successfully").last()).toBeVisible({
timeout: 15_000,
});

View file

@ -85,9 +85,9 @@ test.describe("Clear custom pricing on a deployment", () => {
const inputCost = page.getByPlaceholder("Enter input cost");
const outputCost = page.getByPlaceholder("Enter output cost");
// Both cache fields share the same placeholder ("Defaults to Input Cost if blank"),
// so disambiguate via the Form.Item id (AntD assigns the `name` prop as input id).
const cacheReadCost = page.locator("#cache_read_cost");
const cacheWriteCost = page.locator("#cache_write_cost");
// so disambiguate via their labels.
const cacheReadCost = page.getByLabel(/Cache Read Cost/);
const cacheWriteCost = page.getByLabel(/Cache Write Cost/);
await inputCost.waitFor({ timeout: 15_000 });
for (const field of [inputCost, outputCost, cacheReadCost, cacheWriteCost]) {
await field.click({ clickCount: 3 });

View file

@ -36,9 +36,8 @@ test.describe("Proxy Admin - Keys", () => {
// Wait for the key creation modal
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
// Fill key name (has data-testid="base-input" in the built UI)
const keyName = `e2e-admin-key-${Date.now()}`;
await page.getByTestId("base-input").fill(keyName);
await page.getByLabel(/Key Name/).fill(keyName);
// Select team
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
@ -192,7 +191,7 @@ test.describe("Proxy Admin - Keys", () => {
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
const keyName = `e2e-admin-allproxy-${Date.now()}`;
await page.getByTestId("base-input").fill(keyName);
await page.getByLabel(/Key Name/).fill(keyName);
// No team selection — leave team dropdown empty so the key is owned by the admin user
@ -220,7 +219,7 @@ test.describe("Proxy Admin - Keys", () => {
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
const keyName = `e2e-admin-specific-${Date.now()}`;
await page.getByTestId("base-input").fill(keyName);
await page.getByLabel(/Key Name/).fill(keyName);
// Open the model multi-select and pick a single specific model. Use
// getByRole("option", ...) to avoid the strict-mode collision between

View file

@ -79,7 +79,7 @@ test.describe("Proxy Admin - Teams", () => {
await expect(modal).toBeVisible({ timeout: 5_000 });
// The email field is a Select — type to search, then select from dropdown
await modal.locator(".ant-select").first().click();
await modal.getByRole("combobox").first().click();
await page.keyboard.type("invitable@test.local");
// Wait for the option to appear, then select via keyboard (avoids viewport issues)

View file

@ -66,7 +66,7 @@ test.describe("Team Admin", () => {
// Use a dedicated invitee user so this doesn't race with the proxy-admin
// "Invite a user" test that adds invitable@test.local to the same team.
await modal.locator(".ant-select").first().click();
await modal.getByRole("combobox").first().click();
await page.keyboard.type("invitable-team@test.local");
const emailOption = page.getByRole("option", { name: "invitable-team@test.local" }).first();
@ -136,7 +136,7 @@ test.describe("Team Admin", () => {
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
const keyName = `e2e-team-admin-key-${Date.now()}`;
await page.getByTestId("base-input").fill(keyName);
await page.getByLabel(/Key Name/).fill(keyName);
// Team selector — same locator pattern as the proxy-admin keys test.
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");

View file

@ -269,7 +269,7 @@ class TestAimlImageGeneration(BaseImageGenTest):
class TestGoogleImageGen(BaseImageGenTest):
def get_base_image_generation_call_args(self) -> dict:
return {"model": "gemini/imagen-4.0-generate-001"}
return {"model": "gemini/gemini-3.1-flash-image"}
@pytest.mark.skip(reason="Runwayml image generation API only tested locally")

View file

@ -20,7 +20,7 @@ from litellm.llms.groq.chat.transformation import (
class TestGroq(BaseLLMChatTest):
def get_base_completion_call_args(self) -> dict:
return {
"model": "groq/llama-3.3-70b-versatile",
"model": "groq/openai/gpt-oss-120b",
}
def test_tool_call_no_arguments(self, tool_call_no_arguments):

View file

@ -15,7 +15,7 @@ from litellm import completion, embedding
litellm.set_verbose = True
model_alias_map = {"good-model": "groq/llama-3.1-8b-instant"}
model_alias_map = {"good-model": "groq/openai/gpt-oss-120b"}
def test_model_alias_map(caplog):
@ -34,7 +34,7 @@ def test_model_alias_map(caplog):
if rec.levelname == "ERROR" and rec.name.startswith("LiteLLM"):
pytest.fail(f"Unexpected litellm ERROR log: {rec.getMessage()}")
assert "llama-3.1-8b-instant" in response.model
assert "gpt-oss-120b" in response.model
except litellm.ServiceUnavailableError:
pass
except Exception as e:

View file

@ -120,7 +120,7 @@ async def test_router_provider_wildcard_routing():
print("response 2 = ", response2)
response3 = await router.acompletion(
model="groq/llama-3.1-8b-instant",
model="groq/openai/gpt-oss-120b",
messages=[{"role": "user", "content": "hello"}],
)

View file

@ -44,7 +44,7 @@ async def test_batch_completion_multiple_models(mode):
{
"model_name": "groq-llama",
"litellm_params": {
"model": "groq/llama-3.1-8b-instant",
"model": "groq/openai/gpt-oss-120b",
},
},
]
@ -143,7 +143,7 @@ async def test_batch_completion_fastest_response_streaming():
{
"model_name": "groq-llama",
"litellm_params": {
"model": "groq/llama-3.1-8b-instant",
"model": "groq/openai/gpt-oss-120b",
},
},
]
@ -179,7 +179,7 @@ async def test_batch_completion_multiple_models_multiple_messages():
{
"model_name": "groq-llama",
"litellm_params": {
"model": "groq/llama-3.1-8b-instant",
"model": "groq/openai/gpt-oss-120b",
},
},
]

View file

@ -871,7 +871,7 @@ def load_env():
}
LLAMA3_3 = {
"messages": messages,
"model": "groq/llama-3.3-70b-versatile",
"model": "groq/openai/gpt-oss-120b",
"api_base": "https://api.groq.com/openai/v1",
"temperature": 0.0,
"tools": tools,

View file

@ -317,110 +317,69 @@ async def test_redaction_responses_api_stream():
@pytest.mark.asyncio
async def test_redaction_responses_api_with_reasoning_summary():
"""Test that reasoning summary in ResponsesAPIResponse output is properly redacted"""
import litellm
from litellm.litellm_core_utils.redact_messages import perform_redaction
# Create a simple mock object with output items that have reasoning summaries
class MockResponsesAPIResponse:
def __init__(self):
self.output = [
# Reasoning item with summary
type(
"obj",
(object,),
response = litellm.ResponsesAPIResponse(
id="resp_123",
created_at=1234567890,
output=[
{
"type": "reasoning",
"id": "rs_123",
"summary": [
{
"type": "reasoning",
"id": "rs_123",
"summary": [
type(
"obj",
(object,),
{
"text": "This is a detailed reasoning summary that should be redacted",
"type": "summary_text",
},
)()
],
},
)(),
# Message item with content
type(
"obj",
(object,),
"type": "summary_text",
"text": "This is a detailed reasoning summary that should be redacted",
}
],
},
{
"type": "message",
"id": "msg_123",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "message",
"id": "msg_123",
"content": [
type(
"obj",
(object,),
{
"text": "This is the actual message content",
"type": "output_text",
},
)()
],
},
)(),
]
self.reasoning = {"effort": "low", "summary": "auto"}
"type": "output_text",
"text": "This is the actual message content",
"annotations": [],
}
],
},
],
reasoning={"effort": "low", "summary": "auto"},
)
# Mock as ResponsesAPIResponse so perform_redaction recognizes it
mock_response = MockResponsesAPIResponse()
mock_response.__class__.__name__ = "ResponsesAPIResponse"
model_call_details = {
"messages": [{"role": "user", "content": "test"}],
"prompt": "test prompt",
"input": "test input",
}
# Patch isinstance to recognize our mock as ResponsesAPIResponse
import litellm
redacted_result = perform_redaction(model_call_details, response)
original_isinstance = isinstance
assert isinstance(
redacted_result, litellm.ResponsesAPIResponse
), "Redaction should preserve the ResponsesAPIResponse type"
def patched_isinstance(obj, cls):
if (
cls == litellm.ResponsesAPIResponse
and obj.__class__.__name__ == "ResponsesAPIResponse"
):
return True
return original_isinstance(obj, cls)
reasoning_item = redacted_result.output[0]
assert (
reasoning_item.summary[0].text == "redacted-by-litellm"
), "Reasoning summary text should be redacted"
import builtins
message_item = redacted_result.output[1]
assert (
message_item.content[0].text == "redacted-by-litellm"
), "Message content text should be redacted"
builtins.isinstance = patched_isinstance
assert (
redacted_result.reasoning is None
), "Top-level reasoning field should be None"
try:
model_call_details = {
"messages": [{"role": "user", "content": "test"}],
"prompt": "test prompt",
"input": "test input",
}
# Perform redaction
redacted_result = perform_redaction(model_call_details, mock_response)
# Verify reasoning summary text is redacted
reasoning_item = redacted_result.output[0]
assert (
reasoning_item.summary[0].text == "redacted-by-litellm"
), "Reasoning summary text should be redacted"
# Verify message content is also redacted
message_item = redacted_result.output[1]
assert (
message_item.content[0].text == "redacted-by-litellm"
), "Message content text should be redacted"
# Verify top-level reasoning field is removed
assert (
redacted_result.reasoning is None
), "Top-level reasoning field should be None"
# Verify input messages are redacted
assert (
model_call_details["messages"][0]["content"] == "redacted-by-litellm"
), "Input messages should be redacted"
print("✓ Reasoning summary redaction test passed")
finally:
# Restore original isinstance
builtins.isinstance = original_isinstance
assert (
model_call_details["messages"][0]["content"] == "redacted-by-litellm"
), "Input messages should be redacted"
@pytest.mark.asyncio

View file

@ -62,7 +62,7 @@ class TestAzureDocumentIntelligencePagesParam:
return AzureDocumentIntelligenceOCRConfig()
def test_get_supported_ocr_params_includes_pages_and_features(self, cfg):
assert cfg.get_supported_ocr_params("prebuilt-layout") == ["pages", "features"]
assert cfg.get_supported_ocr_params("prebuilt-layout") == ["pages", "features", "req_format"]
def test_map_ocr_params_mistral_zero_based_int_list(self, cfg):
mapped = cfg.map_ocr_params({"pages": [0, 1, 2]}, {}, "prebuilt-layout")

View file

@ -413,35 +413,54 @@ async def test_pass_through_request_logging_failure_with_stream(
assert response.body == b'{"mock": "response"}'
PROTOCOL_CONSTRAINED_PASS_THROUGH_ROUTES = {
"/comprehendmedical": {"POST"},
"/comprehendmedical/{operation}": {"POST"},
}
def test_pass_through_routes_support_all_methods():
"""
Test that all pass-through routes support GET, POST, PUT, DELETE, PATCH methods
A pass-through route fronts a whole provider API, so narrowing its method
set turns a request the upstream would have accepted into a 405. The
exceptions are providers whose wire protocol admits only one method: Amazon
Comprehend Medical speaks AWS JSON 1.1, which is POST-only, so there is no
other method to forward.
"""
# Import the routers
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
router as llm_router,
)
# Expected HTTP methods
expected_methods = {"GET", "POST", "PUT", "DELETE", "PATCH"}
# Function to check routes in a router
def check_router_methods(router):
for route in router.routes:
if isinstance(route, APIRoute):
# Get path and methods for this route
path = route.path
methods = set(route.methods)
print("supported methods for route", path, "are", methods)
# Assert all expected methods are supported
allowed = PROTOCOL_CONSTRAINED_PASS_THROUGH_ROUTES.get(path, expected_methods)
assert (
methods == expected_methods
), f"Route {path} does not support all methods. Supported: {methods}, Expected: {expected_methods}"
methods == allowed
), f"Route {path} does not support all methods. Supported: {methods}, Expected: {allowed}"
# Check both routers
check_router_methods(llm_router)
def test_protocol_constrained_pass_through_exemptions_are_not_stale():
"""
The exemption list above weakens the method contract, so it must not
outlive the routes it covers: a renamed or deleted route has to fail here
rather than sit in the list silently exempting nothing.
"""
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
router as llm_router,
)
registered_paths = {route.path for route in llm_router.routes if isinstance(route, APIRoute)}
unmatched = set(PROTOCOL_CONSTRAINED_PASS_THROUGH_ROUTES) - registered_paths
assert not unmatched, f"Exempted pass-through routes no longer exist: {sorted(unmatched)}"
def test_is_bedrock_agent_runtime_route():
"""
Test that _is_bedrock_agent_runtime_route correctly identifies bedrock agent runtime endpoints

View file

@ -35,11 +35,16 @@ MOCK_TINYFISH_RESPONSE = {
def _make_mock_response(
json_data: dict, status_code: int = 200, request_url: str | None = None
json_data: dict,
status_code: int = 200,
request_url: str | None = None,
headers: dict | None = None,
) -> MagicMock:
mock = MagicMock()
mock.status_code = status_code
mock.json.return_value = json_data
# httpx.Headers normalizes keys to lowercase — mirror production behavior.
mock.headers = httpx.Headers(headers or {})
if request_url:
mock.request = MagicMock()
mock.request.url = httpx.URL(request_url)
@ -163,7 +168,7 @@ class TestTinyfishSearch:
@pytest.mark.asyncio
async def test_fetch_param_round_trip(self):
# End-to-end check: caller passes `fetch=...` (JSON-encoded tf-fetch
# End-to-end check: caller passes `fetch=...` (JSON-encoded fetch
# config); param reaches TinyFish on the request side and the nested
# `fetch` object on each result surfaces back to the SearchResult on the
# response side. No LiteLLM-side support code is required.
@ -235,6 +240,58 @@ class TestTinyfishSearch:
assert result.results[0].title == "Result 0"
assert result.results[2].title == "Result 2"
@pytest.mark.asyncio
async def test_top_level_extras_surface_end_to_end(self):
# Envelope extras (`query`, `total_results`, `page`) must survive the
# full asearch dispatch — proves LiteLLM's entry-point plumbing outside
# our transformer doesn't accidentally strip them.
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
new_callable=AsyncMock,
) as mock_get:
mock_get.return_value = mock_response
response = await litellm.asearch(
query="web automation tools",
search_provider="tinyfish",
)
assert getattr(response, "query", None) == "web automation tools"
assert getattr(response, "total_results", None) == 2
assert getattr(response, "page", None) == 0
@pytest.mark.asyncio
async def test_response_headers_surface_end_to_end(self):
# Response headers must land on `_hidden_params` after the full
# asearch dispatch (both raw and sanitized channels).
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
mock_response = _make_mock_response(
MOCK_TINYFISH_RESPONSE,
headers={"X-Request-ID": "req-e2e-1"},
)
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
new_callable=AsyncMock,
) as mock_get:
mock_get.return_value = mock_response
response = await litellm.asearch(
query="test",
search_provider="tinyfish",
)
raw = response._hidden_params["headers"]
add = response._hidden_params["additional_headers"]
# httpx lowercases; both channels agree on the value.
assert raw["x-request-id"] == "req-e2e-1"
assert add["llm_provider-x-request-id"] == "req-e2e-1"
@pytest.mark.asyncio
async def test_empty_results(self):
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"

View file

@ -0,0 +1,312 @@
"""Tests for litellm/integrations/otel/model/db_endpoint.py
Prisma talks to PostgreSQL through a loopback query engine, so a DB span with no
``server.address`` gets attributed to ``localhost`` by the backend. These cover
the endpoint derivation that names the real server, for the local engine and for
remote and read-replica deployments, and pin the rule that no credential is ever
exported.
"""
import os
from unittest.mock import patch
import pytest
from litellm.integrations.otel.model.db_endpoint import (
DatabaseEndpoint,
db_span_attributes,
parse_database_endpoint,
postgres_endpoint,
)
LOCAL_DSN = "postgresql://llmproxy:dbpassword9090@localhost:5432/litellm"
REMOTE_DSN = "postgresql://llmproxy:s3cr3t@litellm-prod.abc123.us-east-1.rds.amazonaws.com:6432/litellm?schema=reporting&sslmode=require"
REPLICA_DSN = "postgresql://reader:r3ad0nly@litellm-prod-ro.abc123.us-east-1.rds.amazonaws.com/litellm_replica"
def _resolve(service, call_type=None, database_url=None, read_replica_url=None):
"""Resolve attributes with the two DB env vars set, as the proxy sets them."""
env = {k: v for k, v in (("DATABASE_URL", database_url), ("DATABASE_URL_READ_REPLICA", read_replica_url)) if v}
with patch.dict(os.environ, env, clear=False):
for absent in {"DATABASE_URL", "DATABASE_URL_READ_REPLICA"} - set(env):
os.environ.pop(absent, None)
return dict(db_span_attributes(service, call_type))
def test_local_prisma_engine_endpoint_is_the_postgres_server_not_the_engine():
assert parse_database_endpoint(LOCAL_DSN) == DatabaseEndpoint(
address="localhost", port=5432, namespace="litellm"
)
def test_remote_endpoint_keeps_host_port_and_schema_qualified_namespace():
assert parse_database_endpoint(REMOTE_DSN) == DatabaseEndpoint(
address="litellm-prod.abc123.us-east-1.rds.amazonaws.com",
port=6432,
namespace="litellm|reporting",
)
def test_read_replica_dsn_parses_to_the_replica_host_and_database():
assert parse_database_endpoint(REPLICA_DSN) == DatabaseEndpoint(
address="litellm-prod-ro.abc123.us-east-1.rds.amazonaws.com",
port=5432,
namespace="litellm_replica",
)
def test_default_schema_is_not_spelled_out_in_the_namespace():
"""``?schema=public`` and no schema at all are the same deployment, so they
must not split a group-by on db.namespace."""
assert parse_database_endpoint("postgresql://u:p@db.internal/litellm?schema=public") == parse_database_endpoint(
"postgresql://u:p@db.internal/litellm"
)
def test_unix_socket_host_parameter_wins_over_the_netloc():
"""libpq and the Cloud SQL connector both put the real target in ``host=``
behind a localhost netloc, which is the attribution this module removes."""
assert parse_database_endpoint(
"postgresql://u:p@localhost:5432/litellm?host=/cloudsql/proj:us-east1:inst"
) == DatabaseEndpoint(address="/cloudsql/proj:us-east1:inst", port=5432, namespace="litellm")
def test_socket_only_dsn_without_a_netloc_host_still_resolves():
assert parse_database_endpoint("postgresql:///litellm?host=/var/run/postgresql") == DatabaseEndpoint(
address="/var/run/postgresql", port=5432, namespace="litellm"
)
def test_percent_encoded_database_name_is_decoded():
endpoint = parse_database_endpoint("postgresql://u:p@db.internal/litellm%20prod")
assert endpoint is not None and endpoint.namespace == "litellm prod"
MISPARSED_AUTHORITY_DSNS = (
("postgresql://litellm:/kJ8xQz+9wT@db.internal:5432/litellm", "kJ8xQz+9wT"),
("postgresql://litellm:12345/aBcD@db.internal:5432/litellm", "aBcD"),
# '#' sends the tail to the fragment and '?' to the query, so the path is
# empty and only the stranded userinfo '@' reveals the mis-split.
("postgresql://litellm:12345#aBcD@db.internal/litellm", "aBcD"),
("postgresql://litellm:12345?aBcD@db.internal/litellm", "aBcD"),
# A '?'-stranded tail that happens to parse as parameters, including one
# that hijacks the host= parameter into server.address.
("postgresql://litellm:12345?a=aBcD@db.internal/litellm", "aBcD"),
("postgresql://litellm:12345?host=aBcD@db.internal/litellm", "aBcD"),
# Both '/' and '?key=value' together: the slash leaves a clean path holding
# the password remainder and the query still parses, so only the stranded
# at-sign gives it away.
("postgresql://litellm:12345/aBcD?x=1@db.internal/litellm", "aBcD"),
)
@pytest.mark.parametrize(("dsn", "secret"), MISPARSED_AUTHORITY_DSNS)
def test_unencoded_slash_in_password_never_yields_an_endpoint(dsn, secret):
"""An unencoded '/' truncates the authority, so urlparse reports the username
as the host and the password tail as the database. Postgres drivers reject
such a DSN outright, so the only safe reading is no endpoint at all."""
assert parse_database_endpoint(dsn) is None
@pytest.mark.parametrize(("dsn", "secret"), MISPARSED_AUTHORITY_DSNS)
def test_unencoded_slash_in_password_never_reaches_a_span(dsn, secret):
attrs = _resolve("postgres", "get_data", database_url=dsn)
exported = " ".join(str(value) for value in attrs.values())
assert secret not in exported
assert "db.namespace" not in attrs
assert "server.address" not in attrs
def test_extra_path_segment_yields_no_endpoint():
"""A database name cannot hold an unencoded '/', so a second path segment
means the authority was mis-split even when no '@' survived into the path."""
assert parse_database_endpoint("postgresql://db.internal:5432/litellm/extra") is None
@pytest.mark.parametrize("dsn", [d for d, _ in MISPARSED_AUTHORITY_DSNS])
def test_a_mis_split_authority_never_exports_the_database_username(dsn):
"""The username lands in ``parsed.hostname`` when the authority truncates, so
a span would name the DB user as the server."""
attrs = _resolve("postgres", "get_data", database_url=dsn)
assert "server.address" not in attrs
assert "litellm" not in " ".join(str(v) for v in attrs.values())
@pytest.mark.parametrize(
"dsn",
[
"postgresql://db.internal:5432/litellm?application_name=svc@prod",
"postgresql://db.internal:5432/litellm?user=admin@company.com",
],
)
def test_an_unencoded_at_sign_in_a_query_forfeits_the_endpoint(dsn):
"""This shape is byte-for-byte indistinguishable from a mis-split password,
so it resolves to no endpoint rather than risking a credential fragment.
Percent-encoding the at-sign restores the attributes."""
assert parse_database_endpoint(dsn) is None
assert parse_database_endpoint(dsn.replace("@", "%40")) is not None
def test_host_and_port_query_parameters_are_honoured_together():
assert parse_database_endpoint("postgresql://ignored/litellm?host=real.internal&port=6543") == DatabaseEndpoint(
address="real.internal", port=6543, namespace="litellm"
)
def test_percent_encoded_password_still_resolves_the_endpoint():
"""The encoded spelling is the one a driver accepts, so it must keep working."""
assert parse_database_endpoint("postgresql://litellm:pa%2Fssw0rd@db.internal:5432/litellm") == DatabaseEndpoint(
address="db.internal", port=5432, namespace="litellm"
)
def test_hostless_socket_dsn_still_names_the_database():
"""``postgresql:///litellm`` is a valid local-socket DSN that Prisma accepts,
so the database is knowable even though no server address is."""
assert parse_database_endpoint("postgresql:///litellm") == DatabaseEndpoint(
address=None, port=None, namespace="litellm"
)
def test_hostless_socket_dsn_emits_namespace_without_a_server():
attrs = _resolve("postgres", "get_data", database_url="postgresql:///litellm")
assert attrs["db.namespace"] == "litellm"
assert "server.address" not in attrs
assert "server.port" not in attrs
def test_dsn_with_neither_host_nor_database_yields_no_endpoint():
assert parse_database_endpoint("postgresql://") is None
def test_prisma_default_schema_is_left_implicit():
endpoint = parse_database_endpoint("postgresql://u:p@db.internal/litellm?schema=public")
assert endpoint is not None and endpoint.namespace == "litellm"
@pytest.mark.parametrize("spelling", ["PUBLIC", "Public", "reporting"])
def test_a_non_default_schema_stays_in_the_namespace(spelling):
"""Prisma quotes the schema name, so ``?schema=PUBLIC`` provisions a second
schema alongside ``public`` with its own tables. Case-folding them into one
namespace would report two different schemas as the same database."""
endpoint = parse_database_endpoint(f"postgresql://u:p@db.internal/litellm?schema={spelling}")
assert endpoint is not None and endpoint.namespace == f"litellm|{spelling}"
def test_postgres_scheme_alias_is_accepted():
assert parse_database_endpoint("postgres://u:p@db.internal/litellm") == DatabaseEndpoint(
address="db.internal", port=5432, namespace="litellm"
)
@pytest.mark.parametrize(
"dsn",
[
None,
"",
"mysql://u:p@db.internal:3306/litellm",
"postgresql://u:p@db.internal:not-a-port/litellm",
"not a url at all",
],
)
def test_unusable_dsn_degrades_to_no_endpoint(dsn):
assert parse_database_endpoint(dsn) is None
def test_database_without_name_or_schema_has_no_namespace():
assert parse_database_endpoint("postgresql://u:p@db.internal:5432/") == DatabaseEndpoint(
address="db.internal", port=5432, namespace=None
)
def test_postgres_service_span_carries_system_operation_and_endpoint():
assert _resolve("postgres", "get_data", database_url=REMOTE_DSN) == {
"db.system.name": "postgresql",
"db.system": "postgresql",
"db.operation.name": "get_data",
"server.address": "litellm-prod.abc123.us-east-1.rds.amazonaws.com",
"server.port": 6432,
"db.namespace": "litellm|reporting",
}
def test_legacy_db_system_is_dual_emitted_for_datadog():
"""Datadog's OTLP intake infers the database span type from ``db.system``,
not from the semconv-current ``db.system.name``."""
assert _resolve("postgres", "get_data", database_url=LOCAL_DSN)["db.system"] == "postgresql"
assert _resolve("redis", "set")["db.system"] == "redis"
def test_batch_write_service_is_also_attributed_to_postgres():
attrs = _resolve("batch_write_to_db", "_PROXY_track_cost_callback", database_url=REMOTE_DSN)
assert attrs["db.system.name"] == "postgresql"
assert attrs["server.address"] == "litellm-prod.abc123.us-east-1.rds.amazonaws.com"
def test_redis_service_never_borrows_the_postgres_endpoint():
assert _resolve("redis", "set", database_url=REMOTE_DSN) == {
"db.system.name": "redis",
"db.system": "redis",
"db.operation.name": "set",
}
def test_non_datastore_service_gets_no_db_attributes():
assert _resolve("reset_budget_job", "reset_budget", database_url=REMOTE_DSN) == {}
def test_configured_read_replica_suppresses_the_endpoint_rather_than_naming_the_primary():
"""Reads are routed to the replica per Prisma call, underneath the span, so
naming the writer would pin replica latency onto the primary."""
attrs = _resolve("postgres", "get_data", database_url=REMOTE_DSN, read_replica_url=REPLICA_DSN)
assert attrs == {
"db.system.name": "postgresql",
"db.system": "postgresql",
"db.operation.name": "get_data",
}
def test_endpoint_attributes_are_omitted_when_database_url_is_unset():
assert _resolve("postgres", "get_data") == {
"db.system.name": "postgresql",
"db.system": "postgresql",
"db.operation.name": "get_data",
}
def test_blank_call_type_does_not_emit_an_empty_operation_attribute():
assert "db.operation.name" not in _resolve("postgres", "")
assert "db.operation.name" not in _resolve("postgres", None)
@pytest.mark.parametrize(
("dsn", "secrets"),
[
(LOCAL_DSN, ("dbpassword9090", "llmproxy")),
(REMOTE_DSN, ("s3cr3t", "llmproxy", "sslmode")),
(REPLICA_DSN, ("r3ad0nly", "reader")),
],
)
def test_no_credential_reaches_any_exported_attribute(dsn, secrets):
attrs = _resolve("postgres", "get_data", database_url=dsn)
assert attrs["server.address"]
exported = " ".join(str(value) for value in attrs.values())
for secret in secrets:
assert secret not in exported
def test_a_runtime_endpoint_change_is_reflected_on_the_next_span():
"""The RDS IAM refresh, the reconnect path and the DB-backed
environment_variables overlay can all rewrite DATABASE_URL after startup, so
a value cached for the process lifetime would report a server the process no
longer talks to."""
first = _resolve("postgres", "get_data", database_url=LOCAL_DSN)
assert first["server.address"] == "localhost"
moved = _resolve("postgres", "get_data", database_url=REMOTE_DSN)
assert moved["server.address"] == "litellm-prod.abc123.us-east-1.rds.amazonaws.com"
def test_a_replica_configured_after_the_first_span_suppresses_the_endpoint():
assert _resolve("postgres", "get_data", database_url=REMOTE_DSN)["server.address"]
later = _resolve("postgres", "get_data", database_url=REMOTE_DSN, read_replica_url=REPLICA_DSN)
assert "server.address" not in later

View file

@ -8,6 +8,8 @@ hooks, proxy SERVER span lifecycle (start + setters), parent-context resolution
import asyncio
import contextlib
import os
from unittest.mock import patch
from datetime import datetime, timedelta, timezone
import pytest
@ -1521,6 +1523,36 @@ def test_async_service_success_hook_emits_service_span():
assert span.status.status_code is StatusCode.UNSET
def test_postgres_db_span_names_the_database_server_not_the_prisma_engine():
"""Prisma reaches Postgres over loopback, so without server.address the
backend attributes the wait to localhost."""
dsn = "postgresql://llmproxy:dbpassword9090@litellm-prod.abc123.us-east-1.rds.amazonaws.com:6432/litellm?schema=reporting"
logger, exporter = _logger()
parent = _service_parent(logger)
try:
with patch.dict(os.environ, {"DATABASE_URL": dsn}, clear=False):
os.environ.pop("DATABASE_URL_READ_REPLICA", None)
asyncio.run(
logger.async_service_success_hook(
payload=_ServicePayload("postgres", "get_data"),
parent_otel_span=parent,
)
)
finally:
parent.end()
span = {s.name: s for s in exporter.get_finished_spans()}["postgres get_data"]
assert span.kind is SpanKind.CLIENT
assert span.attributes["db.system.name"] == "postgresql"
assert span.attributes["db.operation.name"] == "get_data"
assert span.attributes["server.address"] == "litellm-prod.abc123.us-east-1.rds.amazonaws.com"
assert span.attributes["server.port"] == 6432
assert span.attributes["db.namespace"] == "litellm|reporting"
assert span.attributes["db.system"] == "postgresql"
exported = " ".join(str(value) for value in span.attributes.values())
assert "dbpassword9090" not in exported
assert "llmproxy" not in exported
def test_async_service_failure_hook_marks_error_status():
logger, exporter = _logger()
parent = _service_parent(logger)

View file

@ -34,6 +34,7 @@ from litellm.integrations.opentelemetry import (
_normalize_team_metadata_keys,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.types.services import ServiceLoggerPayload, ServiceTypes
class TestOpenTelemetryGuardrails(unittest.TestCase):
@ -6212,3 +6213,110 @@ class TestDynamicTracerProviderCache(unittest.TestCase):
self.assertTrue(entry.owns_exporter)
self.assertIsNotNone(entry.provider._atexit_handler)
class TestOpenTelemetryDatabaseSemconvAttributes(unittest.TestCase):
"""A Postgres service span must name the PostgreSQL server it reached.
Without ``db.system`` and ``server.address``, the only host in the trace is
the loopback address of Prisma's local query engine, so the backend
attributes the wait to ``localhost`` and it cannot be correlated with the
database's own metrics.
"""
DSN = "postgresql://llmproxy:dbpassword9090@litellm-prod.abc123.us-east-1.rds.amazonaws.com:6432/litellm?schema=reporting"
REPLICA_DSN = "postgresql://reader:r3ad0nly@litellm-prod-ro.abc123.us-east-1.rds.amazonaws.com/litellm"
def _service_span(self, service, call_type, dsn, error=None, replica_dsn=None):
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel = OpenTelemetry()
otel.tracer = provider.get_tracer(__name__)
parent = otel.tracer.start_span("Received Proxy Server Request")
payload = ServiceLoggerPayload(
is_error=error is not None,
error=error,
service=service,
duration=0.25,
call_type=call_type,
event_metadata=None,
)
hook = otel.async_service_failure_hook if error else otel.async_service_success_hook
kwargs = {"error": error} if error else {}
env = {k: v for k, v in (("DATABASE_URL", dsn), ("DATABASE_URL_READ_REPLICA", replica_dsn)) if v}
with patch.dict(os.environ, env, clear=False):
for absent in {"DATABASE_URL", "DATABASE_URL_READ_REPLICA"} - set(env):
os.environ.pop(absent, None)
asyncio.run(
hook(
payload=payload,
parent_otel_span=parent,
start_time=datetime.now(),
end_time=datetime.now(),
**kwargs,
)
)
parent.end()
return next(s for s in exporter.get_finished_spans() if s.name == service.value)
def test_postgres_span_names_the_database_server(self):
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN)
self.assertEqual(span.attributes["db.system.name"], "postgresql")
self.assertEqual(span.attributes["db.operation.name"], "get_data")
self.assertEqual(
span.attributes["server.address"],
"litellm-prod.abc123.us-east-1.rds.amazonaws.com",
)
self.assertEqual(span.attributes["server.port"], 6432)
self.assertEqual(span.attributes["db.namespace"], "litellm|reporting")
def test_datastore_span_is_a_client_span_carrying_the_legacy_db_system(self):
"""Datadog types a span as a database call from CLIENT kind plus
``db.system``; an INTERNAL span is classified as custom work."""
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN)
self.assertEqual(span.kind, trace.SpanKind.CLIENT)
self.assertEqual(span.attributes["db.system"], "postgresql")
def test_internal_service_span_stays_internal(self):
span = self._service_span(ServiceTypes.RESET_BUDGET_JOB, "reset_budget", self.DSN)
self.assertEqual(span.kind, trace.SpanKind.INTERNAL)
self.assertNotIn("db.system.name", span.attributes)
self.assertNotIn("server.address", span.attributes)
def test_existing_service_and_call_type_attributes_are_unchanged(self):
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN)
self.assertEqual(span.attributes["service"], "postgres")
self.assertEqual(span.attributes["call_type"], "get_data")
def test_failed_postgres_span_also_names_the_database_server(self):
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN, error="connection refused")
self.assertEqual(span.attributes["db.system.name"], "postgresql")
self.assertEqual(span.kind, trace.SpanKind.CLIENT)
self.assertEqual(
span.attributes["server.address"],
"litellm-prod.abc123.us-east-1.rds.amazonaws.com",
)
self.assertEqual(span.attributes["error"], "connection refused")
def test_no_credential_from_the_dsn_lands_on_the_span(self):
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN)
exported = " ".join(str(value) for value in span.attributes.values())
self.assertIn("litellm-prod.abc123.us-east-1.rds.amazonaws.com", exported)
self.assertNotIn("dbpassword9090", exported)
self.assertNotIn("llmproxy", exported)
def test_redis_span_does_not_borrow_the_postgres_endpoint(self):
span = self._service_span(ServiceTypes.REDIS, "async_set_cache", self.DSN)
self.assertEqual(span.attributes["db.system.name"], "redis")
self.assertEqual(span.kind, trace.SpanKind.CLIENT)
self.assertNotIn("server.address", span.attributes)
def test_configured_read_replica_suppresses_the_endpoint(self):
span = self._service_span(ServiceTypes.DB, "get_data", self.DSN, replica_dsn=self.REPLICA_DSN)
self.assertEqual(span.attributes["db.system.name"], "postgresql")
self.assertNotIn("server.address", span.attributes)
self.assertNotIn("db.namespace", span.attributes)
def test_unset_database_url_leaves_the_span_without_endpoint_attributes(self):
span = self._service_span(ServiceTypes.DB, "get_data", None)
self.assertEqual(span.attributes["db.system.name"], "postgresql")
self.assertNotIn("server.address", span.attributes)

View file

@ -241,10 +241,32 @@ def test_split_concatenated_json_non_dict_value():
assert result == [{}]
def test_split_concatenated_json_invalid_raises():
"""Completely invalid JSON raises JSONDecodeError."""
with pytest.raises(json.JSONDecodeError):
split_concatenated_json_objects("not json at all")
def test_split_concatenated_json_wholly_invalid_returns_empty():
"""
Wholly unparseable JSON degrades to an empty list instead of raising.
Regression for https://github.com/BerriAI/litellm/issues/18667: a raise
here propagated out of `_convert_to_bedrock_tool_call_invoke` and turned
every replayed conversation into a 500.
"""
assert split_concatenated_json_objects("not json at all") == []
def test_split_concatenated_json_malformed_object_returns_empty():
"""
A single malformed object (missing comma between keys) degrades to an
empty list rather than raising `Expecting ',' delimiter`.
"""
assert split_concatenated_json_objects('{"location": "Boston" "unit": "celsius"}') == []
def test_split_concatenated_json_salvages_prefix_before_truncated_tail():
"""
Complete objects parsed before an unparseable/truncated tail are kept;
only the bad tail is discarded.
"""
result = split_concatenated_json_objects('{"a": 1}{"b": 2}{"c":')
assert result == [{"a": 1}, {"b": 2}]
# ---------------------------------------------------------------------------

View file

@ -2287,6 +2287,116 @@ def test_bedrock_tool_call_invoke_non_dict_arguments():
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_malformed_json_does_not_raise():
"""
Regression for https://github.com/BerriAI/litellm/issues/18667.
When the model emits malformed JSON in tool-call arguments (here a
missing comma between keys), replaying that history must NOT raise
`Unable to convert openai tool calls ... Expecting ',' delimiter`.
It degrades to an empty-object input so the conversation can continue.
"""
tool_calls = [
{
"id": "toolu_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "Boston" "unit": "celsius"}',
},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["toolUseId"] == "toolu_abc123"
assert result[0]["toolUse"]["name"] == "get_weather"
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_salvages_valid_prefix_before_truncated_tail():
"""
A valid leading object followed by a truncated tail keeps the valid
object rather than dropping everything or raising.
"""
tool_calls = [
{
"id": "call_partial",
"type": "function",
"function": {"name": "shell", "arguments": '{"cmd": "ls"}{"cmd":'},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["input"] == {"cmd": "ls"}
def test_bedrock_tool_call_invoke_mixed_turn_survives_one_malformed_call():
"""
Regression for LIT-4574: an assistant turn with several tool calls where only one
has malformed/truncated arguments must keep the valid calls intact and degrade just
the bad one to empty input, instead of killing the entire turn.
"""
tool_calls = [
{
"id": "t_good",
"type": "function",
"function": {
"name": "good_tool",
"arguments": '{"item_type": "email", "item_id": "AAMkAD=="}',
},
},
{
"id": "t_bad",
"type": "function",
"function": {"name": "bad_tool", "arguments": '{"item_type": "email"'},
},
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
tool_uses = [block["toolUse"] for block in result if "toolUse" in block]
assert len(tool_uses) == 2
by_name = {tool_use["name"]: tool_use for tool_use in tool_uses}
assert by_name["good_tool"]["input"] == {"item_type": "email", "item_id": "AAMkAD=="}
assert by_name["bad_tool"]["input"] == {}
def test_bedrock_tool_call_invoke_truncated_json_arguments():
"""
Truncated tool call arguments (issue #35303) must not raise. A client replaying a
partially streamed tool call would otherwise trigger a pre-network exception that the
router maps to a retryable APIConnectionError and retries through the fallback graph.
"""
tool_calls = [
{
"id": "tooluse_MAh2QLVjBRkvi5QJkLQ08V",
"type": "function",
"function": {
"name": "replace_note_content",
"arguments": '{"note_id": "999af35c-4061-4ece-8581-7d43fc988ba4", "title": "WG"',
},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["toolUseId"] == "tooluse_MAh2QLVjBRkvi5QJkLQ08V"
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_unconvertible_raises_non_retryable_bad_request():
"""
Conversion failures are client input errors, so they must surface as a non-retryable
BadRequestError instead of a bare Exception that maps to APIConnectionError, and the
message must not embed the tool call payload (issue #35303).
"""
tool_calls = [{"id": "call_bad", "type": "function", "function": None}]
with pytest.raises(litellm.BadRequestError) as exc_info:
_convert_to_bedrock_tool_call_invoke(tool_calls)
assert exc_info.value.status_code == 400
assert "call_bad" in str(exc_info.value)
assert "function" not in str(exc_info.value).split("Received error=")[0]
def test_make_valid_bedrock_tool_name_preserves_hyphens():
assert make_valid_bedrock_tool_name("my-tool") == "my-tool"
assert (

View file

@ -271,6 +271,47 @@ class TestDashscopeCostCalculator:
assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10)
def test_dashscope_nested_cache_creation_input_tokens_bill_at_cache_write_rate(self):
"""
Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside
prompt_tokens_details; those tokens must bill at the tier's cache-creation
rate instead of being folded into text tokens at the input rate.
"""
self._register_tiered_model(
"dashscope/qwen-nested-cache-write-test",
[
{
"range": [0, 128000],
"input_cost_per_token": 4e-07,
"cache_read_input_token_cost": 1.6e-07,
"cache_creation_input_token_cost": 5e-07,
"output_cost_per_token": 1.6e-06,
}
],
)
usage = Usage(
prompt_tokens=2059,
completion_tokens=201,
total_tokens=2260,
prompt_tokens_details={
"cached_tokens": 0,
"text_tokens": 2059,
"cache_type": "ephemeral",
"cache_creation_input_tokens": 2048,
"cache_creation": {"ephemeral_5m_input_tokens": 2048},
},
completion_tokens_details={"reasoning_tokens": 170},
)
prompt_cost, _ = dashscope_cost_per_token(
model="qwen-nested-cache-write-test", usage=usage
)
assert math.isclose(
prompt_cost, (2048 * 5e-07) + (11 * 4e-07), rel_tol=1e-10
)
def test_dashscope_tiered_cache_creation_falls_back_to_tier_input_rate(self):
"""
Tiers without a cache_creation_input_token_cost bill cache-creation tokens at

View file

@ -47,7 +47,9 @@ def _make_mock_response(
mock = MagicMock()
mock.status_code = status_code
mock.headers = headers or {}
# httpx.Headers normalizes keys to lowercase — mirror production so tests
# assert what callers actually see.
mock.headers = httpx.Headers(headers or {})
if json_data is not None:
mock.json.return_value = json_data
mock.text = text if text is not None else _json.dumps(json_data)
@ -222,7 +224,7 @@ class TestTransformSearchRequest:
assert param not in result["_tinyfish_params"]
def test_arbitrary_param_passed_through(self):
# `fetch` is a TinyFish-specific param (JSON-encoded tf-fetch config).
# `fetch` is a TinyFish-specific param (JSON-encoded fetch config).
# The passthrough loop should forward it verbatim without LiteLLM needing
# to know about it.
config = TinyfishSearchConfig()
@ -237,26 +239,49 @@ class TestTransformSearchRequest:
config = TinyfishSearchConfig()
result = config.transform_search_request(
query="test",
optional_params={"fetch": {"format": "html", "fetch_path": "fast"}},
)
assert (
result["_tinyfish_params"]["fetch"]
== '{"format":"html","fetch_path":"fast"}'
optional_params={"fetch": {"format": "html"}},
)
assert result["_tinyfish_params"]["fetch"] == '{"format":"html"}'
def test_bool_param_serialized_as_lowercase(self):
# urlencode renders Python bool as capitalized "True"/"False"; ux-labs
# rejects those (e.g. include_thumbnail must be literal "true"/"false").
# Normalize before passing through.
# urlencode renders Python bool as capitalized "True"/"False"; TinyFish
# Search's bool params require lowercase "true"/"false" strings on the
# wire. Normalize before passing through.
config = TinyfishSearchConfig()
true_result = config.transform_search_request(
query="test", optional_params={"include_thumbnail": True}
query="test", optional_params={"some_bool_param": True}
)
false_result = config.transform_search_request(
query="test", optional_params={"include_thumbnail": False}
query="test", optional_params={"some_bool_param": False}
)
assert true_result["_tinyfish_params"]["include_thumbnail"] == "true"
assert false_result["_tinyfish_params"]["include_thumbnail"] == "false"
assert true_result["_tinyfish_params"]["some_bool_param"] == "true"
assert false_result["_tinyfish_params"]["some_bool_param"] == "false"
def test_float_param_passes_through(self):
# Float values pass the urlencode adapter and land on the wire as
# their decimal string form. If TinyFish's server rejects a float
# for a param it expects as int, the server's 400 response is
# attributed via _wrap_error (`TinyFish Search: ...`) — better than
# a client-side pydantic ValidationError with no context.
config = TinyfishSearchConfig()
result = config.transform_search_request(
query="test",
optional_params={"some_float_param": 0.5},
)
assert result["_tinyfish_params"]["some_float_param"] == 0.5
def test_list_param_auto_json_encoded(self):
# TinyFish Search's JSON-array params arrive on the wire as JSON-
# encoded strings. Accept the natural Python list form and serialize
# so the caller doesn't have to pre-stringify. Params whose wire
# format is a plain comma-separated string are the caller's
# responsibility to pass as a Python str.
config = TinyfishSearchConfig()
result = config.transform_search_request(
query="test",
optional_params={"some_list_param": ["a.example", "b.example"]},
)
assert result["_tinyfish_params"]["some_list_param"] == '["a.example","b.example"]'
def test_pre_stringified_param_passed_unchanged(self):
# If the caller already JSON-encoded, don't re-encode.
@ -422,10 +447,104 @@ class TestTransformSearchResponse:
assert getattr(first, "position", None) == 1
assert getattr(first, "site_name", None) == "tinyfish.ai"
def test_top_level_extras_flow_through(self):
# TinyFish returns `query`, `total_results`, `page` at the envelope
# level. These must ride through to the caller via SearchResponse's
# extra="allow" so pagination logic, echo checks, etc. work.
config = TinyfishSearchConfig()
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
result = config.transform_search_response(
raw_response=mock_response, logging_obj=None
)
assert getattr(result, "query", None) == "web automation tools"
assert getattr(result, "total_results", None) == 2
assert getattr(result, "page", None) == 0
def test_top_level_future_extras_flow_through(self):
# Any future TinyFish top-level field must ride through unchanged
# (design contract: no LiteLLM code change needed for new fields).
config = TinyfishSearchConfig()
body = {
"results": [
{"title": "x", "url": "https://x", "snippet": "x"},
],
"query": "test",
"example_int_extra": 123, # hypothetical future field
"example_str_extra": "value", # hypothetical future field
"example_id_extra": "abc-def", # hypothetical future field
}
result = config.transform_search_response(
raw_response=_make_mock_response(body), logging_obj=None
)
assert getattr(result, "example_int_extra", None) == 123
assert getattr(result, "example_str_extra", None) == "value"
assert getattr(result, "example_id_extra", None) == "abc-def"
def test_response_headers_stashed_on_hidden_params(self):
# TinyFish Search sets X-Request-ID on every success response. Confirm it
# lands on both `_hidden_params["headers"]` (raw) and
# `_hidden_params["additional_headers"]` (sanitized/prefixed).
# httpx.Headers lowercases every key, so assertions use lowercase.
config = TinyfishSearchConfig()
mock_response = _make_mock_response(
MOCK_TINYFISH_RESPONSE,
headers={"X-Request-ID": "req-abc-123", "Content-Type": "application/json"},
)
result = config.transform_search_response(
raw_response=mock_response, logging_obj=None
)
# Raw copy — httpx has normalized keys to lowercase.
assert result._hidden_params["headers"]["x-request-id"] == "req-abc-123"
# process_response_headers prefixes non-OpenAI-standard keys with "llm_provider-".
assert result._hidden_params["additional_headers"]["llm_provider-x-request-id"] == "req-abc-123"
def test_response_headers_future_headers_flow_through(self):
# "Accept extra": any header TinyFish Search adds later must ride
# through without a LiteLLM code change.
config = TinyfishSearchConfig()
mock_response = _make_mock_response(
MOCK_TINYFISH_RESPONSE,
headers={
"X-Request-ID": "req-1",
"X-Example-Header-A": "value-a", # hypothetical future header
"X-Example-Header-B": "value-b", # hypothetical future header
},
)
result = config.transform_search_response(
raw_response=mock_response, logging_obj=None
)
raw = result._hidden_params["headers"]
# httpx lowercases header names on read.
assert raw["x-example-header-a"] == "value-a"
assert raw["x-example-header-b"] == "value-b"
def test_response_headers_strips_x_litellm_spoof(self):
# A provider setting `x-litellm-*` in its response must not be able to
# spoof LiteLLM-internal markers via _hidden_params["additional_headers"].
# The raw copy preserves the header (opt-in debug view); the sanitized
# copy prefixes it with `llm_provider-` so bare `x-litellm-*` markers
# can't be spoofed (values still survive under the prefixed key for
# observability).
config = TinyfishSearchConfig()
mock_response = _make_mock_response(
MOCK_TINYFISH_RESPONSE,
headers={"x-litellm-attempted-fallbacks": "spoofed", "X-Request-ID": "r1"},
)
result = config.transform_search_response(
raw_response=mock_response, logging_obj=None
)
# Raw view still has the spoof.
assert result._hidden_params["headers"]["x-litellm-attempted-fallbacks"] == "spoofed"
# Sanitized view: the spoof survives only under the llm_provider- prefix
# (never under the bare x-litellm-* key that LiteLLM downstream trusts).
additional = result._hidden_params["additional_headers"]
assert "x-litellm-attempted-fallbacks" not in additional
assert additional.get("llm_provider-x-litellm-attempted-fallbacks") == "spoofed"
def test_fetch_field_rides_through_to_search_result(self):
# Mirrors browser-search's per-result `fetch` nested object (see
# api/src/parser.rs SearchResult.fetch). Confirms `fetch=...` requests
# surface their content to LiteLLM callers without provider changes.
# Mirrors TinyFish Search's per-result `fetch` nested object.
# Confirms `fetch=...` requests surface their content to LiteLLM
# callers without provider changes.
config = TinyfishSearchConfig()
fetched = {
"results": [
@ -568,7 +687,7 @@ class TestTransformSearchResponse:
class TestErrorHandling:
def test_4xx_response_raises_with_attribution_and_unwrapped_message(self):
# Reproduces ux-labs' error envelope shape for an INVALID_INPUT response.
# Reproduces TinyFish Search's error envelope shape for an INVALID_INPUT response.
config = TinyfishSearchConfig()
body = {
"error": {
@ -590,7 +709,7 @@ class TestErrorHandling:
def test_429_preserves_status_code_and_headers(self):
config = TinyfishSearchConfig()
body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "60 rpm"}}
body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "rate limit exceeded"}}
mock_response = _make_mock_response(
body, status_code=429, headers={"Retry-After": "60"}
)
@ -600,10 +719,12 @@ class TestErrorHandling:
)
assert getattr(exc_info.value, "status_code", None) == 429
headers = getattr(exc_info.value, "headers", {}) or {}
assert headers.get("Retry-After") == "60"
# httpx lowercases; the exception carries the same dict shape.
assert headers.get("retry-after") == "60"
def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self):
# Cloudflare-style JSON or any other envelope: unwrap fails, fall back to raw.
def test_5xx_with_non_tinyfish_envelope_shape_falls_back_to_raw_text(self):
# A JSON body that doesn't match TinyFish Search's error envelope shape:
# unwrap fails, fall back to the raw body text.
config = TinyfishSearchConfig()
body = {"errors": [{"code": "10000", "message": "Internal"}]}
mock_response = _make_mock_response(body, status_code=502)

View file

@ -14,6 +14,7 @@ sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory
from litellm.proxy.management_endpoints.common_daily_activity import (
_adjust_dates_for_timezone,
_build_aggregated_sql_query,
_build_entity_rollup_sql_query,
_is_user_agent_tag,
_record_to_spend_metrics,
get_api_key_metadata,
@ -982,6 +983,58 @@ class TestBuildAggregatedSqlQuery:
assert "COALESCE(model_group, model)" not in normalized
class TestAggregatedEmptyEntityFilter:
_BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query)
@pytest.mark.parametrize("build", _BUILDERS)
def test_empty_entity_list_emits_no_degenerate_in_clause(self, build):
sql, params = build(
table_name="litellm_dailyteamspend",
entity_id_field="team_id",
entity_id=[],
start_date="2026-08-01",
end_date="2026-08-19",
model=None,
api_key=None,
)
normalized = " ".join(sql.split())
assert "IN ()" not in normalized
assert '"team_id" IN' not in normalized
assert params == ["2026-08-01", "2026-08-19"]
@pytest.mark.parametrize("build", _BUILDERS)
def test_empty_entity_list_matches_nothing_rather_than_everything(self, build):
sql, _ = build(
table_name="litellm_dailyteamspend",
entity_id_field="team_id",
entity_id=[],
start_date="2026-08-01",
end_date="2026-08-19",
model=None,
api_key=None,
)
assert "FALSE" in " ".join(sql.split())
@pytest.mark.parametrize("build", _BUILDERS)
def test_populated_entity_list_still_filters_on_its_ids(self, build):
sql, params = build(
table_name="litellm_dailyteamspend",
entity_id_field="team_id",
entity_id=["team-alpha", "team-beta"],
start_date="2026-08-01",
end_date="2026-08-19",
model=None,
api_key=None,
)
normalized = " ".join(sql.split())
assert '"team_id" IN ($3, $4)' in normalized
assert "FALSE" not in normalized
assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"]
@pytest.mark.asyncio
async def test_get_daily_activity_aggregated_empty_result_set():
"""Regression test for the empty-range 500.

View file

@ -1286,6 +1286,75 @@ class TestOpenAIPassthroughIntegration:
mock_chat_handler.assert_called_once()
assert result == {"result": None, "kwargs": {}}
def test_openai_passthrough_handler_embeddings_unmapped_model_logs_zero_cost(self):
response_body = {
"object": "list",
"model": "lit5787-unmapped-embeddings-deployment",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
"usage": {"prompt_tokens": 9, "total_tokens": 9},
}
mock_logging_obj = self._create_mock_logging_obj()
result = OpenAIPassthroughLoggingHandler.openai_passthrough_handler(
httpx_response=self._create_mock_httpx_response(response_body),
response_body=response_body,
logging_obj=mock_logging_obj,
url_route="https://my-resource.openai.azure.com/openai/v1/embeddings",
result="",
start_time=self.start_time,
end_time=self.end_time,
cache_hit=False,
request_body={
"model": "lit5787-unmapped-embeddings-deployment",
"input": "spend probe",
},
passthrough_logging_payload=PassthroughStandardLoggingPayload(
url="https://my-resource.openai.azure.com/openai/v1/embeddings",
request_body={
"model": "lit5787-unmapped-embeddings-deployment",
"input": "spend probe",
},
request_method="POST",
),
litellm_params={},
)
assert result["result"] is not None
assert result["result"].usage.prompt_tokens == 9
assert result["kwargs"]["response_cost"] == 0.0
assert result["kwargs"]["model"] == "lit5787-unmapped-embeddings-deployment"
assert result["result"]._hidden_params["response_cost"] == 0.0
assert mock_logging_obj.model_call_details["response_cost"] == 0.0
def test_openai_passthrough_handler_embeddings_error_skips_chat_fallback(self):
response_body = {
"object": "list",
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 9, "total_tokens": 9},
}
kwargs_in = {
"passthrough_logging_payload": PassthroughStandardLoggingPayload(
url="https://api.openai.com/v1/embeddings",
request_body={"model": "text-embedding-3-small", "input": "spend probe"},
request_method="POST",
),
"litellm_params": {},
}
result = OpenAIPassthroughLoggingHandler.openai_passthrough_handler(
httpx_response=self._create_mock_httpx_response(response_body),
response_body=response_body,
logging_obj=self._create_mock_logging_obj(),
url_route="https://api.openai.com/v1/embeddings",
result="",
start_time=self.start_time,
end_time=self.end_time,
cache_hit=False,
request_body={"model": "text-embedding-3-small", "input": "spend probe"},
**kwargs_in,
)
assert result["result"] is None
assert result["kwargs"]["passthrough_logging_payload"] == kwargs_in["passthrough_logging_payload"]
@patch(
"litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_passthrough_logging_handler.OpenAIPassthroughLoggingHandler.openai_passthrough_handler"
)

View file

@ -124,6 +124,30 @@ def test_get_logging_payload_maps_openai_cache_write_tokens_to_cache_creation_in
assert additional_usage_values["prompt_tokens_details"]["cache_write_tokens"] == 800
def test_get_logging_payload_maps_nested_cache_creation_input_tokens():
"""
Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside
prompt_tokens_details; SpendLogs must record it as cache_creation_input_tokens.
"""
additional_usage_values: Final = _get_additional_usage_values_for_usage(
litellm.Usage(
prompt_tokens=2059,
completion_tokens=31,
total_tokens=2090,
prompt_tokens_details={
"cached_tokens": 0,
"text_tokens": 2059,
"cache_type": "ephemeral",
"cache_creation_input_tokens": 2048,
"cache_creation": {"ephemeral_5m_input_tokens": 2048},
},
)
)
assert additional_usage_values["cache_creation_input_tokens"] == 2048
assert additional_usage_values["prompt_tokens_details"]["cache_write_tokens"] == 2048
def test_get_logging_payload_preserves_anthropic_cache_creation_input_tokens():
additional_usage_values = _get_additional_usage_values_for_usage(
litellm.Usage(

View file

@ -1,4 +1,5 @@
import json
from typing import NoReturn
from unittest.mock import MagicMock, patch
import httpx
@ -167,6 +168,10 @@ async def _acreate_batch(*args, **kwargs):
raise AssertionError("only used for its __name__")
async def _acreate_file(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_uploaded_file_requests_in_their_model_group():
"""An input_file_id only exists under the credentials of the group it was uploaded
@ -229,6 +234,46 @@ async def test_run_async_fallback_allows_same_model_group_retry_for_uploaded_fil
assert router.attempted_model_groups == ["openai-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_file_creation_in_its_model_group():
"""A file created for batches lands in the account of the deployment that stored it,
and its id is only usable against the model group the caller named. A cross-group
fallback silently stores the file with the wrong provider."""
router = AttemptRecordingRouter()
with pytest.raises(RuntimeError, match="azure connection error"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["openai-group"],
original_model_group="azure-group",
original_exception=RuntimeError("azure connection error"),
max_fallbacks=3,
fallback_depth=0,
model="azure-group",
original_function=_acreate_file,
)
assert router.attempted_model_groups == []
@pytest.mark.asyncio
async def test_run_async_fallback_allows_same_model_group_retry_for_file_creation():
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "azure-group", "_target_order": 2}],
original_model_group="azure-group",
original_exception=RuntimeError("first deployment failed"),
max_fallbacks=3,
fallback_depth=0,
model="azure-group",
original_function=_acreate_file,
)
assert router.attempted_model_groups == ["azure-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_still_crosses_model_groups_without_an_uploaded_file():
router = AttemptRecordingRouter()

View file

@ -1,6 +1,6 @@
import os
import sys
from typing import Optional
from typing import Final, Optional
from unittest.mock import Mock
import pytest
@ -192,6 +192,32 @@ def test_transform_usage_with_cached_tokens_only():
print("✓ Transformation works with cached_tokens only")
def test_transform_usage_maps_nested_cache_creation_input_tokens():
"""
Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside
prompt_tokens_details; the bridge must surface it as cache_write_tokens.
"""
usage: Final = Usage(
prompt_tokens=2059,
completion_tokens=31,
total_tokens=2090,
prompt_tokens_details={
"cached_tokens": 0,
"text_tokens": 2059,
"cache_type": "ephemeral",
"cache_creation_input_tokens": 2048,
"cache_creation": {"ephemeral_5m_input_tokens": 2048},
},
)
responses_usage: Final = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
usage
)
assert responses_usage.input_tokens_details is not None
assert responses_usage.input_tokens_details.cache_write_tokens == 2048
def test_transform_usage_with_reasoning_tokens_only():
"""
Test transformation when only reasoning_tokens is provided (no cached_tokens).

View file

@ -492,6 +492,54 @@ async def test_async_router_acreate_file_with_jsonl():
assert first_call_content == non_jsonl_content
@pytest.mark.asyncio
async def test_async_router_acreate_file_does_not_fall_back_across_model_groups():
"""A file created for batches only exists under the credentials of the model group
the caller named. A cross-group fallback silently stores it with the wrong provider
and the later batch create against the named group permanently fails."""
from unittest.mock import MagicMock, patch
router = litellm.Router(
model_list=[
{
"model_name": "azure-gpt",
"litellm_params": {
"model": "azure/my-azure-deployment",
"api_base": "http://127.0.0.1:9",
"api_key": "dummy-key",
"api_version": "2024-06-01",
},
},
{
"model_name": "openai-gpt",
"litellm_params": {"model": "gpt-4o-mini"},
},
],
fallbacks=[{"azure-gpt": ["openai-gpt"]}],
)
def fail_azure(*args: object, **kwargs: object) -> MagicMock:
if kwargs.get("model") == "azure/my-azure-deployment":
raise litellm.APIConnectionError(
message="Connection error.",
llm_provider="azure",
model="azure/my-azure-deployment",
)
return MagicMock()
with patch("litellm.acreate_file", side_effect=fail_azure) as mock_acreate_file:
with pytest.raises(litellm.APIConnectionError):
await router.acreate_file(
model="azure-gpt",
purpose="batch",
file=MagicMock(),
)
called_models = [call.kwargs.get("model") for call in mock_acreate_file.call_args_list]
assert "azure/my-azure-deployment" in called_models
assert "gpt-4o-mini" not in called_models
@pytest.mark.asyncio
async def test_async_router_acreate_file_uses_deployment_custom_llm_provider():
"""
@ -7794,6 +7842,21 @@ class TestTaggedAutoRouterOnSharedModelName:
assert response is not None
assert response.model == "gemini-flash"
@pytest.mark.asyncio
async def test_request_level_tag_filtering_from_key_settings_bypasses_the_marker(self):
router = self._router(marker_tags=["route"], include_plain_sibling=True, enable_tag_filtering=False)
assert await self._hook_response(router, {"enable_tag_filtering": True}) is None
@pytest.mark.asyncio
async def test_globally_disabled_filtering_still_lets_the_marker_capture_untagged_requests(self):
router = self._router(marker_tags=["route"], include_plain_sibling=True, enable_tag_filtering=False)
response = await self._hook_response(router, {})
assert response is not None
assert response.model == "gemini-flash"
@pytest.mark.asyncio
async def test_marker_only_alias_still_captures_untagged_requests(self):
router = self._router(marker_tags=["route"], include_plain_sibling=False, enable_tag_filtering=True)

View file

@ -1,5 +1,6 @@
import os
import sys
from typing import Final
import pytest
@ -75,6 +76,29 @@ def test_usage_dump():
assert new_usage.prompt_tokens_details.web_search_requests == 1
def test_prompt_tokens_details_maps_nested_cache_creation_input_tokens():
"""Regression (LIT-5757): DashScope nests the Anthropic-spelled
cache_creation_input_tokens inside prompt_tokens_details. It must populate
the canonical cache_write_tokens/cache_creation_tokens pair, without
overriding an explicitly provided canonical value."""
from litellm.types.utils import PromptTokensDetailsWrapper
nested: Final = PromptTokensDetailsWrapper(
cached_tokens=0, text_tokens=2059, cache_creation_input_tokens=2048
)
assert nested.cache_write_tokens == 2048
assert nested.cache_creation_tokens == 2048
explicit: Final = PromptTokensDetailsWrapper(
cache_write_tokens=100, cache_creation_input_tokens=2048
)
assert explicit.cache_write_tokens == 100
assert explicit.cache_creation_tokens == 100
non_int: Final = PromptTokensDetailsWrapper(cache_creation_input_tokens=None)
assert not hasattr(non_int, "cache_write_tokens")
def test_usage_server_tool_use_dict_is_coerced_and_round_trips():
from litellm.types.utils import ServerToolUse, Usage

View file

@ -2328,14 +2328,6 @@
"count": 1
}
},
"src/components/organisms/create_key_button.test.tsx": {
"@typescript-eslint/no-require-imports": {
"count": 1
},
"react/display-name": {
"count": 8
}
},
"src/components/organisms/create_key_button.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2636,15 +2628,6 @@
"src/components/templates/key_edit_view.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"local/no-complex-jsx-arrow": {
"count": 2
},
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/templates/key_info_view.tsx": {
@ -3031,4 +3014,4 @@
"count": 1
}
}
}
}

View file

@ -28,6 +28,7 @@ interface TagsInputProps {
emptyText?: string;
tokenSeparators?: string[];
loading?: boolean;
disabled?: boolean;
id?: string;
}
@ -48,6 +49,7 @@ export const TagsInput = ({
emptyText = "No matching options",
tokenSeparators = [],
loading = false,
disabled = false,
id,
}: TagsInputProps) => {
const anchor = useComboboxAnchor();
@ -103,6 +105,7 @@ export const TagsInput = ({
itemToStringLabel={(option: TagsInputOption) => option.label}
filter={matchesQuery}
openOnInputClick
disabled={disabled || loading}
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
<ComboboxValue>

View file

@ -1,5 +1,6 @@
import * as networking from "@/components/networking";
import { fireEvent, render, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import GuardrailInfoView from "./guardrail_info";
@ -136,7 +137,7 @@ describe("Guardrail Info", () => {
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const { getByText, container } = render(
const { getByText, findByText, container } = render(
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
);
@ -152,18 +153,9 @@ describe("Guardrail Info", () => {
expect(getByText("Guardrail Settings")).toBeInTheDocument();
});
// Find the info icon and hover over it
const infoIcon = within(container).getByRole("img", { name: "info-circle" });
expect(infoIcon).toBeInTheDocument();
await userEvent.hover(within(container).getByRole("img", { name: "info-circle" }));
if (infoIcon) {
fireEvent.mouseEnter(infoIcon);
// Wait for the tooltip to appear
await waitFor(() => {
expect(getByText("Guardrail is defined in the config file and cannot be edited.")).toBeInTheDocument();
});
}
expect(await findByText("Guardrail is defined in the config file and cannot be edited.")).toBeInTheDocument();
});
it("should render the guardrail info", async () => {

View file

@ -6,7 +6,8 @@ import {
} from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CodeOutlined, EyeInvisibleOutlined, InfoCircleOutlined, StopOutlined } from "@ant-design/icons";
import { Button as AntdButton, Tooltip } from "antd";
import { Button as AntdButton } from "antd";
import { ArrowLeft, CheckIcon, CopyIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
@ -21,7 +22,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SimpleTooltip, TooltipProvider } from "@/components/ui/tooltip";
import {
asText,
GuardrailField,
@ -663,9 +664,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Guardrail Settings</h3>
{isConfigGuardrail && (
<Tooltip title="Guardrail is defined in the config file and cannot be edited.">
<SimpleTooltip content="Guardrail is defined in the config file and cannot be edited.">
<InfoCircleOutlined />
</Tooltip>
</SimpleTooltip>
)}
{!isEditing &&
!isConfigGuardrail &&

View file

@ -1,7 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
import { Tooltip, Button as AntdButton } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Button as AntdButton } from "antd";
import { z } from "zod/v4";
import { fetchUserModels } from "@/components/organisms/create_key_button";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
@ -280,7 +281,9 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
) : (
tagDetails.models.map((modelId) => (
<Badge key={modelId} variant="secondary">
<Tooltip title={`ID: ${modelId}`}>{tagDetails.model_info?.[modelId] || modelId}</Tooltip>
<SimpleTooltip content={`ID: ${modelId}`}>
{tagDetails.model_info?.[modelId] || modelId}
</SimpleTooltip>
</Badge>
))
)}

View file

@ -17,7 +17,8 @@ import {
teamMemberDeleteCall,
Member,
} from "@/components/networking";
import { Button as AntdButton, Modal, Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Button as AntdButton, Modal } from "antd";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import {
Combobox,
@ -747,10 +748,10 @@ export default function UserInfoView({
<SelectContent>
{MEMBER_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} title={option.value}>
<Tooltip title={option.hint}>
<SimpleTooltip content={option.hint}>
<span className="font-medium">{option.value}</span>
<span className="ml-2 text-muted-foreground text-sm">- {option.hint}</span>
</Tooltip>
</SimpleTooltip>
</SelectItem>
))}
</SelectContent>

View file

@ -403,28 +403,27 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
</div>
);
const pricingField = (name: TouchedPricingField, label: string, placeholder: string, description?: string) => (
<div>
<FieldLabel>{label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={name} description={description}>
{({ value, onChange, ...control }) => (
<NumericalInput
{...control}
value={value ?? ""}
placeholder={placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
markTouched(name);
onChange(event);
}}
/>
)}
</FormField>
) : (
const pricingField = (name: TouchedPricingField, label: string, placeholder: string, description?: string) =>
isEditing ? (
<FormField control={form.control} name={name} label={label} description={description}>
{({ value, onChange, ...control }) => (
<NumericalInput
{...control}
value={value ?? ""}
placeholder={placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
markTouched(name);
onChange(event);
}}
/>
)}
</FormField>
) : (
<div>
<FieldLabel>{label}</FieldLabel>
<Display>{displayCost(localModelData, name)}</Display>
)}
</div>
);
</div>
);
const tagsField = (
name: "model_access_group" | "guardrails" | "tags",

View file

@ -3,7 +3,8 @@
* Handles primary model selection and fallback chain configuration
*/
import { Select, Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select } from "antd";
import { AlertCircle, ArrowDown, X } from "lucide-react";
import React from "react";
@ -142,12 +143,9 @@ export function FallbackGroupConfig({
}}
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<Tooltip
styles={{ root: { pointerEvents: "none" } }}
title={omittedValues.map(({ value }) => value).join(", ")}
>
<SimpleTooltip content={omittedValues.map(({ value }) => value).join(", ")}>
<span>+{omittedValues.length} more</span>
</Tooltip>
</SimpleTooltip>
)}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}

View file

@ -3,7 +3,8 @@ import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
import { Segmented, Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Segmented } from "antd";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
@ -113,9 +114,9 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
<div className="overflow-hidden">
<div className="flex flex-wrap items-center gap-1">
{displayTags.map((tag, index) => (
<Tooltip
<SimpleTooltip
key={index}
title={
content={
<div>
<div>
<span className="text-gray-300">Tag Name:</span> {tag.tag}
@ -128,7 +129,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
}
>
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">{tag.tag.slice(0, 7)}...</span>
</Tooltip>
</SimpleTooltip>
))}
{hasMoreTags && (
<button

View file

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Select as AntdSelect, Card, InputNumber, Radio, Space, Switch, Tooltip, Typography } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Card, InputNumber, Radio, Space, Switch, Typography } from "antd";
import React from "react";
import ClassifierPromptEditor from "./ClassifierPromptEditor";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
@ -286,11 +287,14 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<div>
<div className="flex items-center gap-2 mb-1">
<Text strong>Classification Rubric</Text>
<Tooltip title="Every rubric uses the same four tiers and the same tier definitions. They differ only in the worked examples that show the classifier where the boundary between tiers sits.">
<SimpleTooltip content="Every rubric uses the same four tiers and the same tier definitions. They differ only in the worked examples that show the classifier where the boundary between tiers sits.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Tooltip title={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}>
<SimpleTooltip
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
className="w-full"
>
<AntdSelect
value={classificationRubric}
onChange={handleClassificationRubricChange}
@ -302,7 +306,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
}))}
/>
</Tooltip>
</SimpleTooltip>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
{usesCustomPrompt
? "Not in use: the custom prompt below is the classifier's entire rubric."
@ -335,8 +339,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<Text type="secondary"> right when the classifier grades complexity too</Text>
</Radio>
<Radio value="default_model" disabled={!hasDefaultModel}>
<Tooltip
title={
<SimpleTooltip
content={
hasDefaultModel
? "Change it from the Default Model select."
: "Set a default model on this router to use this option"
@ -346,7 +350,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<Text>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</Text>{" "}
<Text type="secondary"> right when your prompt grades something other than complexity</Text>
</span>
</Tooltip>
</SimpleTooltip>
</Radio>
</Space>
</Radio.Group>
@ -393,9 +397,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
aria-label="Include Assistant Turns"
/>
<Text strong>Include Assistant Turns</Text>
<Tooltip title="Off by default. Enabling it changes tier decisions, and therefore spend, for an existing router, and sends assistant text to the classifier model, which may be a different provider than the routed model.">
<SimpleTooltip content="Off by default. Enabling it changes tier decisions, and therefore spend, for an existing router, and sends assistant text to the classifier model, which may be a different provider than the routed model.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
Let the classifier read the assistant&apos;s replies, so difficulty the model stated rather than the user
@ -411,9 +415,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<Text strong>Custom Technical Keywords</Text>
<Tooltip title="Domain-specific terms appended to the built-in technical keyword list. Prompts containing these terms score higher on the technical dimension and route to more capable models.">
<SimpleTooltip content="Domain-specific terms appended to the built-in technical keyword list. Prompts containing these terms score higher on the technical dimension and route to more capable models.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension.

View file

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Select as AntdSelect, Card, Collapse, Divider, Input, Space, Switch, Tooltip, Typography } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Card, Collapse, Divider, Input, Space, Switch, Typography } from "antd";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -249,9 +250,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Typography.Title level={4} style={{ margin: 0 }}>
Complexity Tier Configuration
</Typography.Title>
<Tooltip title="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</Space>
<Text type="secondary" style={{ display: "block", marginBottom: 24 }}>
@ -279,9 +280,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Text strong style={{ fontSize: 16 }}>
{label} Tier
</Text>
<Tooltip title={tierInfo.description}>
<SimpleTooltip content={tierInfo.description}>
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
<Text type="secondary" style={{ fontSize: 12 }}>
Tier {index + 1} of {TIER_KEYS.length} &middot; {tier}
</Text>
@ -329,9 +330,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Text strong style={{ fontSize: 16 }}>
Default Model
</Text>
<Tooltip title="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<AntdSelect
value={value.default_model || undefined}

View file

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Select as AntdSelect, Tooltip, Typography } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Typography } from "antd";
import React from "react";
const { Text } = Typography;
@ -18,9 +19,9 @@ const EscalationKeywords: React.FC<EscalationKeywordsProps> = ({ keywords, onCha
<Typography.Title level={4} style={{ margin: 0 }}>
Escalation Keywords
</Typography.Title>
<Tooltip title="Case-sensitive phrases a user can include in their message to force a bump to the next-higher complexity tier when they aren't happy with results. They can force a stronger model, but not choose which one.">
<SimpleTooltip content="Case-sensitive phrases a user can include in their message to force a bump to the next-higher complexity tier when they aren't happy with results. They can force a stronger model, but not choose which one.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Optional: when a user message contains one of these phrases, the request is bumped one tier higher than it would

View file

@ -1,5 +1,6 @@
import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Card, Empty, Select as AntdSelect, Tooltip, Typography } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Button, Card, Empty, Select as AntdSelect, Typography } from "antd";
import React from "react";
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
@ -70,9 +71,9 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<Typography.Title level={4} style={{ margin: 0 }}>
Keyword Tier Overrides
</Typography.Title>
<Tooltip title="Match known terms and force the request straight to a chosen complexity tier, bypassing rule-based scoring.">
<SimpleTooltip content="Match known terms and force the request straight to a chosen complexity tier, bypassing rule-based scoring.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Button icon={<PlusOutlined />} onClick={addRule}>
Add keyword rule

View file

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { InputNumber, Select as AntdSelect, Switch, Tooltip, Typography } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { InputNumber, Select as AntdSelect, Switch, Typography } from "antd";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
@ -41,9 +42,9 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
<div>
<div className="flex items-center gap-2">
<Text className="font-medium">Semantic keyword matching</Text>
<Tooltip title="Recognize related phrasing beyond exact keyword matches by comparing embeddings instead of plain text. Overrides direct keyword matching">
<SimpleTooltip content="Recognize related phrasing beyond exact keyword matches by comparing embeddings instead of plain text. Overrides direct keyword matching">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</SimpleTooltip>
</div>
<Text className="text-gray-500 text-sm">
Uses same keyword-tier pairs as above and overrides direct keyword matching. Adds latency based on embedding

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Form, Table } from "antd";
import { Input } from "@/components/ui/input";
import { Tooltip } from "../atoms/index";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Providers } from "../provider_info_helpers";
const ConditionalPublicModelName: React.FC = () => {
@ -119,7 +119,7 @@ const ConditionalPublicModelName: React.FC = () => {
title: (
<span className="flex items-center">
Public Model Name
<Tooltip content={publicNameTooltipContent} width="500px" />
<SimpleTooltip content={publicNameTooltipContent} width="500px" />
</span>
),
dataIndex: "public_name",
@ -164,7 +164,7 @@ const ConditionalPublicModelName: React.FC = () => {
title: (
<span className="flex items-center">
LiteLLM Model Name
<Tooltip content={liteLLMModelTooltipContent} width="360px" />
<SimpleTooltip content={liteLLMModelTooltipContent} width="360px" />
</span>
),
dataIndex: "litellm_model",

View file

@ -1,40 +0,0 @@
import React from "react";
import { CircleHelp } from "lucide-react";
import { Tooltip as ShadcnTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
interface TooltipProps {
content: React.ReactNode;
children?: React.ReactNode;
width?: string;
className?: string;
}
const widthClassNames: Record<string, string> = {
"360px": "max-w-[360px]",
"500px": "max-w-[500px]",
auto: "max-w-xs",
};
export const Tooltip: React.FC<TooltipProps> = ({ content, children, width = "auto", className }) => (
<TooltipProvider>
<ShadcnTooltip>
<TooltipTrigger
render={
<span
className={cn(
"inline-flex cursor-help items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
/>
}
>
{children ?? <CircleHelp aria-label="question-circle" className="ml-1 size-4 text-muted-foreground" />}
</TooltipTrigger>
<TooltipContent className={cn("whitespace-normal", widthClassNames[width] ?? "max-w-xs")}>
{content}
</TooltipContent>
</ShadcnTooltip>
</TooltipProvider>
);

View file

@ -1 +0,0 @@
export { Tooltip } from "./Tooltip";

View file

@ -1,4 +1,4 @@
import { Tooltip } from "@/components/atoms/Tooltip";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Member } from "@/components/networking";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
@ -60,9 +60,9 @@ export default function MemberTable({
{roleTooltip ? (
<span className="inline-flex items-center gap-2">
{roleColumnTitle}
<Tooltip content={roleTooltip}>
<SimpleTooltip content={roleTooltip}>
<Info className="size-3.5" />
</Tooltip>
</SimpleTooltip>
</span>
) : (
roleColumnTitle

View file

@ -0,0 +1,39 @@
import { readFileSync } from "fs";
import { resolve } from "path";
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import PremiumLoggingSettings from "./PremiumLoggingSettings";
const SOURCE_PATH = resolve(process.cwd(), "src/components/common_components/PremiumLoggingSettings.tsx");
const HARDCODED_PALETTE =
/\b(?:text|bg|border|hover:bg|hover:text|hover:border|dark:bg|dark:text|dark:border|ring|divide|fill|stroke)-(?:gray|slate|zinc|neutral|stone|red|blue|green|yellow|amber|orange|indigo|purple|pink|rose|teal|cyan|sky|violet|fuchsia|lime|emerald)-\d+(?:\/\d+)?\b/g;
const SEMANTIC_TOKEN =
/\b(?:text|bg|border|hover:bg|hover:text|ring|divide|fill|stroke)-(?:foreground|muted-foreground|muted|background|card|popover|primary|secondary|destructive|border|input|accent|ring)(?:-foreground)?(?:\/\d+)?\b/g;
describe("PremiumLoggingSettings", () => {
it("styles itself from semantic tokens instead of hardcoded palette classes", () => {
const source = readFileSync(SOURCE_PATH, "utf8");
expect(source).toContain("export function PremiumLoggingSettings");
expect(source.match(SEMANTIC_TOKEN) ?? []).not.toHaveLength(0);
expect(source.match(HARDCODED_PALETTE) ?? []).toHaveLength(0);
});
it("shows the enterprise notice and withholds the editor from a free user", () => {
renderWithProviders(<PremiumLoggingSettings value={[]} onChange={vi.fn()} />);
expect(screen.getByText(/LiteLLM Enterprise feature/)).toBeInTheDocument();
expect(screen.getByText("✨ langfuse-logging")).toBeInTheDocument();
expect(screen.queryByText("Logging Integrations")).not.toBeInTheDocument();
});
it("renders the editor for a premium user", () => {
renderWithProviders(<PremiumLoggingSettings value={[]} onChange={vi.fn()} premiumUser />);
expect(screen.getByText("Logging Integrations")).toBeInTheDocument();
expect(screen.queryByText(/LiteLLM Enterprise feature/)).not.toBeInTheDocument();
});
});

View file

@ -1,4 +1,5 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import LoggingSettings from "../team/LoggingSettings";
interface PremiumLoggingSettingsProps {
@ -20,15 +21,15 @@ export function PremiumLoggingSettings({
return (
<div>
<div className="flex flex-wrap gap-2 mb-3">
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm font-medium opacity-50">
<Badge variant="secondary" className="opacity-50">
langfuse-logging
</div>
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm font-medium opacity-50">
</Badge>
<Badge variant="secondary" className="opacity-50">
datadog-logging
</div>
</Badge>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<div className="p-3 bg-muted border border-border rounded-lg">
<p className="text-sm text-muted-foreground">
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
all free users. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">

View file

@ -161,6 +161,26 @@ describe("UserSearchModal submit payload", () => {
});
});
it("commits the first match when the typed search is confirmed with Enter", async () => {
const { user, onSubmit } = setup();
const input = getEmailSearchInput();
await user.click(input);
await user.type(input, "pick");
await waitFor(() => expect(userFilterUICall).toHaveBeenCalled(), { timeout: 3000 });
await screen.findByRole("option", { name: "picked@example.com" });
await user.keyboard("{Enter}");
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toStrictEqual({
user_email: "picked@example.com",
user_id: "u-1",
role: "user",
});
});
it("does not submit on Enter in any field, while the button still does", async () => {
const { user, onSubmit } = setup();

View file

@ -165,6 +165,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
<Combobox
items={items}
value={selected}
// @ts-expect-error TS2322 -- Combobox.Root narrows autoHighlight to boolean; the AriaCombobox it wraps
// accepts "always", the only value that highlights a list this component filters server-side
autoHighlight="always"
filter={null}
onValueChange={(option: UserOption | null) => {
controlProps.onChange(option?.value);

View file

@ -1,4 +1,5 @@
import { Button, Select, Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Button, Select } from "antd";
import { ArrowDown, Plus, X } from "lucide-react";
import React, { useState } from "react";
@ -128,12 +129,9 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
getPopupContainer={(trigger) => trigger.parentElement || document.body}
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<Tooltip
styles={{ root: { pointerEvents: "none" } }}
title={omittedValues.map(({ value: v }) => v).join(", ")}
>
<SimpleTooltip content={omittedValues.map(({ value: v }) => v).join(", ")}>
<span>+{omittedValues.length} more</span>
</Tooltip>
</SimpleTooltip>
)}
/>
{entry.fallbackModels.length > 1 && (

View file

@ -7,7 +7,8 @@ import { KeyIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button as AntdButton, Modal, Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Button as AntdButton, Modal } from "antd";
import { applyPtuModelInfo } from "../utils/ptuModelInfo";
import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled";
import { ArrowLeft, CheckIcon, CopyIcon } from "lucide-react";
@ -685,11 +686,11 @@ export default function ModelInfoView({
<Card className="block p-6">
<p className="text-sm">LiteLLM Model</p>
<div className="mt-2 overflow-hidden">
<Tooltip title={modelData.litellm_model_name || "Not Set"}>
<SimpleTooltip content={modelData.litellm_model_name || "Not Set"} className="w-full min-w-0">
<div className="break-all text-sm font-medium leading-relaxed cursor-pointer">
{modelData.litellm_model_name || "Not Set"}
</div>
</Tooltip>
</SimpleTooltip>
</div>
</Card>
<Card className="block p-6">
@ -751,9 +752,9 @@ export default function ModelInfoView({
</Button>
)
) : (
<Tooltip title="Only DB models can be edited. You must be an admin or the creator of the model to edit it.">
<SimpleTooltip content="Only DB models can be edited. You must be an admin or the creator of the model to edit it.">
<InfoCircleOutlined />
</Tooltip>
</SimpleTooltip>
)}
</div>
</div>

View file

@ -0,0 +1,556 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildKeyCreatePayload, type KeyCreateInput, type KeyPayloadResult } from "./createKeyPayload";
const baseInput: KeyCreateInput = {
formValues: {},
existingKeys: null,
keyOwner: "you",
userID: "test-user",
selectedAgentId: null,
loggingSettings: [],
disabledCallbacks: [],
autoRotationEnabled: false,
rotationInterval: "30d",
modelAliases: {},
routerSettings: null,
budgetLimits: [],
tagRateLimits: [],
budgetFallbacks: {},
};
const build = (formValues: Record<string, unknown>, overrides: Partial<KeyCreateInput> = {}): KeyPayloadResult =>
buildKeyCreatePayload({ ...baseInput, ...overrides, formValues });
const payloadOf = (result: KeyPayloadResult): Record<string, unknown> => {
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("unreachable");
return result.payload;
};
const wireKeys = (payload: Record<string, unknown>): string[] =>
Object.keys(JSON.parse(JSON.stringify(payload)) as Record<string, unknown>);
const DROPPED_AT_SERIALISATION = [
"access_group_ids",
"allowed_passthrough_routes",
"allowed_vector_store_ids",
"budget_duration",
"enable_prompt_caching",
"guardrails",
"max_budget",
"organization_id",
"policies",
"prompts",
"rpm_limit",
"tags",
"throttle_on_budget_exceeded",
"tpm_limit",
];
const CLOSED_SECTIONS_VALUES = {
organization_id: undefined,
team_id: null,
key_alias: "my-key",
models: [],
key_type: "llm_api",
};
const OPTIONAL_SETTINGS_VALUES = {
...CLOSED_SECTIONS_VALUES,
max_budget: undefined,
budget_duration: undefined,
tpm_limit: undefined,
tpm_limit_type: "key",
rpm_limit: undefined,
rpm_limit_type: "key",
throttle_on_budget_exceeded: undefined,
enable_prompt_caching: undefined,
guardrails: undefined,
disable_global_guardrails: undefined,
policies: undefined,
prompts: undefined,
access_group_ids: undefined,
allowed_passthrough_routes: undefined,
allowed_vector_store_ids: undefined,
tags: undefined,
};
const aliasOnly = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
key_alias: "my-key",
user_id: "test-user",
duration: null,
metadata: "{}",
...overrides,
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("always-present keys", () => {
it("emits the eight keys the closed form sends, and nothing else", () => {
const closedFormValues = { ...CLOSED_SECTIONS_VALUES };
const closedFormPayload = {
...closedFormValues,
user_id: "test-user",
duration: null,
metadata: "{}",
};
expect(payloadOf(build(closedFormValues))).toStrictEqual(closedFormPayload);
});
it("injects user_id, duration and metadata even when the form reported none of them", () => {
expect(payloadOf(build({ key_alias: "my-key" }))).toStrictEqual(aliasOnly());
});
it("keeps a mounted-but-untouched field as an undefined-valued key rather than dropping it", () => {
expect(payloadOf(build({ key_alias: "my-key", guardrails: undefined, tags: undefined }))).toStrictEqual(
aliasOnly({ guardrails: undefined, tags: undefined }),
);
});
});
describe("duration", () => {
it("forwards a typed duration by value", () => {
expect(payloadOf(build({ key_alias: "my-key", duration: "45d" }))).toStrictEqual(aliasOnly({ duration: "45d" }));
});
it.each([
["an empty string", ""],
["a whitespace-only string", " "],
["undefined", undefined],
])("coalesces %s to null", (_label, duration) => {
expect(payloadOf(build({ key_alias: "my-key", duration }))).toStrictEqual(aliasOnly({ duration: null }));
});
it("does not coerce a non-string duration", () => {
expect(() => build({ key_alias: "my-key", duration: 30 })).toThrow(TypeError);
});
});
describe("key ownership", () => {
it("overwrites user_id with the signed-in user when the key is owned by you", () => {
expect(payloadOf(build({ key_alias: "my-key", user_id: "someone-else" }))).toStrictEqual(
aliasOnly({ user_id: "test-user" }),
);
});
it("leaves the form's user_id alone for another_user", () => {
const expected = { key_alias: "my-key", user_id: "someone-else", duration: null, metadata: "{}" };
expect(
payloadOf(build({ key_alias: "my-key", user_id: "someone-else" }, { keyOwner: "another_user" })),
).toStrictEqual(expected);
});
it("adds the selected agent id for an agent-owned key", () => {
const expected = { key_alias: "my-key", agent_id: "agent-1", duration: null, metadata: "{}" };
expect(payloadOf(build({ key_alias: "my-key" }, { keyOwner: "agent", selectedAgentId: "agent-1" }))).toStrictEqual(
expected,
);
});
it("reports agent_not_selected instead of building a payload when no agent is selected", () => {
expect(build({ key_alias: "my-key" }, { keyOwner: "agent", selectedAgentId: null })).toStrictEqual({
kind: "agent_not_selected",
});
});
it("stamps the alias into metadata as the service account id and sends no user_id", () => {
expect(payloadOf(build({ key_alias: "svc-key" }, { keyOwner: "service_account" }))).toStrictEqual({
key_alias: "svc-key",
duration: null,
metadata: '{"service_account_id":"svc-key"}',
});
});
});
describe("metadata", () => {
it("re-serialises the parsed form value", () => {
expect(payloadOf(build({ key_alias: "my-key", metadata: '{"team":"core"}' }))).toStrictEqual(
aliasOnly({ metadata: '{"team":"core"}' }),
);
});
it("falls back to an empty object and logs when the JSON is malformed", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(payloadOf(build({ key_alias: "my-key", metadata: "{not json" }))).toStrictEqual(aliasOnly());
expect(consoleError).toHaveBeenCalledWith("Error parsing metadata:", expect.any(SyntaxError));
});
it("merges logging configs, dropping rows with no callback selected", () => {
expect(
payloadOf(
build(
{ key_alias: "my-key", metadata: '{"team":"core"}' },
{ loggingSettings: [{ callback_name: "langfuse" }, { callback_name: "" }] },
),
),
).toStrictEqual(aliasOnly({ metadata: '{"team":"core","logging":[{"callback_name":"langfuse"}]}' }));
});
it("maps disabled callbacks from display names to internal names", () => {
expect(payloadOf(build({ key_alias: "my-key" }, { disabledCallbacks: ["Langfuse"] }))).toStrictEqual(
aliasOnly({ metadata: '{"litellm_disabled_callbacks":["langfuse"]}' }),
);
});
it("keeps an array metadata as an array when stamping the service account id", () => {
expect(
payloadOf(build({ key_alias: "svc-key", metadata: '["a"]' }, { keyOwner: "service_account" })),
).toStrictEqual({ key_alias: "svc-key", duration: null, metadata: '["a"]' });
});
it("rejects a service account whose metadata parses to a primitive", () => {
expect(() => build({ key_alias: "svc-key", metadata: "5" }, { keyOwner: "service_account" })).toThrow(TypeError);
});
it("keeps every metadata contributor in one object", () => {
expect(
payloadOf(
build(
{ key_alias: "svc-key", metadata: '{"team":"core"}' },
{
keyOwner: "service_account",
loggingSettings: [{ callback_name: "otel" }],
disabledCallbacks: ["Datadog"],
},
),
),
).toStrictEqual({
key_alias: "svc-key",
duration: null,
metadata:
'{"team":"core","service_account_id":"svc-key","logging":[{"callback_name":"otel"}],"litellm_disabled_callbacks":["datadog"]}',
});
});
});
describe("object_permission", () => {
it("is absent when nothing contributes to it", () => {
expect(payloadOf(build({ key_alias: "my-key", allowed_vector_store_ids: [] }))).toStrictEqual(
aliasOnly({ allowed_vector_store_ids: [] }),
);
});
it("moves selected vector stores off the top level", () => {
expect(payloadOf(build({ key_alias: "my-key", allowed_vector_store_ids: ["vs-1"] }))).toStrictEqual(
aliasOnly({ object_permission: { vector_stores: ["vs-1"] } }),
);
});
it("splits an MCP selection into servers, access groups and toolsets", () => {
expect(
payloadOf(
build({
key_alias: "my-key",
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["g-1"], toolsets: ["t-1"] },
}),
),
).toStrictEqual(
aliasOnly({
object_permission: { mcp_servers: ["s-1"], mcp_access_groups: ["g-1"], mcp_toolsets: ["t-1"] },
}),
);
});
it("omits the empty halves of an MCP selection", () => {
expect(
payloadOf(
build({
key_alias: "my-key",
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: [], toolsets: [] },
}),
),
).toStrictEqual(aliasOnly({ object_permission: { mcp_servers: ["s-1"] } }));
});
it("leaves an all-empty MCP selection on the top level", () => {
expect(
payloadOf(
build({ key_alias: "my-key", allowed_mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] } }),
),
).toStrictEqual(aliasOnly({ allowed_mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] } }));
});
it("nests configured MCP tool permissions", () => {
expect(payloadOf(build({ key_alias: "my-key", mcp_tool_permissions: { "s-1": ["read"] } }))).toStrictEqual(
aliasOnly({ object_permission: { mcp_tool_permissions: { "s-1": ["read"] } } }),
);
});
it("always strips mcp_tool_permissions from the top level, even when empty", () => {
expect(payloadOf(build({ key_alias: "my-key", mcp_tool_permissions: {} }))).toStrictEqual(aliasOnly());
});
it("lets a standalone access group list win over the one from the MCP selection", () => {
expect(
payloadOf(
build({
key_alias: "my-key",
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["from-selector"] },
allowed_mcp_access_groups: ["standalone"],
}),
),
).toStrictEqual(aliasOnly({ object_permission: { mcp_servers: ["s-1"], mcp_access_groups: ["standalone"] } }));
});
it("splits an agent selection into agents and agent access groups", () => {
expect(
payloadOf(build({ key_alias: "my-key", allowed_agents_and_groups: { agents: ["a-1"], accessGroups: ["ag-1"] } })),
).toStrictEqual(aliasOnly({ object_permission: { agents: ["a-1"], agent_access_groups: ["ag-1"] } }));
});
it("merges every source into a single object_permission", () => {
const everySource = {
key_alias: "my-key",
allowed_vector_store_ids: ["vs-1"],
allowed_mcp_servers_and_groups: { servers: ["s-1"], accessGroups: ["g-1"], toolsets: ["t-1"] },
mcp_tool_permissions: { "s-1": ["read"] },
allowed_agents_and_groups: { agents: ["a-1"], accessGroups: ["ag-1"] },
};
expect(payloadOf(build(everySource))).toStrictEqual(
aliasOnly({
object_permission: {
vector_stores: ["vs-1"],
mcp_servers: ["s-1"],
mcp_access_groups: ["g-1"],
mcp_toolsets: ["t-1"],
mcp_tool_permissions: { "s-1": ["read"] },
agents: ["a-1"],
agent_access_groups: ["ag-1"],
},
}),
);
});
});
describe("premium and rotation flags", () => {
it("drops disable_global_guardrails when it is off", () => {
expect(payloadOf(build({ key_alias: "my-key", disable_global_guardrails: false }))).toStrictEqual(aliasOnly());
});
it("keeps disable_global_guardrails when it is on", () => {
expect(payloadOf(build({ key_alias: "my-key", disable_global_guardrails: true }))).toStrictEqual(
aliasOnly({ disable_global_guardrails: true }),
);
});
it("adds the rotation fields only when auto rotation is enabled", () => {
expect(
payloadOf(build({ key_alias: "my-key" }, { autoRotationEnabled: true, rotationInterval: "7d" })),
).toStrictEqual(aliasOnly({ auto_rotate: true, rotation_interval: "7d" }));
});
it("sends no rotation fields when auto rotation is off", () => {
expect(payloadOf(build({ key_alias: "my-key" }, { rotationInterval: "7d" }))).toStrictEqual(aliasOnly());
});
});
describe("keys sourced from component state", () => {
it("serialises model aliases", () => {
expect(payloadOf(build({ key_alias: "my-key" }, { modelAliases: { fast: "gpt-4o-mini" } }))).toStrictEqual(
aliasOnly({ aliases: '{"fast":"gpt-4o-mini"}' }),
);
});
it("sends router settings that hold at least one value", () => {
expect(
payloadOf(build({ key_alias: "my-key" }, { routerSettings: { router_settings: { num_retries: 3 } } })),
).toStrictEqual(aliasOnly({ router_settings: { num_retries: 3 } }));
});
it("skips router settings whose every field is blank", () => {
expect(
payloadOf(
build(
{ key_alias: "my-key" },
{ routerSettings: { router_settings: { num_retries: null, timeout: undefined, routing_strategy: "" } } },
),
),
).toStrictEqual(aliasOnly());
});
it("keeps only budget windows that carry both a duration and a limit", () => {
expect(
payloadOf(
build(
{ key_alias: "my-key" },
{
budgetLimits: [
{ budget_duration: "1h", max_budget: 5 },
{ budget_duration: "", max_budget: 3 },
{ budget_duration: "7d", max_budget: null },
],
},
),
),
).toStrictEqual(aliasOnly({ budget_limits: [{ budget_duration: "1h", max_budget: 5 }] }));
});
it("keeps a zero budget window rather than treating it as unset", () => {
expect(
payloadOf(build({ key_alias: "my-key" }, { budgetLimits: [{ budget_duration: "1h", max_budget: 0 }] })),
).toStrictEqual(aliasOnly({ budget_limits: [{ budget_duration: "1h", max_budget: 0 }] }));
});
it("omits budget_limits when no window is complete", () => {
expect(
payloadOf(build({ key_alias: "my-key" }, { budgetLimits: [{ budget_duration: "7d", max_budget: null }] })),
).toStrictEqual(aliasOnly());
});
it("reduces tag rows into a tag_rpm_limit map", () => {
expect(
payloadOf(
build(
{ key_alias: "my-key" },
{
tagRateLimits: [
{ id: "r-1", tag: "prod", rpm_limit: 10 },
{ id: "r-2", tag: " ", rpm_limit: 5 },
{ id: "r-3", tag: "dev", rpm_limit: null },
],
},
),
),
).toStrictEqual(aliasOnly({ tag_rpm_limit: { prod: 10 } }));
});
it("omits tag_rpm_limit when no row is complete", () => {
expect(
payloadOf(build({ key_alias: "my-key" }, { tagRateLimits: [{ id: "r-1", tag: "", rpm_limit: 10 }] })),
).toStrictEqual(aliasOnly());
});
it("sends configured budget fallbacks", () => {
expect(payloadOf(build({ key_alias: "my-key" }, { budgetFallbacks: { "gpt-4": ["gpt-4o"] } }))).toStrictEqual(
aliasOnly({ budget_fallbacks: { "gpt-4": ["gpt-4o"] } }),
);
});
});
describe("budget duration", () => {
it("turns the never-resets sentinel into null", () => {
expect(payloadOf(build({ key_alias: "my-key", budget_duration: "none" }))).toStrictEqual(
aliasOnly({ budget_duration: null }),
);
});
it("forwards a real budget duration untouched", () => {
expect(payloadOf(build({ key_alias: "my-key", budget_duration: "30d" }))).toStrictEqual(
aliasOnly({ budget_duration: "30d" }),
);
});
});
describe("purity", () => {
it("leaves the submitted form values untouched", () => {
const values = {
key_alias: "my-key",
mcp_tool_permissions: { "s-1": ["read"] },
allowed_vector_store_ids: ["vs-1"],
disable_global_guardrails: false,
duration: "",
};
const before = structuredClone(values);
build(values);
expect(values).toStrictEqual(before);
});
});
describe("serialised wire shape", () => {
it("keeps an untouched closed form at eight object keys and seven wire keys", () => {
const payload = payloadOf(build(CLOSED_SECTIONS_VALUES));
expect(Object.keys(payload)).toHaveLength(8);
expect(wireKeys(payload)).toStrictEqual([
"team_id",
"key_alias",
"models",
"key_type",
"user_id",
"duration",
"metadata",
]);
expect(payload.duration).toBeNull();
});
it("drops the undefined picker and keeps the null one, which is what the two Form.Items differ on", () => {
const payload = payloadOf(build(CLOSED_SECTIONS_VALUES));
expect(payload.organization_id).toBeUndefined();
expect(payload.team_id).toBeNull();
expect(wireKeys(payload)).not.toContain("organization_id");
expect(wireKeys(payload)).toContain("team_id");
});
it("forwards a selected team by value", () => {
expect(payloadOf(build({ ...CLOSED_SECTIONS_VALUES, team_id: "team-1" })).team_id).toBe("team-1");
});
it("adds fifteen keys to the object and only the two limit types to the wire when Optional Settings opens", () => {
const payload = payloadOf(build(OPTIONAL_SETTINGS_VALUES));
expect(Object.keys(payload)).toHaveLength(23);
expect(wireKeys(payload)).toStrictEqual([
"team_id",
"key_alias",
"models",
"key_type",
"tpm_limit_type",
"rpm_limit_type",
"user_id",
"duration",
"metadata",
]);
});
it("never turns an undefined-valued key into null or an empty string", () => {
const payload = payloadOf(build(OPTIONAL_SETTINGS_VALUES));
DROPPED_AT_SERIALISATION.forEach((key) => {
expect(payload[key]).toBeUndefined();
});
expect(wireKeys(payload)).toEqual(expect.not.arrayContaining(DROPPED_AT_SERIALISATION));
});
});
describe("duplicate alias", () => {
it("reports the clash instead of building a payload", () => {
expect(
build({ key_alias: "taken", team_id: "team-1" }, { existingKeys: [{ team_id: "team-1", key_alias: "taken" }] }),
).toStrictEqual({ kind: "duplicate_alias", alias: "taken", teamId: "team-1" });
});
it("scopes the clash to the same team", () => {
expect(
payloadOf(
build({ key_alias: "taken", team_id: "team-2" }, { existingKeys: [{ team_id: "team-1", key_alias: "taken" }] }),
).key_alias,
).toBe("taken");
});
it("treats a keyless form and a teamless key as the same bucket", () => {
expect(build({}, { existingKeys: [{ team_id: null, key_alias: "" }] })).toStrictEqual({
kind: "duplicate_alias",
alias: "",
teamId: null,
});
});
it("checks the alias before the agent selection", () => {
expect(
build(
{ key_alias: "taken" },
{ existingKeys: [{ team_id: null, key_alias: "taken" }], keyOwner: "agent", selectedAgentId: null },
).kind,
).toBe("duplicate_alias");
});
});
describe("endpoint", () => {
it.each([
["you", "standard"],
["another_user", "standard"],
["service_account", "service_account"],
])("routes a %s key to the %s endpoint", (keyOwner, endpoint) => {
const result = build({ key_alias: "my-key" }, { keyOwner });
expect(result.kind === "ok" && result.endpoint).toBe(endpoint);
});
});

View file

@ -0,0 +1,212 @@
import { mapDisplayToInternalNames } from "../callback_info_helpers";
import { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
import type { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import { tagRowsToLimits, type TagRateLimitEntry } from "../key_team_helpers/TagRateLimitEditor";
export interface KeyLoggingSetting {
callback_name?: string;
}
export interface ExistingKey {
readonly team_id?: string | null;
readonly key_alias?: string | null;
}
export interface KeyCreateInput {
readonly formValues: Record<string, unknown>;
readonly existingKeys: readonly ExistingKey[] | null;
readonly keyOwner: string;
readonly userID: string | null;
readonly selectedAgentId: string | null;
readonly loggingSettings: KeyLoggingSetting[];
readonly disabledCallbacks: string[];
readonly autoRotationEnabled: boolean;
readonly rotationInterval: string;
readonly modelAliases: Record<string, string>;
readonly routerSettings: RouterSettingsAccordionValue | null;
readonly budgetLimits: BudgetWindowEntry[];
readonly tagRateLimits: TagRateLimitEntry[];
readonly budgetFallbacks: Record<string, string[]>;
}
export type KeyPayloadResult =
| {
readonly kind: "ok";
readonly payload: Record<string, unknown>;
readonly endpoint: "standard" | "service_account";
}
| { readonly kind: "duplicate_alias"; readonly alias: string; readonly teamId: string | null }
| { readonly kind: "agent_not_selected" };
interface McpSelection {
readonly servers?: unknown[];
readonly accessGroups?: unknown[];
readonly toolsets?: unknown[];
}
interface AgentSelection {
readonly agents?: unknown[];
readonly accessGroups?: unknown[];
}
const nonEmptyList = (raw: unknown): unknown[] | undefined => {
const list = raw as unknown[] | undefined;
return list && list.length > 0 ? list : undefined;
};
const readMcpSelection = (raw: unknown): McpSelection | undefined => {
const selection = raw as McpSelection | undefined;
if (!selection) return undefined;
const servers = nonEmptyList(selection.servers);
const accessGroups = nonEmptyList(selection.accessGroups);
const toolsets = nonEmptyList(selection.toolsets);
if (!servers && !accessGroups && !toolsets) return undefined;
return { servers, accessGroups, toolsets };
};
const readAgentSelection = (raw: unknown): AgentSelection | undefined => {
const selection = raw as AgentSelection | undefined;
if (!selection) return undefined;
const agents = nonEmptyList(selection.agents);
const accessGroups = nonEmptyList(selection.accessGroups);
if (!agents && !accessGroups) return undefined;
return { agents, accessGroups };
};
const readToolPermissions = (raw: unknown): unknown | undefined => {
const permissions = raw || {};
return Object.keys(permissions as object).length > 0 ? permissions : undefined;
};
const parseMetadata = (raw: unknown): unknown => {
try {
return JSON.parse((raw as string) || "{}");
} catch (error) {
console.error("Error parsing metadata:", error);
return {};
}
};
const buildMetadataJson = (values: Record<string, unknown>, input: KeyCreateInput): string => {
const parsed = parseMetadata(values.metadata);
if (input.keyOwner === "service_account") {
(parsed as Record<string, unknown>).service_account_id = values.key_alias;
}
const logged =
input.loggingSettings.length > 0
? { ...(parsed as object), logging: input.loggingSettings.filter((config) => config.callback_name) }
: parsed;
const disabled =
input.disabledCallbacks.length > 0
? { ...(logged as object), litellm_disabled_callbacks: mapDisplayToInternalNames(input.disabledCallbacks) }
: logged;
return JSON.stringify(disabled);
};
interface PermissionSources {
readonly vectorStores: unknown[] | undefined;
readonly mcp: McpSelection | undefined;
readonly toolPermissions: unknown | undefined;
readonly extraMcpAccessGroups: unknown[] | undefined;
readonly agents: AgentSelection | undefined;
}
const readPermissionSources = (values: Record<string, unknown>): PermissionSources => ({
vectorStores: nonEmptyList(values.allowed_vector_store_ids),
mcp: readMcpSelection(values.allowed_mcp_servers_and_groups),
toolPermissions: readToolPermissions(values.mcp_tool_permissions),
extraMcpAccessGroups: nonEmptyList(values.allowed_mcp_access_groups),
agents: readAgentSelection(values.allowed_agents_and_groups),
});
const buildObjectPermission = ({
vectorStores,
mcp,
toolPermissions,
extraMcpAccessGroups,
agents,
}: PermissionSources): Record<string, unknown> | undefined => {
const permission: Record<string, unknown> = {
...(vectorStores && { vector_stores: vectorStores }),
...(mcp?.servers && { mcp_servers: mcp.servers }),
...(mcp?.accessGroups && { mcp_access_groups: mcp.accessGroups }),
...(mcp?.toolsets && { mcp_toolsets: mcp.toolsets }),
...(toolPermissions !== undefined && { mcp_tool_permissions: toolPermissions }),
...(extraMcpAccessGroups && { mcp_access_groups: extraMcpAccessGroups }),
...(agents?.agents && { agents: agents.agents }),
...(agents?.accessGroups && { agent_access_groups: agents.accessGroups }),
};
return Object.keys(permission).length > 0 ? permission : undefined;
};
const consumedSourceKeys = (
values: Record<string, unknown>,
{ vectorStores, mcp, extraMcpAccessGroups, agents }: PermissionSources,
): ReadonlySet<string> =>
new Set<string>([
"mcp_tool_permissions",
...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]),
...(vectorStores ? ["allowed_vector_store_ids"] : []),
...(mcp ? ["allowed_mcp_servers_and_groups"] : []),
...(extraMcpAccessGroups ? ["allowed_mcp_access_groups"] : []),
...(agents ? ["allowed_agents_and_groups"] : []),
]);
const withoutKeys = (values: Record<string, unknown>, dropped: ReadonlySet<string>): Record<string, unknown> =>
Object.fromEntries(Object.entries(values).filter(([key]) => !dropped.has(key)));
const duplicateAlias = (input: KeyCreateInput): { alias: string; teamId: string | null } | undefined => {
const alias = (input.formValues?.key_alias as string | undefined) ?? "";
const teamId = (input.formValues?.team_id as string | undefined) ?? null;
const taken = (input.existingKeys ?? []).filter((key) => key.team_id === teamId).map((key) => key.key_alias);
return taken.includes(alias) ? { alias, teamId } : undefined;
};
export const buildKeyCreatePayload = (input: KeyCreateInput): KeyPayloadResult => {
const duplicate = duplicateAlias(input);
if (duplicate) {
return { kind: "duplicate_alias", ...duplicate };
}
if (input.keyOwner === "agent" && !input.selectedAgentId) {
return { kind: "agent_not_selected" };
}
const values = input.formValues;
const sources = readPermissionSources(values);
const objectPermission = buildObjectPermission(sources);
const dropped = consumedSourceKeys(values, sources);
const duration = values.duration;
const validWindows = input.budgetLimits.filter(
(window) => window.budget_duration && window.max_budget !== null && window.max_budget !== undefined,
);
const { tag_rpm_limit } = tagRowsToLimits(input.tagRateLimits);
const routerSettings = input.routerSettings?.router_settings;
const configuredRouterSettings =
routerSettings &&
Object.values(routerSettings).some((value) => value !== null && value !== undefined && value !== "")
? routerSettings
: undefined;
return {
kind: "ok",
endpoint: input.keyOwner === "service_account" ? "service_account" : "standard",
payload: {
...withoutKeys(values, dropped),
...(input.keyOwner === "you" && { user_id: input.userID }),
...(input.keyOwner === "agent" && { agent_id: input.selectedAgentId }),
...(input.autoRotationEnabled && { auto_rotate: true, rotation_interval: input.rotationInterval }),
duration: !duration || (duration as string).trim() === "" ? null : duration,
metadata: buildMetadataJson(values, input),
...(objectPermission && { object_permission: objectPermission }),
...(Object.keys(input.modelAliases).length > 0 && { aliases: JSON.stringify(input.modelAliases) }),
...(configuredRouterSettings && { router_settings: configuredRouterSettings }),
...(validWindows.length > 0 && { budget_limits: validWindows }),
...(Object.keys(tag_rpm_limit).length > 0 && { tag_rpm_limit }),
...(Object.keys(input.budgetFallbacks).length > 0 && { budget_fallbacks: input.budgetFallbacks }),
...(values.budget_duration === NEVER_RESETS_BUDGET_DURATION && { budget_duration: null }),
},
};
};

View file

@ -1,70 +1,114 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import type { Team } from "../key_team_helpers/key_list";
import { keyCreateCall, keyCreateServiceAccountCall, modelAvailableCall, userFilterUICall } from "../networking";
import CreateKey from "./create_key_button";
const { mockKeyCreateCall } = vi.hoisted(() => ({
mockKeyCreateCall: vi.fn().mockResolvedValue({ key: "sk-created", soft_budget: null }),
const state = vi.hoisted(() => ({
authorized: {
accessToken: "test-token",
userId: "test-user-id",
userRole: "Admin",
premiumUser: false,
},
can: {} as Record<string, boolean>,
uiSettings: {} as Record<string, unknown>,
tags: {} as Record<string, { name: string }>,
teams: [] as { team_id: string; team_alias: string; models: string[] }[],
organizations: [] as { organization_id: string; organization_alias: string }[],
accessGroups: [] as { access_group_id: string; access_group_name: string }[],
projects: [] as { project_id: string; project_alias: string; team_id?: string; models?: string[] }[],
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "test-token", userId: "test-user-id", userRole: "Admin", premiumUser: true }),
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => state.authorized }));
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (capability: string) => state.can[capability] ?? true,
}));
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: () => false }));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ keyKeys: { lists: () => ["keys"] } }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => ({ data: [], isLoading: false }),
useOrganizations: () => ({ data: state.organizations, isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: () => ({ data: [], isLoading: false }),
useProjects: () => ({ data: state.projects, isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: {} } }),
useUISettings: () => ({ data: { values: state.uiSettings } }),
}));
vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({
useTags: () => ({ data: state.tags, isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({ useTags: () => ({ data: {} }) }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({ data: { pages: [{ teams: [] }] }, fetchNextPage: vi.fn(), hasNextPage: false }),
useInfiniteTeams: () => ({
data: { pages: [{ teams: state.teams, total: state.teams.length, page: 1, page_size: 50, total_pages: 1 }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: () => ({ data: [], isLoading: false, isError: false }),
useAccessGroups: () => ({ data: state.accessGroups, isLoading: false, isError: false }),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
useMCPServers: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups", () => ({
useMCPAccessGroups: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
useMCPToolsets: () => ({ data: [], isLoading: false }),
}));
vi.mock("../networking", () => ({
keyCreateCall: mockKeyCreateCall,
keyCreateServiceAccountCall: vi.fn().mockResolvedValue({ key: "sk-sa", soft_budget: null }),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
userFilterUICall: vi.fn().mockResolvedValue([]),
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
proxyBaseUrl: "http://localhost:4000",
}));
vi.mock("../networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("../networking")>();
const emptyMcpTools = { tools: [], error: null, message: null, stack_trace: null };
return {
...actual,
keyCreateCall: vi.fn().mockResolvedValue({ key: "sk-created", soft_budget: null }),
keyCreateServiceAccountCall: vi.fn().mockResolvedValue({ key: "sk-service-account", soft_budget: null }),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
userFilterUICall: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
listMCPTools: vi.fn().mockResolvedValue(emptyMcpTools),
getRouterSettingsCall: vi.fn().mockResolvedValue({ router_settings: {} }),
};
});
vi.mock("../agent_management/AgentSelector", () => ({ default: () => null }));
vi.mock("../common_components/check_openapi_schema", () => ({ default: () => null }));
vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null }));
vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null }));
vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null }));
vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null }));
vi.mock("../vector_store_management/VectorStoreSelector", () => ({ default: () => null }));
vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null }));
const OPENAPI_SCHEMA = {
components: {
schemas: {
GenerateKeyRequest: {
properties: {
key: { type: "string", title: "Key" },
soft_budget: { type: "number", title: "Soft Budget" },
blocked: { type: "boolean", title: "Blocked" },
max_budget: { type: "number", title: "Max Budget" },
},
},
},
},
};
const MINIMAL_CREATE_PAYLOAD = {
const SECTIONS = {
mcp: /MCP Settings/i,
agent: /Agent Settings/i,
logging: /Logging Settings/i,
router: /Router Settings/i,
aliases: /Model Aliases/i,
lifecycle: /Key Lifecycle/i,
advanced: /Advanced Settings/i,
} as const;
const ALL_CLOSED_PAYLOAD = {
organization_id: undefined,
team_id: null,
key_alias: "probe-key",
key_alias: "contract-key",
models: [],
key_type: "llm_api",
user_id: "test-user-id",
@ -72,100 +116,763 @@ const MINIMAL_CREATE_PAYLOAD = {
metadata: "{}",
};
describe("CreateKey submit payload contract", () => {
const OPTIONAL_OPEN_PAYLOAD = {
...ALL_CLOSED_PAYLOAD,
max_budget: undefined,
budget_duration: undefined,
tpm_limit: undefined,
tpm_limit_type: null,
rpm_limit: undefined,
rpm_limit_type: null,
throttle_on_budget_exceeded: undefined,
enable_prompt_caching: undefined,
guardrails: undefined,
policies: undefined,
prompts: undefined,
access_group_ids: undefined,
allowed_passthrough_routes: undefined,
allowed_vector_store_ids: undefined,
tags: undefined,
};
const ROUTER_SETTINGS_DEFAULT = {
routing_strategy: null,
allowed_fails: null,
cooldown_time: null,
num_retries: null,
timeout: null,
retry_after: null,
fallbacks: null,
context_window_fallbacks: null,
retry_policy: null,
model_group_alias: null,
enable_tag_filtering: false,
routing_strategy_args: null,
};
const SECTION_PAYLOAD_ADDITIONS: Record<keyof typeof SECTIONS, Record<string, unknown>> = {
mcp: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] } },
agent: { allowed_agents_and_groups: undefined },
logging: {},
router: { router_settings: ROUTER_SETTINGS_DEFAULT },
aliases: {},
lifecycle: {},
advanced: { key: undefined, soft_budget: undefined, blocked: undefined },
};
const ALL_OPEN_PAYLOAD = {
...OPTIONAL_OPEN_PAYLOAD,
...SECTION_PAYLOAD_ADDITIONS.mcp,
...SECTION_PAYLOAD_ADDITIONS.agent,
...SECTION_PAYLOAD_ADDITIONS.router,
...SECTION_PAYLOAD_ADDITIONS.advanced,
};
const renderCreateKey = (props: Partial<React.ComponentProps<typeof CreateKey>> = {}) =>
renderWithProviders(<CreateKey team={null} teams={[]} data={[]} addKey={vi.fn()} {...props} />);
const openModal = async (props: Partial<React.ComponentProps<typeof CreateKey>> = {}) => {
const view = renderCreateKey(props);
await userEvent.click(screen.getByTestId("create-key-button"));
await screen.findByRole("button", { name: /^create key$/i });
return view;
};
const antdSearchInput = (placeholder: HTMLElement): HTMLInputElement => {
const input = placeholder.parentElement?.querySelector("input");
if (input == null) {
throw new Error("no antd search input next to the placeholder");
}
return input;
};
const openAntdSelect = async (placeholder: HTMLElement) => {
await userEvent.click(antdSearchInput(placeholder));
};
const openSection = async (name: RegExp) => {
await userEvent.click(await screen.findByRole("button", { name }));
};
const nameTheKey = async (alias = "contract-key") => {
await userEvent.type(await screen.findByLabelText(/Key Name/), alias);
};
const submit = async () => {
await userEvent.click(screen.getByRole("button", { name: /^create key$/i }));
};
const createdPayload = async () => {
await waitFor(() => {
expect(vi.mocked(keyCreateCall)).toHaveBeenCalled();
});
return vi.mocked(keyCreateCall).mock.calls[0][2] as Record<string, unknown>;
};
describe("CreateKey", () => {
beforeEach(() => {
mockKeyCreateCall.mockClear();
testQueryClient.clear();
state.authorized = { accessToken: "test-token", userId: "test-user-id", userRole: "Admin", premiumUser: false };
state.can = {};
state.uiSettings = {};
state.tags = {};
state.teams = [];
state.organizations = [];
state.projects = [];
state.accessGroups = [];
vi.mocked(keyCreateCall).mockClear().mockResolvedValue({ key: "sk-created", soft_budget: null });
vi.mocked(keyCreateServiceAccountCall)
.mockClear()
.mockResolvedValue({ key: "sk-service-account", soft_budget: null });
vi.mocked(userFilterUICall).mockClear().mockResolvedValue([]);
vi.mocked(modelAvailableCall)
.mockClear()
.mockResolvedValue({ data: [{ id: "gpt-4" }] });
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("openapi.json")) {
return { ok: true, status: 200, json: async () => OPENAPI_SCHEMA } as unknown as Response;
}
return { ok: true, status: 200, json: async () => ({}) } as unknown as Response;
}),
);
});
const openModal = async () => {
renderWithProviders(<CreateKey team={null} teams={[]} data={[]} addKey={() => {}} />);
await userEvent.click(screen.getAllByTestId("create-key-button")[0]);
await screen.findByRole("button", { name: /create key/i });
};
it("sends exactly the bound form fields for a minimal create", async () => {
await openModal();
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
});
expect(mockKeyCreateCall.mock.calls[0][2]).toStrictEqual(MINIMAL_CREATE_PAYLOAD);
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps a collapsed Optional Settings section out of the payload entirely", async () => {
await openModal();
describe("submit payload contract", () => {
it("sends only the always-mounted fields when every collapsible section is closed", async () => {
await openModal();
await nameTheKey();
await submit();
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
expect(await createdPayload()).toStrictEqual(ALL_CLOSED_PAYLOAD);
});
const payload = mockKeyCreateCall.mock.calls[0][2];
expect(payload).not.toHaveProperty("tpm_limit_type");
expect(payload).not.toHaveProperty("rpm_limit_type");
expect(payload).not.toHaveProperty("max_budget");
});
it("carries the shared rate-limit-type control into the payload once its section is open", async () => {
await openModal();
it("registers the Optional Settings fields as undefined-valued keys once that section is open", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await submit();
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
await userEvent.click(screen.getByText("Optional Settings"));
await userEvent.click(await screen.findByLabelText(/TPM Rate Limit Type/));
await userEvent.click(await screen.findByText("Guaranteed throughput"));
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
expect(await createdPayload()).toStrictEqual(OPTIONAL_OPEN_PAYLOAD);
});
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({
tpm_limit_type: "guaranteed_throughput",
rpm_limit_type: null,
it("sends the full mounted field set when every section is open", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
for (const trigger of Object.values(SECTIONS)) {
await openSection(trigger);
}
await screen.findByLabelText("Soft Budget");
await submit();
expect(await createdPayload()).toStrictEqual(ALL_OPEN_PAYLOAD);
});
it.each(Object.keys(SECTIONS) as (keyof typeof SECTIONS)[])(
"adds exactly the %s section's own keys when it is the only nested section open",
async (section) => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS[section]);
if (section === "advanced") {
await screen.findByLabelText("Soft Budget");
}
await submit();
expect(await createdPayload()).toStrictEqual({
...OPTIONAL_OPEN_PAYLOAD,
...SECTION_PAYLOAD_ADDITIONS[section],
});
},
);
it.each([
[
"every section closed",
false,
["team_id", "key_alias", "models", "key_type", "user_id", "duration", "metadata"],
],
[
"Optional Settings open",
true,
[
"team_id",
"key_alias",
"models",
"key_type",
"tpm_limit_type",
"rpm_limit_type",
"user_id",
"duration",
"metadata",
],
],
])("serialises to exactly the wire keys with %s", async (_label, openOptional, wireKeys) => {
await openModal();
await nameTheKey();
if (openOptional) {
await openSection(/Optional Settings/i);
}
await submit();
const serialised = JSON.parse(JSON.stringify(await createdPayload())) as Record<string, unknown>;
expect(Object.keys(serialised).sort()).toStrictEqual([...wireKeys].sort());
});
it("omits a budget typed into a section the user closed again, rather than sending it as null", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText(/Max Budget \(USD\)/), "150.75");
await openSection(/Optional Settings/i);
await submit();
const payload = await createdPayload();
expect(payload).not.toHaveProperty("max_budget");
expect(payload).toStrictEqual(ALL_CLOSED_PAYLOAD);
});
it("restores the typed budget when the closed section is expanded again before submit", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText(/Max Budget \(USD\)/), "150.75");
await openSection(/Optional Settings/i);
await openSection(/Optional Settings/i);
expect(await screen.findByLabelText(/Max Budget \(USD\)/)).toHaveValue(150.75);
await submit();
expect(await createdPayload()).toStrictEqual({ ...OPTIONAL_OPEN_PAYLOAD, max_budget: "150.75" });
});
it("sends a typed max budget as a string, not a number", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText(/Max Budget \(USD\)/), "150.75");
await submit();
const payload = await createdPayload();
expect(payload.max_budget).toBe("150.75");
});
it.each([
["Tokens per minute Limit (TPM)", "tpm_limit"],
["Requests per minute Limit (RPM)", "rpm_limit"],
])("routes a typed %s into the %s payload key", async (label, key) => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText(label), "42");
await submit();
expect((await createdPayload())[key]).toBe("42");
});
it("routes the shared rate-limit-type control into its own payload key", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.click(await screen.findByLabelText(/TPM Rate Limit Type/));
await userEvent.click(await screen.findByRole("option", { name: /Guaranteed throughput/ }));
await submit();
const payload = await createdPayload();
expect(payload.tpm_limit_type).toBe("guaranteed_throughput");
expect(payload.rpm_limit_type).toBeNull();
});
it("routes a typed expiry into duration, which is otherwise coalesced to null", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.lifecycle);
await userEvent.type(await screen.findByLabelText("Expire Key"), "45d");
await submit();
expect((await createdPayload()).duration).toBe("45d");
});
it("drops a typed expiry back to null when its section is collapsed before submit", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.lifecycle);
await userEvent.type(await screen.findByLabelText("Expire Key"), "45d");
await openSection(SECTIONS.lifecycle);
await submit();
expect((await createdPayload()).duration).toBeNull();
});
it("routes a selected budget reset window into budget_duration", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.click(await screen.findByLabelText("Reset Budget"));
await userEvent.click(await screen.findByRole("option", { name: "daily" }));
await submit();
expect((await createdPayload()).budget_duration).toBe("24h");
});
it("sends an explicit null budget_duration when the never-resets window is chosen", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.click(await screen.findByLabelText("Reset Budget"));
await userEvent.click(await screen.findByRole("option", { name: /never resets/i }));
await submit();
const payload = await createdPayload();
expect("budget_duration" in payload).toBe(true);
expect(payload.budget_duration).toBeNull();
});
it("routes typed tags into the tags key", async () => {
state.tags = { production: { name: "production" } };
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText("Tags"), "production{Enter}");
await submit();
expect((await createdPayload()).tags).toStrictEqual(["production"]);
});
it("routes a chosen access group into access_group_ids", async () => {
state.accessGroups = [{ access_group_id: "ag-1", access_group_name: "Group One" }];
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openAntdSelect(await screen.findByText("Select access groups (optional)"));
await userEvent.click(await screen.findByText("Group One"));
await submit();
expect((await createdPayload()).access_group_ids).toStrictEqual(["ag-1"]);
});
it("moves the schema-driven Advanced Settings fields onto the payload under their own keys", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.advanced);
await userEvent.type(await screen.findByLabelText("Soft Budget"), "12");
await submit();
const payload = await createdPayload();
expect(payload.soft_budget).toBe(12);
expect(payload).toHaveProperty("key");
});
it("drops the schema-driven custom key field when the proxy disables custom API keys", async () => {
state.uiSettings = { disable_custom_api_keys: true };
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.advanced);
await screen.findByLabelText("Soft Budget");
await submit();
const payload = await createdPayload();
expect(payload).not.toHaveProperty("key");
expect(payload).toHaveProperty("soft_budget");
});
it("drops the policy and prompt keys entirely for a role that cannot see those fields", async () => {
state.can = { viewPolicies: false, viewPrompts: false };
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await submit();
const payload = await createdPayload();
expect(payload).not.toHaveProperty("policies");
expect(payload).not.toHaveProperty("prompts");
expect(payload).toHaveProperty("guardrails");
});
it("adds project_id only when the projects UI is enabled", async () => {
state.uiSettings = { enable_projects_ui: true };
await openModal();
await nameTheKey();
await submit();
expect(await createdPayload()).toStrictEqual({ ...ALL_CLOSED_PAYLOAD, project_id: undefined });
});
it("keeps disable_global_guardrails out of the payload while the switch is off", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await submit();
expect(await createdPayload()).not.toHaveProperty("disable_global_guardrails");
});
it("sends disable_global_guardrails once the switch is on", async () => {
state.authorized = { ...state.authorized, premiumUser: true };
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.click(await screen.findByLabelText("Disable Global Guardrails"));
await submit();
expect((await createdPayload()).disable_global_guardrails).toBe(true);
});
it("folds a metadata JSON string back through JSON.stringify", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText("Metadata"), '{{"team":"research"}');
await submit();
expect((await createdPayload()).metadata).toBe('{"team":"research"}');
});
});
it("carries the shared lifecycle expiry into the payload once its section is open", async () => {
await openModal();
describe("key ownership", () => {
it("stamps the signed-in user onto user_id when the key is owned by you", async () => {
await openModal();
await nameTheKey();
await submit();
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
await userEvent.click(screen.getByText("Optional Settings"));
await userEvent.click(await screen.findByText("Key Lifecycle"));
await userEvent.type(await screen.findByLabelText("Expire Key"), "45d");
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
expect((await createdPayload()).user_id).toBe("test-user-id");
});
it("mounts the user search control only once Another User is chosen", async () => {
await openModal();
expect(screen.queryByText("Type email to search for users")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
expect(await screen.findByText("Type email to search for users")).toBeInTheDocument();
});
it("hides the Another User option from a non-admin", async () => {
state.authorized = { ...state.authorized, userRole: "Internal User" };
await openModal();
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
});
it("routes a service account through the service account endpoint and stamps the alias into metadata", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });
await userEvent.click(screen.getByRole("radio", { name: "Service Account" }));
await userEvent.type(await screen.findByLabelText(/Service Account ID/), "svc-account-1");
await userEvent.click(await screen.findByLabelText("Team"));
await userEvent.click(await screen.findByRole("option", { name: /Team One/ }));
await submit();
await waitFor(() => {
expect(vi.mocked(keyCreateServiceAccountCall)).toHaveBeenCalled();
});
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
const payload = vi.mocked(keyCreateServiceAccountCall).mock.calls[0][1] as Record<string, unknown>;
expect(JSON.parse(String(payload.metadata))).toStrictEqual({ service_account_id: "svc-account-1" });
expect(payload).not.toHaveProperty("user_id");
});
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({ duration: "45d" });
});
it("preserves a value typed in a section that is collapsed and reopened before submit", async () => {
await openModal();
describe("required field validation", () => {
it("blocks the submit and marks the alias invalid when it is blank", async () => {
await openModal();
await submit();
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
await userEvent.click(screen.getByText("Optional Settings"));
const maxBudget = await screen.findByLabelText(/Max Budget/);
await userEvent.type(maxBudget, "150.75");
await userEvent.click(screen.getByText("Optional Settings"));
await userEvent.click(screen.getByText("Optional Settings"));
expect(await screen.findByLabelText(/Max Budget/)).toHaveValue(150.75);
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByLabelText(/Key Name/)).toHaveAttribute("aria-invalid", "true");
});
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
});
it("suppresses the required message behind the always-visible help text", async () => {
await openModal();
await submit();
await waitFor(() => {
expect(screen.getByLabelText(/Key Name/)).toHaveAttribute("aria-invalid", "true");
});
expect(screen.queryByText("Please input a key name")).not.toBeInTheDocument();
expect(screen.getByText("required")).toBeInTheDocument();
});
it("blocks a service account submit until a team is chosen, then lets it through", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });
await userEvent.click(screen.getByRole("radio", { name: "Service Account" }));
await userEvent.type(await screen.findByLabelText(/Service Account ID/), "svc-account-1");
await submit();
await waitFor(() => {
expect(vi.mocked(keyCreateServiceAccountCall)).not.toHaveBeenCalled();
});
await userEvent.click(await screen.findByLabelText("Team"));
await userEvent.click(await screen.findByRole("option", { name: /Team One/ }));
await submit();
await waitFor(() => {
expect(vi.mocked(keyCreateServiceAccountCall)).toHaveBeenCalledTimes(1);
});
});
it("blocks the submit when the budget exceeds the team ceiling", async () => {
await openModal({ team: { team_id: "team-1", max_budget: 10 } as unknown as Team });
await nameTheKey();
await openSection(/Optional Settings/i);
await userEvent.type(await screen.findByLabelText(/Max Budget \(USD\)/), "50");
await submit();
await waitFor(() => {
expect(screen.getByLabelText(/Max Budget \(USD\)/)).toHaveAttribute("aria-invalid", "true");
});
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
});
});
describe("deep link prefill", () => {
it("prefills the key alias", async () => {
renderCreateKey({ autoOpenCreate: true, prefillData: { key_alias: "prefilled-key" } });
expect(await screen.findByLabelText(/Key Name/)).toHaveValue("prefilled-key");
});
it("prefills models once the available model list arrives", async () => {
renderCreateKey({ autoOpenCreate: true, prefillData: { models: ["gpt-4"] } });
expect(await screen.findByTitle("gpt-4")).toBeInTheDocument();
});
it("ignores a team the user has no access to", async () => {
renderCreateKey({
teams: [{ team_id: "team-1", models: [] } as unknown as Team],
autoOpenCreate: true,
prefillData: { team_id: "team-404", key_alias: "example-key" },
});
await userEvent.type(await screen.findByLabelText(/Key Name/), "-suffix");
await submit();
const payload = await createdPayload();
expect(payload.team_id).toBeNull();
expect(payload.key_alias).toBe("example-key-suffix");
});
it("falls back to you when another_user is requested by a non-admin", async () => {
state.authorized = { ...state.authorized, userRole: "Internal User" };
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user", key_alias: "example-key" } });
expect(await screen.findByRole("radio", { name: "You" })).toBeChecked();
});
it("applies another_user for an admin", async () => {
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
expect(await screen.findByRole("radio", { name: "Another User" })).toBeChecked();
});
it("prefills the key type", async () => {
renderCreateKey({ autoOpenCreate: true, prefillData: { key_type: "management" } });
await screen.findByLabelText(/Key Name/);
await userEvent.type(await screen.findByLabelText(/Key Name/), "prefilled-type");
await submit();
expect((await createdPayload()).key_type).toBe("management");
});
});
describe("models dropdown team gating", () => {
it("offers all-proxy-models but not all-team-models when no team is selected", async () => {
await openModal();
await userEvent.click(await screen.findByLabelText("Models"));
expect(await screen.findByTitle("All Proxy Models")).toBeInTheDocument();
expect(screen.queryByTitle("All Team Models")).not.toBeInTheDocument();
});
it("offers all-team-models but hides all-proxy-models once a team is selected", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: ["team-model-1"] }];
await openModal({ teams: state.teams as unknown as Team[] });
await userEvent.click(await screen.findByLabelText("Team"));
await userEvent.click(await screen.findByRole("option", { name: /Team One/ }));
await userEvent.click(await screen.findByLabelText("Models"));
expect(await screen.findByTitle("All Team Models")).toBeInTheDocument();
expect(screen.queryByTitle("All Proxy Models")).not.toBeInTheDocument();
});
});
describe("organization dropdown", () => {
it("is editable for an admin", async () => {
state.organizations = [{ organization_id: "org-1", organization_alias: "Engineering" }];
await openModal();
expect(await screen.findByLabelText("Organization")).not.toHaveAttribute("data-disabled");
});
it("is read-only for a non-admin", async () => {
state.authorized = { ...state.authorized, userRole: "Internal User" };
state.organizations = [{ organization_id: "org-1", organization_alias: "Engineering" }];
await openModal();
expect(await screen.findByLabelText("Organization")).toHaveAttribute("data-disabled", "");
});
it("routes a chosen organization into organization_id", async () => {
state.organizations = [{ organization_id: "org-1", organization_alias: "Engineering" }];
await openModal();
await nameTheKey();
await userEvent.click(await screen.findByLabelText("Organization"));
await userEvent.click(await screen.findByRole("option", { name: /Engineering/ }));
await submit();
expect((await createdPayload()).organization_id).toBe("org-1");
});
});
describe("policy and prompt fields", () => {
it("loads and offers both selectors for a role that can see them", async () => {
await openModal();
await openSection(/Optional Settings/i);
expect(await screen.findByLabelText("Policies")).toBeInTheDocument();
expect(await screen.findByLabelText("Prompts")).toBeInTheDocument();
});
it("omits both selectors for a role that cannot see them", async () => {
state.can = { viewPolicies: false, viewPrompts: false };
await openModal();
await openSection(/Optional Settings/i);
await screen.findByLabelText("Guardrails");
expect(screen.queryByLabelText("Policies")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Prompts")).not.toBeInTheDocument();
});
});
describe("tags dropdown", () => {
it("offers the tags returned by the tags hook", async () => {
state.tags = { production: { name: "production" }, staging: { name: "staging" } };
await openModal();
await openSection(/Optional Settings/i);
await userEvent.click(await screen.findByLabelText("Tags"));
expect(await screen.findByTitle("production")).toBeInTheDocument();
expect(await screen.findByTitle("staging")).toBeInTheDocument();
});
});
describe("user search debounce", () => {
it("fires exactly one search carrying the last typed value", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
await user.type(search, "ali");
expect(vi.mocked(userFilterUICall)).not.toHaveBeenCalled();
await user.type(search, "ce");
await vi.advanceTimersByTimeAsync(400);
expect(vi.mocked(userFilterUICall)).toHaveBeenCalledTimes(1);
const params = vi.mocked(userFilterUICall).mock.calls[0][1] as URLSearchParams;
expect(params.get("user_email")).toBe("alice");
} finally {
vi.useRealTimers();
}
});
});
describe("created key display", () => {
it("surfaces the generated key after a successful create", async () => {
await openModal();
await nameTheKey();
await submit();
await createdPayload();
expect(await screen.findByText("Save your Key")).toBeInTheDocument();
});
it("rejects a duplicate alias within the same team without calling the API", async () => {
await openModal({ data: [{ team_id: null, key_alias: "contract-key" }] });
await nameTheKey();
await submit();
await waitFor(() => {
expect(screen.getByRole("button", { name: /^create key$/i })).toBeInTheDocument();
});
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
});
});
describe("key type gating", () => {
it("labels the llm_api option AI APIs", async () => {
await openModal();
await userEvent.click(await screen.findByLabelText("Key Type"));
expect(
await screen.findByText("Can call only AI API routes (chat/completions, embeddings, etc.)"),
).toBeInTheDocument();
expect(screen.queryByText("LLM API")).not.toBeInTheDocument();
});
it("clears and disables models when a management key type is chosen", async () => {
await openModal();
await nameTheKey();
await userEvent.click(await screen.findByLabelText("Key Type"));
await userEvent.click(await screen.findByText("Management"));
await submit();
const payload = await createdPayload();
expect(payload.key_type).toBe("management");
expect(payload.models).toStrictEqual([]);
});
});
describe("mount-gate liveness", () => {
it("keeps the MCP tool permissions key out of the payload even with its section open", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await openSection(SECTIONS.mcp);
await submit();
const payload = await createdPayload();
expect(payload).not.toHaveProperty("mcp_tool_permissions");
expect(payload).toHaveProperty("allowed_mcp_servers_and_groups");
});
it("registers no Optional Settings field while a team choice is still required", async () => {
vi.mocked(modelAvailableCall).mockResolvedValue({ data: [{ id: "no-default-models" }] });
renderCreateKey();
await userEvent.click(screen.getByTestId("create-key-button"));
expect(await screen.findByText(/Please select a team to continue/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Key Name/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Optional Settings/i })).not.toBeInTheDocument();
});
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({ max_budget: "150.75" });
});
});

View file

@ -1,891 +1,76 @@
import { act, fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { Team } from "../key_team_helpers/key_list";
import { getPoliciesList, getPromptsList, userFilterUICall } from "../networking";
import CreateKey from "./create_key_button";
const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall, teamDropdownTeamsRef } =
vi.hoisted(() => {
const formStateRef = { current: {} as Record<string, any> };
const teamDropdownTeamsRef = { current: [] as Array<{ team_id: string; team_alias: string; models: string[] }> };
const mockKeyCreateCall = vi.fn().mockResolvedValue({
key: "test-api-key",
soft_budget: null,
});
const formMock = {
setFieldsValue: vi.fn((values: Record<string, any>) => {
Object.assign(formStateRef.current, values);
}),
setFieldValue: vi.fn((name: string, value: any) => {
formStateRef.current[name] = value;
}),
getFieldValue: vi.fn((name: string) => formStateRef.current[name]),
resetFields: vi.fn(() => {
formStateRef.current = {};
}),
};
const radioGroupValueRef = { current: null as string | null };
return {
formMock,
setFieldsValueMock: formMock.setFieldsValue,
radioGroupValueRef,
formStateRef,
mockKeyCreateCall,
teamDropdownTeamsRef,
};
});
const defaultAuthorizedState = {
accessToken: "test-token",
userId: "test-user-id",
userRole: "Admin",
premiumUser: false,
};
let authorizedState = { ...defaultAuthorizedState };
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => authorizedState,
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
keyKeys: {
lists: () => ["keys"],
},
}));
vi.mock("@ant-design/icons", () => ({
InfoCircleOutlined: () => null,
}));
vi.mock("react-copy-to-clipboard", () => ({
CopyToClipboard: ({ children }: { children: any }) => children,
}));
vi.mock("antd", () => {
const React = require("react");
const getValueFromEvent = (event: any) => {
if (event?.target) {
if (event.target.type === "checkbox") {
return event.target.checked;
}
return event.target.value;
}
return event;
};
const Form = ({
children,
onFinish,
...props
}: {
children?: any;
onFinish?: (values: Record<string, any>) => void;
}) =>
React.createElement(
"form",
{
...props,
onSubmit: (event: Event) => {
event.preventDefault();
onFinish?.({ ...formStateRef.current });
},
},
children,
);
Form.Item = ({ children, name }: { children?: any; name?: string }) => {
if (!name || !React.isValidElement(children)) {
return React.createElement(React.Fragment, null, children);
}
return React.cloneElement(children, {
value: formStateRef.current[name],
onChange: (event: any) => {
formStateRef.current[name] = getValueFromEvent(event);
},
});
};
Form.useForm = () => [formMock];
Form.useWatch = (name: string) => formStateRef.current[name];
const Select = ({
children,
onChange,
onSearch,
options,
...props
}: {
children?: any;
onChange?: (value: string) => void;
onSearch?: (value: string) => void;
options?: Array<{ value: string; label: string }>;
}) => {
const select = React.createElement(
"select",
{
...props,
onChange: (event: any) => onChange?.(event.target.value),
},
children,
options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)),
);
if (!onSearch) {
return select;
}
return React.createElement(
React.Fragment,
null,
React.createElement("input", {
"data-testid": "select-search-input",
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onSearch(event.target.value),
}),
select,
);
};
Select.Option = ({ children, ...props }: { children?: any }) => React.createElement("option", props, children);
const Input = (props: any) => React.createElement("input", props);
Input.Password = (props: any) => React.createElement("input", { ...props, type: "password" });
Input.TextArea = (props: any) => React.createElement("textarea", props);
const Modal = ({ children, open }: { children?: any; open?: boolean }) =>
open ? React.createElement("div", null, children) : null;
const Radio = ({ children, ...props }: { children?: any }) => React.createElement("div", props, children);
Radio.Group = ({ children, value }: { children?: any; value?: string }) => {
radioGroupValueRef.current = value ?? null;
return React.createElement("div", null, children);
};
const Switch = (props: any) => React.createElement("input", { ...props, type: "checkbox" });
const Tag = ({ children }: { children?: any }) => React.createElement("span", null, children);
const Tooltip = ({ children }: { children?: any }) => React.createElement(React.Fragment, null, children);
const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) =>
React.createElement("button", { ...props, type: htmlType ?? props.type }, children);
const Typography = ({ children, ...props }: { children?: any }) => React.createElement("div", props, children);
Typography.Text = ({ children, ...props }: { children?: any }) => React.createElement("span", props, children);
Typography.Paragraph = ({ children, ...props }: { children?: any }) => React.createElement("p", props, children);
Typography.Title = ({ children, ...props }: { children?: any }) => React.createElement("h1", props, children);
return {
Button,
Form,
Input,
message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
Modal,
Radio,
Select,
Switch,
Tag,
Tooltip,
Typography,
};
});
import { beforeEach, describe, expect, it, vi } from "vitest";
import { modelAvailableCall } from "../networking";
import { fetchTeamModels, fetchUserModels } from "./create_key_button";
vi.mock("../networking", () => ({
keyCreateCall: mockKeyCreateCall,
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
proxyBaseUrl: "http://localhost:4000",
getPossibleUserRoles: vi.fn().mockResolvedValue({
Admin: { ui_label: "Admin" },
User: { ui_label: "User" },
}),
userFilterUICall: vi.fn().mockResolvedValue([]),
keyCreateServiceAccountCall: vi.fn().mockResolvedValue({
key: "test-service-account-key",
soft_budget: null,
}),
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
modelAvailableCall: vi.fn(),
}));
vi.mock("../agent_management/AgentSelector", () => ({ default: () => null }));
vi.mock("../common_components/budget_duration_dropdown", () => ({
NEVER_RESETS_BUDGET_DURATION: "none",
default: ({
showNeverResets,
placeholder,
onChange,
}: {
showNeverResets?: boolean;
placeholder?: string;
onChange?: (value: string) => void;
}) => (
<select data-testid="budget-duration-dropdown" onChange={(event) => onChange?.(event.target.value)}>
<option value="">{placeholder ?? "n/a"}</option>
{showNeverResets ? <option value="none">Never resets</option> : null}
<option value="30d">monthly</option>
</select>
),
}));
vi.mock("../common_components/check_openapi_schema", () => ({ default: () => null }));
vi.mock("../common_components/KeyLifecycleSettings", () => ({ default: () => null }));
vi.mock("../common_components/ModelAliasManager", () => ({ default: () => null }));
vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () => null }));
vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null }));
vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null }));
vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: {
pages: [
{
teams: [
{ team_id: "team-1", team_alias: "Team One" },
{ team_id: "team-2", team_alias: "Team Two" },
],
total: 2,
page: 1,
page_size: 50,
total_pages: 1,
},
],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("../common_components/team_dropdown", () => ({
default: ({
onTeamSelect,
disabled,
}: {
onTeamSelect?: (team: { team_id: string; team_alias: string; models: string[] } | null) => void;
disabled?: boolean;
}) => (
<select
data-testid="team-dropdown"
disabled={disabled}
onChange={(e) =>
onTeamSelect?.(teamDropdownTeamsRef.current.find((team) => team.team_id === e.target.value) ?? null)
}
>
<option value="">Select team</option>
{teamDropdownTeamsRef.current.map((team) => (
<option key={team.team_id} value={team.team_id}>
{team.team_alias}
</option>
))}
</select>
),
}));
vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null }));
vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null }));
vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null }));
vi.mock("../shared/numerical_input", () => ({ default: () => null }));
vi.mock("../vector_store_management/VectorStoreSelector", () => ({ default: () => null }));
vi.mock("../key_team_helpers/fetch_available_models_team_key", async () => {
const actual = await vi.importActual("../key_team_helpers/fetch_available_models_team_key");
return {
...actual,
getModelDisplayName: (model: string) => model,
};
});
vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({
useTags: vi.fn().mockReturnValue({
data: [
{ name: "production", description: "Prod tag", models: [], created_at: "2026-01-01", updated_at: "2026-01-01" },
{ name: "staging", description: "Staging tag", models: [], created_at: "2026-01-01", updated_at: "2026-01-01" },
],
isLoading: false,
}),
}));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({
data: [
{ organization_id: "org-1", organization_alias: "Engineering" },
{ organization_id: "org-2", organization_alias: "Sales" },
],
isLoading: false,
}),
}));
vi.mock("../common_components/OrganizationDropdown", () => ({
default: ({ value, onChange, disabled }: { value?: string; onChange?: (v: string) => void; disabled?: boolean }) => (
<select
data-testid="org-dropdown"
disabled={disabled}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
>
<option value="">Select org</option>
<option value="org-1">Engineering</option>
<option value="org-2">Sales</option>
</select>
),
}));
vi.mock("../common_components/ProjectDropdown", () => ({
default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => (
<input data-testid="project-dropdown" value={value || ""} onChange={(e) => onChange?.(e.target.value)} />
),
}));
vi.mock("../common_components/AccessGroupSelector", () => ({
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
<input
data-testid="access-group-selector"
value={Array.isArray(value) ? value.join(",") : ""}
onChange={(event) => onChange?.(event.target.value ? event.target.value.split(",").map((v) => v.trim()) : [])}
/>
),
}));
describe("CreateKey", () => {
const defaultProps = {
team: null,
teams: [],
data: [],
addKey: vi.fn(),
};
const openOptionalSettings = async () => {
const trigger = await screen.findByRole("button", { name: /optional settings/i });
act(() => {
fireEvent.click(trigger);
});
};
describe("fetchTeamModels", () => {
beforeEach(() => {
vi.clearAllMocks();
if (typeof window !== "undefined" && window.localStorage && typeof window.localStorage.clear === "function") {
window.localStorage.clear();
}
authorizedState = { ...defaultAuthorizedState };
radioGroupValueRef.current = null;
formStateRef.current = {};
teamDropdownTeamsRef.current = [
{ team_id: "team-1", team_alias: "Team One", models: [] },
{ team_id: "team-2", team_alias: "Team Two", models: [] },
];
mockKeyCreateCall.mockResolvedValue({
key: "test-api-key",
soft_budget: null,
});
vi.mocked(modelAvailableCall).mockReset();
});
it("should render the CreateKey component", () => {
renderWithProviders(<CreateKey {...defaultProps} />);
expect(screen.getByRole("button", { name: /create new key/i })).toBeInTheDocument();
it("asks the proxy for the team-scoped model list and returns the ids", async () => {
vi.mocked(modelAvailableCall).mockResolvedValue({ data: [{ id: "gpt-4" }, { id: "claude-opus-4" }] });
await expect(fetchTeamModels("user-1", "Admin", "token-1", "team-1")).resolves.toStrictEqual([
"gpt-4",
"claude-opus-4",
]);
expect(modelAvailableCall).toHaveBeenCalledWith("token-1", "user-1", "Admin", true, "team-1", true);
});
it("should display 'AI APIs' label for the llm_api key type option", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
it("passes a null team through rather than dropping the argument", async () => {
vi.mocked(modelAvailableCall).mockResolvedValue({ data: [] });
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await fetchTeamModels("user-1", "Admin", "token-1", null);
await waitFor(() => {
expect(screen.getByText("AI APIs")).toBeInTheDocument();
expect(screen.queryByText("LLM API")).not.toBeInTheDocument();
});
expect(modelAvailableCall).toHaveBeenCalledWith("token-1", "user-1", "Admin", true, null, true);
});
it("should include access_group_ids in keyCreateCall payload when access groups are selected", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await openOptionalSettings();
await waitFor(() => {
expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
});
act(() => {
fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } });
formMock.setFieldValue("key_alias", "Test Key");
});
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create key/i }));
});
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
const formValues = mockKeyCreateCall.mock.calls[0][2];
expect(formValues).toHaveProperty("access_group_ids");
expect(formValues.access_group_ids).toEqual(["ag-1", "ag-2"]);
});
it("returns an empty list and makes no call when the user id is null", async () => {
await expect(fetchTeamModels(null as unknown as string, "Admin", "token-1", "team-1")).resolves.toStrictEqual([]);
expect(modelAvailableCall).not.toHaveBeenCalled();
});
it("should include mcp_toolsets in keyCreateCall payload when only toolsets are selected", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
it("swallows a failed lookup and returns an empty list", async () => {
vi.mocked(modelAvailableCall).mockRejectedValue(new Error("proxy down"));
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByRole("button", { name: /create key/i })).toBeInTheDocument();
});
act(() => {
formMock.setFieldValue("key_alias", "Test Key");
formMock.setFieldValue("allowed_mcp_servers_and_groups", {
servers: [],
accessGroups: [],
toolsets: ["ts-1"],
});
});
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create key/i }));
});
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
});
expect(mockKeyCreateCall.mock.calls[0][2].object_permission?.mcp_toolsets).toEqual(["ts-1"]);
});
it("should prefill models when provided without team_id", async () => {
renderWithProviders(
<CreateKey
{...defaultProps}
autoOpenCreate={true}
prefillData={{
models: ["gpt-4"],
}}
/>,
);
await waitFor(() => {
expect(setFieldsValueMock).toHaveBeenCalledWith({ models: ["gpt-4"] });
});
});
it("should prefill team_id when it exists in teams", async () => {
renderWithProviders(
<CreateKey
{...defaultProps}
teams={[{ team_id: "team-1", models: [] } as any]}
autoOpenCreate={true}
prefillData={{ team_id: "team-1" }}
/>,
);
await waitFor(() => {
expect(setFieldsValueMock).toHaveBeenCalledWith({ team_id: "team-1" });
});
});
it("should ignore team_id when it does not exist in teams", async () => {
renderWithProviders(
<CreateKey
{...defaultProps}
teams={[{ team_id: "team-1", models: [] } as any]}
autoOpenCreate={true}
prefillData={{ team_id: "team-404", key_alias: "example-key" }}
/>,
);
await waitFor(() => {
expect(setFieldsValueMock).toHaveBeenCalledWith({ key_alias: "example-key" });
});
expect(setFieldsValueMock).not.toHaveBeenCalledWith({ team_id: "team-404" });
});
it('should fall back to "you" when owned_by is another_user for non-admin', async () => {
authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" };
renderWithProviders(
<CreateKey
{...defaultProps}
autoOpenCreate={true}
prefillData={{ owned_by: "another_user", key_alias: "example-key" }}
/>,
);
await waitFor(() => {
expect(setFieldsValueMock).toHaveBeenCalledWith({ key_alias: "example-key" });
});
expect(radioGroupValueRef.current).toBe("you");
});
it("should apply owned_by another_user for admin", async () => {
renderWithProviders(
<CreateKey {...defaultProps} autoOpenCreate={true} prefillData={{ owned_by: "another_user" }} />,
);
await waitFor(() => {
expect(radioGroupValueRef.current).toBe("another_user");
});
});
it("should prefill key_type when provided", async () => {
renderWithProviders(<CreateKey {...defaultProps} autoOpenCreate={true} prefillData={{ key_type: "management" }} />);
await waitFor(() => {
expect(setFieldsValueMock).toHaveBeenCalledWith({ key_type: "management" });
});
});
describe("organization dropdown", () => {
it("should render the organization dropdown when modal is open", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
});
});
it("should disable the organization dropdown for non-admin users", async () => {
authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" };
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("org-dropdown")).toBeDisabled();
});
});
it("should enable the organization dropdown for admin users", async () => {
authorizedState = { ...defaultAuthorizedState, userRole: "Admin" };
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("org-dropdown")).toBeEnabled();
});
});
it("should render team dropdown alongside organization dropdown", async () => {
const teamsWithOrg = [{ team_id: "team-1", team_alias: "Team Alpha", organization_id: "org-1", models: [] }];
renderWithProviders(<CreateKey {...defaultProps} teams={teamsWithOrg as any} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
});
});
it("should set organization_id in form state when org is selected", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
});
act(() => {
fireEvent.change(screen.getByTestId("org-dropdown"), { target: { value: "org-1" } });
});
expect(formStateRef.current["organization_id"]).toBe("org-1");
});
});
describe("models dropdown team gating", () => {
const getModelsSelect = async (): Promise<HTMLElement> => {
return waitFor(() => {
const element = document.querySelector('select[placeholder="Select models"]');
expect(element).toBeTruthy();
return element as HTMLElement;
});
};
it("should offer all-proxy-models but not all-team-models when no team is selected", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
const modelsSelect = await getModelsSelect();
await waitFor(() => {
expect(within(modelsSelect).getByText("gpt-4")).toBeInTheDocument();
});
expect(within(modelsSelect).getByText("All Proxy Models")).toBeInTheDocument();
expect(within(modelsSelect).queryByText("All Team Models")).not.toBeInTheDocument();
});
it("should offer all-team-models but hide all-proxy-models when a team is selected", async () => {
teamDropdownTeamsRef.current = [
{ team_id: "team-1", team_alias: "Team One", models: ["all-proxy-models", "team-model-1"] },
];
renderWithProviders(<CreateKey {...defaultProps} teams={teamDropdownTeamsRef.current as unknown as Team[]} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await waitFor(() => {
expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
});
act(() => {
fireEvent.change(screen.getByTestId("team-dropdown"), { target: { value: "team-1" } });
});
const modelsSelect = await getModelsSelect();
await waitFor(() => {
expect(within(modelsSelect).getByText("team-model-1")).toBeInTheDocument();
});
expect(within(modelsSelect).getByText("All Team Models")).toBeInTheDocument();
expect(within(modelsSelect).queryByText("All Proxy Models")).not.toBeInTheDocument();
expect(within(modelsSelect).queryByText("all-proxy-models")).not.toBeInTheDocument();
});
});
describe("user search debounce", () => {
const mockUserFilterUICall = vi.mocked(userFilterUICall);
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const renderUserSearch = () => {
const view = renderWithProviders(
<CreateKey {...defaultProps} autoOpenCreate={true} prefillData={{ owned_by: "another_user" }} />,
);
return { input: screen.getByTestId("select-search-input"), unmount: view.unmount };
};
it("should not fire the search before the wait elapses", () => {
const { input } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "alice" } });
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(299);
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
});
it("should fire exactly one search carrying the last value after the wait", async () => {
const { input } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "a" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "al" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "alice" } });
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(mockUserFilterUICall).toHaveBeenCalledTimes(1);
const params = mockUserFilterUICall.mock.calls[0][1] as URLSearchParams;
expect(params.get("user_email")).toBe("alice");
});
it("should fire nothing when unmounted mid-wait", () => {
const { input, unmount } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "alice" } });
});
unmount();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
});
});
describe("tags dropdown", () => {
it("should populate tags dropdown with options from useTags hook", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await openOptionalSettings();
await waitFor(() => {
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("staging")).toBeInTheDocument();
});
});
});
describe("policy and prompt fields", () => {
const POLICIES_PLACEHOLDER = "Premium feature - Upgrade to set policies by key";
const PROMPTS_PLACEHOLDER = "Premium feature - Upgrade to set prompts by key";
const openModal = async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await openOptionalSettings();
};
beforeEach(() => {
vi.mocked(getPoliciesList).mockResolvedValue({ policies: [{ policy_name: "policy-a" }] });
vi.mocked(getPromptsList).mockResolvedValue({ prompts: [{ prompt_id: "prompt-a" }] } as any);
});
it("should load and offer both selectors for an admin", async () => {
await openModal();
await waitFor(() => {
expect(screen.getByRole("option", { name: "policy-a" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "prompt-a" })).toBeInTheDocument();
});
expect(getPoliciesList).toHaveBeenCalledWith("test-token");
expect(getPromptsList).toHaveBeenCalledWith("test-token");
expect(screen.getByPlaceholderText(POLICIES_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByPlaceholderText(PROMPTS_PLACEHOLDER)).toBeInTheDocument();
});
it("should omit both selectors and fire neither admin-only request for an internal user", async () => {
authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" };
await openModal();
expect(await screen.findByTestId("org-dropdown")).toBeInTheDocument();
expect(getPoliciesList).not.toHaveBeenCalled();
expect(getPromptsList).not.toHaveBeenCalled();
expect(screen.queryByPlaceholderText(POLICIES_PLACEHOLDER)).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(PROMPTS_PLACEHOLDER)).not.toBeInTheDocument();
});
});
describe("budget reset", () => {
const openModal = async () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
await openOptionalSettings();
await waitFor(() => {
expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument();
});
};
const submit = async () => {
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create key/i }));
});
await waitFor(() => {
expect(mockKeyCreateCall).toHaveBeenCalled();
});
return mockKeyCreateCall.mock.calls[0][2];
};
it("should send an explicit null budget_duration when 'Never resets' is selected", async () => {
await openModal();
expect(screen.getByRole("option", { name: "Never resets" })).toBeInTheDocument();
act(() => {
fireEvent.change(screen.getByTestId("budget-duration-dropdown"), { target: { value: "none" } });
formMock.setFieldValue("key_alias", "Never Resets Key");
});
const formValues = await submit();
expect("budget_duration" in formValues).toBe(true);
expect(formValues.budget_duration).toBeNull();
});
it("should label the omit option distinctly from 'Never resets'", async () => {
await openModal();
const optionLabels = Array.from(
screen.getByTestId("budget-duration-dropdown").querySelectorAll("option"),
(option) => option.textContent,
);
expect(optionLabels).toContain("Never resets");
expect(new Set(optionLabels).size).toBe(optionLabels.length);
expect(screen.getByRole("option", { name: "Never resets" })).toHaveValue("none");
});
it("should omit budget_duration entirely when the reset dropdown is untouched", async () => {
await openModal();
act(() => {
formMock.setFieldValue("key_alias", "Inherits Default Key");
});
const formValues = await submit();
expect("budget_duration" in formValues).toBe(false);
});
await expect(fetchTeamModels("user-1", "Admin", "token-1", "team-1")).resolves.toStrictEqual([]);
});
});
describe("fetchUserModels", () => {
beforeEach(() => {
vi.mocked(modelAvailableCall).mockReset();
});
it("hands the returned ids to the setter without the team-scoped arguments", async () => {
vi.mocked(modelAvailableCall).mockResolvedValue({ data: [{ id: "gpt-4" }] });
const setUserModels = vi.fn();
await fetchUserModels("user-1", "Admin", "token-1", setUserModels);
expect(modelAvailableCall).toHaveBeenCalledWith("token-1", "user-1", "Admin");
expect(setUserModels).toHaveBeenCalledWith(["gpt-4"]);
});
it("leaves the setter untouched when the lookup fails", async () => {
vi.mocked(modelAvailableCall).mockRejectedValue(new Error("proxy down"));
const setUserModels = vi.fn();
await fetchUserModels("user-1", "Admin", "token-1", setUserModels);
expect(setUserModels).not.toHaveBeenCalled();
});
it("makes no call when the user role is null", async () => {
const setUserModels = vi.fn();
await fetchUserModels("user-1", null as unknown as string, "token-1", setUserModels);
expect(modelAvailableCall).not.toHaveBeenCalled();
expect(setUserModels).not.toHaveBeenCalled();
});
});

View file

@ -30,9 +30,8 @@ import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import React, { useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import { mapDisplayToInternalNames } from "../callback_info_helpers";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
import SchemaFormFields from "../common_components/check_openapi_schema";
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
import ModelAliasManager from "../common_components/ModelAliasManager";
@ -46,7 +45,7 @@ import ProjectDropdown from "../common_components/ProjectDropdown";
import { CreateUserButton } from "../CreateUserButton";
import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor";
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
import { TagRateLimitEditor, TagRateLimitEntry, tagRowsToLimits } from "../key_team_helpers/TagRateLimitEditor";
import { TagRateLimitEditor, TagRateLimitEntry } from "../key_team_helpers/TagRateLimitEditor";
import {
excludeProxyWideSentinel,
getModelDisplayName,
@ -72,6 +71,7 @@ import {
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
import { simplifyKeyGenerateError } from "./utils";
const { Option } = Select;
@ -378,198 +378,42 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const handleCreate = async (formValues: Record<string, any>) => {
try {
const newKeyAlias = formValues?.key_alias ?? "";
const newKeyTeamId = formValues?.team_id ?? null;
const existingKeyAliases = data?.filter((k) => k.team_id === newKeyTeamId).map((k) => k.key_alias) ?? [];
if (existingKeyAliases.includes(newKeyAlias)) {
const input: KeyCreateInput = {
formValues,
existingKeys: data,
keyOwner,
userID,
selectedAgentId,
loggingSettings,
disabledCallbacks,
autoRotationEnabled,
rotationInterval,
modelAliases,
routerSettings,
budgetLimits,
tagRateLimits,
budgetFallbacks,
};
const built = buildKeyCreatePayload(input);
if (built.kind === "duplicate_alias") {
throw new Error(
`Key alias ${newKeyAlias} already exists for team with ID ${newKeyTeamId}, please provide another key alias`,
`Key alias ${built.alias} already exists for team with ID ${built.teamId}, please provide another key alias`,
);
}
toast.info("Making API Call");
setIsModalVisible(true);
if (keyOwner === "you") {
formValues.user_id = userID;
} else if (keyOwner === "agent") {
if (!selectedAgentId) {
toast.fromError("Please select an agent");
return;
}
formValues.agent_id = selectedAgentId;
if (built.kind === "agent_not_selected") {
toast.fromError("Please select an agent");
return;
}
const { payload, endpoint } = built;
// Handle metadata for all key types
let metadata: Record<string, any> = {};
try {
metadata = JSON.parse(formValues.metadata || "{}");
} catch (error) {
console.error("Error parsing metadata:", error);
}
// If it's a service account, add the service_account_id to the metadata
if (keyOwner === "service_account") {
metadata["service_account_id"] = formValues.key_alias;
}
// Add logging settings to the metadata
if (loggingSettings.length > 0) {
metadata = {
...metadata,
logging: loggingSettings.filter((config) => config.callback_name),
};
}
// Add disabled callbacks to the metadata
if (disabledCallbacks.length > 0) {
// Map display names to internal callback values
const mappedDisabledCallbacks = mapDisplayToInternalNames(disabledCallbacks);
metadata = {
...metadata,
litellm_disabled_callbacks: mappedDisabledCallbacks,
};
}
// Add auto-rotation settings as top-level fields
if (autoRotationEnabled) {
formValues.auto_rotate = true;
formValues.rotation_interval = rotationInterval;
}
// Handle duration field for key expiry - convert empty string to null
if (!formValues.duration || formValues.duration.trim() === "") {
formValues.duration = null;
}
// Update the formValues with the final metadata
formValues.metadata = JSON.stringify(metadata);
// disable_global_guardrails is premium-gated server-side; only send it when enabled
// so non-premium key creation isn't blocked by that gate.
if (!formValues.disable_global_guardrails) {
delete formValues.disable_global_guardrails;
}
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission format
if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) {
formValues.object_permission = {
vector_stores: formValues.allowed_vector_store_ids,
};
// Remove the original field as it's now part of object_permission
delete formValues.allowed_vector_store_ids;
}
// Transform allowed_mcp_servers_and_groups into object_permission format
if (
formValues.allowed_mcp_servers_and_groups &&
(formValues.allowed_mcp_servers_and_groups.servers?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.toolsets?.length > 0)
) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
const { servers, accessGroups, toolsets } = formValues.allowed_mcp_servers_and_groups;
if (servers && servers.length > 0) {
formValues.object_permission.mcp_servers = servers;
}
if (accessGroups && accessGroups.length > 0) {
formValues.object_permission.mcp_access_groups = accessGroups;
}
if (toolsets && toolsets.length > 0) {
formValues.object_permission.mcp_toolsets = toolsets;
}
// Remove the original field as it's now part of object_permission
delete formValues.allowed_mcp_servers_and_groups;
}
// Add MCP tool permissions to object_permission
const mcpToolPermissions = formValues.mcp_tool_permissions || {};
if (Object.keys(mcpToolPermissions).length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.mcp_tool_permissions = mcpToolPermissions;
}
delete formValues.mcp_tool_permissions;
// Transform allowed_mcp_access_groups into object_permission format
if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups;
// Remove the original field as it's now part of object_permission
delete formValues.allowed_mcp_access_groups;
}
// Transform allowed_agents_and_groups into object_permission format
if (
formValues.allowed_agents_and_groups &&
(formValues.allowed_agents_and_groups.agents?.length > 0 ||
formValues.allowed_agents_and_groups.accessGroups?.length > 0)
) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
const { agents, accessGroups } = formValues.allowed_agents_and_groups;
if (agents && agents.length > 0) {
formValues.object_permission.agents = agents;
}
if (accessGroups && accessGroups.length > 0) {
formValues.object_permission.agent_access_groups = accessGroups;
}
// Remove the original field as it's now part of object_permission
delete formValues.allowed_agents_and_groups;
}
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.aliases = JSON.stringify(modelAliases);
}
// Add router_settings if any are defined
if (routerSettings?.router_settings) {
// Only include router_settings if it has at least one non-null value
const hasValues = Object.values(routerSettings.router_settings).some(
(value) => value !== null && value !== undefined && value !== "",
);
if (hasValues) {
formValues.router_settings = routerSettings.router_settings;
}
}
// Add multi-window budget limits (filter out incomplete entries)
const validWindows = budgetLimits.filter(
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
);
if (validWindows.length > 0) {
formValues.budget_limits = validWindows;
}
// Add per-tag rate limits (only when at least one row is configured)
const { tag_rpm_limit } = tagRowsToLimits(tagRateLimits);
if (Object.keys(tag_rpm_limit).length > 0) {
formValues.tag_rpm_limit = tag_rpm_limit;
}
if (Object.keys(budgetFallbacks).length > 0) {
formValues.budget_fallbacks = budgetFallbacks;
}
if (formValues.budget_duration === NEVER_RESETS_BUDGET_DURATION) {
formValues.budget_duration = null;
}
let response;
if (keyOwner === "service_account") {
response = await keyCreateServiceAccountCall(accessToken, formValues);
} else {
response = await keyCreateCall(accessToken, userID, formValues);
}
const response =
endpoint === "service_account"
? await keyCreateServiceAccountCall(accessToken, payload)
: await keyCreateCall(accessToken, userID, payload);
// Add the data to the state in the parent component
// Also directly update the keys list in VirtualKeysTable without an API call

View file

@ -1,6 +1,6 @@
"use client";
import { Tooltip } from "@/components/atoms/Tooltip";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { Team } from "@/components/key_team_helpers/key_list";
import type { Organization } from "@/components/networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -53,7 +53,7 @@ interface InheritedBudgetHintProps {
export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
if (gates.length === 0) return null;
return (
<Tooltip
<SimpleTooltip
content={
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>This key has no budget of its own, but its spend still counts toward:</span>

View file

@ -92,4 +92,18 @@ describe("MultiSelect", () => {
expect(screen.queryByText('Create "vs-typed"')).not.toBeInTheDocument();
expect(await screen.findByText("No options found")).toBeInTheDocument();
});
it("marks a disabled option as disabled and refuses to select it", async () => {
const { onValueChange, input } = renderMultiSelect({
options: [OPTIONS[0], { ...OPTIONS[1], disabled: true }],
});
await openPopup(input);
const disabledOption = screen.getByRole("option", { name: /beta-kb/ });
expect(disabledOption).toHaveAttribute("aria-disabled", "true");
await userEvent.click(disabledOption);
expect(onValueChange).not.toHaveBeenCalled();
});
});

View file

@ -18,6 +18,7 @@ export interface MultiSelectOption {
label: string;
value: string;
description?: string;
disabled?: boolean;
}
interface MultiSelectProps {
@ -116,7 +117,7 @@ export function MultiSelect({
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: MultiSelectOption) => (
<ComboboxItem key={option.value} value={option}>
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (

View file

@ -1,9 +1,19 @@
import { readFileSync } from "fs";
import { resolve } from "path";
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, fireEvent } from "../../../tests/test-utils";
import LoggingSettings from "./LoggingSettings";
const SOURCE_PATH = resolve(process.cwd(), "src/components/team/LoggingSettings.tsx");
const HARDCODED_PALETTE =
/\b(?:text|bg|border|hover:bg|hover:text|hover:border|dark:bg|dark:text|dark:border|ring|divide|fill|stroke)-(?:gray|slate|zinc|neutral|stone|red|blue|green|yellow|amber|orange|indigo|purple|pink|rose|teal|cyan|sky|violet|fuchsia|lime|emerald)-\d+(?:\/\d+)?\b/g;
const SEMANTIC_TOKEN =
/\b(?:text|bg|border|hover:bg|hover:text|ring|divide|fill|stroke)-(?:foreground|muted-foreground|muted|background|card|popover|primary|secondary|destructive|border|input|accent|ring)(?:-foreground)?(?:\/\d+)?\b/g;
describe("LoggingSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -163,6 +173,33 @@ describe("LoggingSettings", () => {
expect(screen.getByText("C")).toBeInTheDocument();
});
it("styles itself from semantic tokens instead of hardcoded palette classes", () => {
const source = readFileSync(SOURCE_PATH, "utf8");
expect(source).toContain("const LoggingSettings");
expect(source.match(SEMANTIC_TOKEN) ?? []).not.toHaveLength(0);
expect(source.match(HARDCODED_PALETTE) ?? []).toHaveLength(0);
});
it("reports the chosen event type when a different option is picked", async () => {
const user = userEvent.setup({ delay: null });
const mockOnChange = vi.fn();
const initialValue = [
{
callback_name: "langsmith",
callback_type: "success",
callback_vars: {},
},
];
renderWithProviders(<LoggingSettings value={initialValue} onChange={mockOnChange} />);
await user.click(screen.getByTitle("Success Only"));
await user.click(await screen.findByTitle("Failure Only"));
expect(mockOnChange).toHaveBeenCalledWith([expect.objectContaining({ callback_type: "failure" })]);
});
it("correctly handles numerical input with decimal values", () => {
const mockOnChange = vi.fn();

View file

@ -1,8 +1,10 @@
/* eslint-disable react/no-unescaped-entities */
import React from "react";
import { Select, Tooltip, Divider } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select, Divider } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
@ -142,30 +144,24 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
if (Object.keys(dynamicParams).length === 0) return null;
return (
<div className="mt-6 pt-4 border-t border-gray-100">
<div className="mt-6 pt-4 border-t border-border">
<div className="flex items-center space-x-2 mb-4">
<div className="w-3 h-3 bg-blue-100 rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full"></div>
<div className="w-3 h-3 bg-muted rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-primary rounded-full"></div>
</div>
<span className="text-sm font-medium text-gray-700">Integration Parameters</span>
<span className="text-sm font-medium text-foreground">Integration Parameters</span>
</div>
<div className="grid grid-cols-1 gap-4">
{Object.entries(dynamicParams).map(([paramName, paramType]) => (
<div key={paramName} className="space-y-2">
<label className="text-sm font-medium text-gray-700 capitalize flex items-center space-x-1">
<label className="text-sm font-medium text-foreground capitalize flex items-center space-x-1">
<span>{paramName.replace(/_/g, " ")}</span>
{paramType === "password" && (
<span className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
Sensitive
</span>
)}
{paramType === "number" && (
<span className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
Number
</span>
)}
{paramType === "password" && <Badge variant="secondary">Sensitive</Badge>}
{paramType === "number" && <Badge variant="secondary">Number</Badge>}
</label>
{paramType === "number" && <span className="text-xs text-gray-500">Value must be between 0 and 1</span>}
{paramType === "number" && (
<span className="text-xs text-muted-foreground">Value must be between 0 and 1</span>
)}
{paramType === "number" ? (
<NumericalInput
step={0.01}
@ -194,15 +190,15 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
{/* Disabled Callbacks Section */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<BanIcon className="w-5 h-5 text-red-500" />
<span className="text-base font-semibold text-gray-800">Disabled Callbacks</span>
<Tooltip title="Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
<BanIcon className="w-5 h-5 text-destructive" />
<span className="text-base font-semibold text-foreground">Disabled Callbacks</span>
<SimpleTooltip content="Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.">
<InfoCircleOutlined className="text-muted-foreground cursor-help" />
</SimpleTooltip>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Disabled Callbacks</label>
<label className="text-sm font-medium text-foreground">Disabled Callbacks</label>
<Select
mode="multiple"
placeholder="Select callbacks to disable"
@ -215,7 +211,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
const description = callbackInfo[callbackName]?.description;
return (
<Option key={callbackName} value={callbackName} label={callbackName}>
<Tooltip title={description} placement="right">
<SimpleTooltip content={description} side="right">
<div className="flex items-center space-x-2">
<Logo
src={callbackInfo[callbackName]?.logo}
@ -224,12 +220,12 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
/>
<span>{callbackName}</span>
</div>
</Tooltip>
</SimpleTooltip>
</Option>
);
})}
</Select>
<div className="text-xs text-gray-500">
<div className="text-xs text-muted-foreground">
Select callbacks that should be disabled for this key. These callbacks will not receive any logging data.
</div>
</div>
@ -240,19 +236,13 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
{/* Logging Integrations Section */}
<div className="flex justify-between items-center">
<div className="flex items-center space-x-2">
<CogIcon className="w-5 h-5 text-blue-500" />
<span className="text-base font-semibold text-gray-800">Logging Integrations</span>
<Tooltip title="Configure callback logging integrations for this team.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
<CogIcon className="w-5 h-5 text-foreground" />
<span className="text-base font-semibold text-foreground">Logging Integrations</span>
<SimpleTooltip content="Configure callback logging integrations for this team.">
<InfoCircleOutlined className="text-muted-foreground cursor-help" />
</SimpleTooltip>
</div>
<Button
variant="secondary"
onClick={addLoggingConfig}
size="sm"
className="hover:border-blue-400 hover:text-blue-500"
type="button"
>
<Button variant="secondary" onClick={addLoggingConfig} size="sm" type="button">
<Plus />
Add Integration
</Button>
@ -267,7 +257,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
return (
<Card
key={index}
className="block p-6 border border-gray-200 shadow-xs hover:shadow-md transition-shadow duration-200"
className="block p-6 border border-border shadow-xs hover:shadow-md transition-shadow duration-200"
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center space-x-2">
@ -284,7 +274,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
variant="ghost"
onClick={() => removeLoggingConfig(index)}
size="sm"
className="text-red-500 hover:bg-red-50 hover:text-red-500"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
type="button"
>
<Trash2 />
@ -294,7 +284,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Integration Type</label>
<label className="text-sm font-medium text-foreground">Integration Type</label>
<Select
value={callbackDisplayName}
placeholder="Select integration"
@ -306,7 +296,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
const description = callbackInfo[callbackName]?.description;
return (
<Option key={callbackName} value={callbackName} label={callbackName}>
<Tooltip title={description} placement="right">
<SimpleTooltip content={description} side="right">
<div className="flex items-center space-x-2">
<Logo
src={callbackInfo[callbackName]?.logo}
@ -315,7 +305,7 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
/>
<span>{callbackName}</span>
</div>
</Tooltip>
</SimpleTooltip>
</Option>
);
})}
@ -323,30 +313,15 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Event Type</label>
<label className="text-sm font-medium text-foreground">Event Type</label>
<Select
value={config.callback_type}
onChange={(value) => updateLoggingConfig(index, "callback_type", value)}
className="w-full"
>
<Option value="success">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Success Only</span>
</div>
</Option>
<Option value="failure">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span>Failure Only</span>
</div>
</Option>
<Option value="success_and_failure">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>Success & Failure</span>
</div>
</Option>
<Option value="success">Success Only</Option>
<Option value="failure">Failure Only</Option>
<Option value="success_and_failure">Success &amp; Failure</Option>
</Select>
</div>
</div>
@ -359,10 +334,12 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
</div>
{value.length === 0 && (
<div className="text-center py-12 text-gray-500 border-2 border-dashed border-gray-200 rounded-lg bg-gray-50/50">
<CogIcon className="w-12 h-12 text-gray-300 mb-3 mx-auto" />
<div className="text-center py-12 text-muted-foreground border-2 border-dashed border-border rounded-lg bg-muted/30">
<CogIcon className="w-12 h-12 text-muted-foreground mb-3 mx-auto" />
<div className="text-base font-medium mb-1">No logging integrations configured</div>
<div className="text-sm text-gray-400">Click "Add Integration" to configure logging for this team</div>
<div className="text-sm text-muted-foreground">
Click "Add Integration" to configure logging for this team
</div>
</div>
)}
</div>

View file

@ -1,6 +1,6 @@
import { formatBudgetReset } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Tooltip } from "@/components/atoms/Tooltip";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { CircleHelp } from "lucide-react";
@ -14,9 +14,9 @@ interface MyUserTabProps {
const labelWithTooltip = (label: string, tooltip: string) => (
<span className="flex items-center gap-1 text-muted-foreground">
{label}
<Tooltip content={tooltip}>
<SimpleTooltip content={tooltip}>
<CircleHelp className="size-4" aria-label={`${label} information`} />
</Tooltip>
</SimpleTooltip>
</span>
);

View file

@ -1,6 +1,6 @@
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Tooltip } from "@/components/atoms/Tooltip";
import { SimpleTooltip } from "@/components/ui/tooltip";
import MemberTable from "@/components/common_components/MemberTable";
import { Member } from "@/components/networking";
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
@ -102,9 +102,9 @@ export default function TeamMemberTab({
title: (
<span className="flex items-center gap-1">
Model Scope
<Tooltip content="Models this member can access. Empty means they inherit all team models.">
<SimpleTooltip content="Models this member can access. Empty means they inherit all team models.">
<CircleHelp className="size-4" aria-label="Model scope information" />
</Tooltip>
</SimpleTooltip>
</span>
),
key: "model_scope",
@ -123,9 +123,9 @@ export default function TeamMemberTab({
</code>
))}
{remaining > 0 && (
<Tooltip content={models.slice(2).join(", ")}>
<SimpleTooltip content={models.slice(2).join(", ")}>
<span className="text-muted-foreground">+{remaining} more</span>
</Tooltip>
</SimpleTooltip>
)}
</div>
);
@ -135,9 +135,9 @@ export default function TeamMemberTab({
title: (
<span className="flex items-center gap-1">
Current Cycle Spend (USD)
<Tooltip content="Spend for the current budget cycle. Resets to $0 when the member's budget window rolls over. This is the value checked against the member's budget.">
<SimpleTooltip content="Spend for the current budget cycle. Resets to $0 when the member's budget window rolls over. This is the value checked against the member's budget.">
<CircleHelp className="size-4" aria-label="Current cycle spend information" />
</Tooltip>
</SimpleTooltip>
</span>
),
key: "spend",
@ -149,9 +149,9 @@ export default function TeamMemberTab({
title: (
<span className="flex items-center gap-1">
Total Spend (USD)
<Tooltip content="Cumulative spend by this member within this team, across all budget cycles. Tracking began 2026-04-21; spend from before that date is not included.">
<SimpleTooltip content="Cumulative spend by this member within this team, across all budget cycles. Tracking began 2026-04-21; spend from before that date is not included.">
<CircleHelp className="size-4" aria-label="Total spend information" />
</Tooltip>
</SimpleTooltip>
</span>
),
key: "total_spend",
@ -173,9 +173,9 @@ export default function TeamMemberTab({
title: (
<span className="flex items-center gap-1">
Team Member Rate Limits
<Tooltip content="Rate limits for this member's usage within this team.">
<SimpleTooltip content="Rate limits for this member's usage within this team.">
<CircleHelp className="size-4" aria-label="Team member rate limits information" />
</Tooltip>
</SimpleTooltip>
</span>
),
key: "rate_limits",

View file

@ -1,6 +1,6 @@
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { Tooltip } from "@/components/atoms/Tooltip";
import { SimpleTooltip } from "@/components/ui/tooltip";
import CopyButton from "@/components/shared/CopyButton";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import {
@ -150,9 +150,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip content={value}>
<SimpleTooltip content={value}>
<span className="block max-w-full truncate font-mono text-xs">{value ?? "-"}</span>
</Tooltip>
</SimpleTooltip>
);
},
},
@ -182,9 +182,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const user = info.getValue() as { user_email?: string } | undefined;
const value = user?.user_email;
return (
<Tooltip content={value}>
<SimpleTooltip content={value}>
<span className="block max-w-full truncate font-mono text-xs">{value ?? "-"}</span>
</Tooltip>
</SimpleTooltip>
);
},
},
@ -198,9 +198,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const userId = info.getValue() as string | null;
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
return (
<Tooltip content={displayValue}>
<SimpleTooltip content={displayValue}>
<span className="block max-w-full truncate font-mono text-xs">{displayValue ?? "-"}</span>
</Tooltip>
</SimpleTooltip>
);
},
},
@ -336,11 +336,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
const models = info.getValue() as string[];
const scope = deriveKeyModelScope(info.row.original.allowed_routes, info.row.original.key_type);
const emptyModelsBadge = !scope.hasModelAccess ? (
<Tooltip content={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<SimpleTooltip content={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<Badge variant="secondary" className="mb-1">
No model access
</Badge>
</Tooltip>
</SimpleTooltip>
) : (
<Badge variant="destructive" className="mb-1">
All Proxy Models

View file

@ -0,0 +1,50 @@
import React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CircleHelp } from "lucide-react";
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent className="max-w-xs">{hint}</TooltipContent>
</Tooltip>
</>
);
const KEY_TYPE_OPTIONS = [
{ value: "default", label: "Full Access", hint: "Can call all routes (AI APIs, Management, and read-only)" },
{ value: "llm_api", label: "AI APIs", hint: "Can call only AI API routes (chat/completions, embeddings, etc.)" },
{ value: "management", label: "Management", hint: "Can call only management routes (user/team/key management)" },
];
export const KeyTypeSelect = ({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) => (
<Select
items={Object.fromEntries(KEY_TYPE_OPTIONS.map((option) => [option.value, option.label]))}
value={value}
onValueChange={(next: string | null) => next != null && onChange(next)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Select key type" />
</SelectTrigger>
<SelectContent>
{KEY_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="py-1">
<div className="font-medium">{option.label}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{option.hint}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);

View file

@ -44,25 +44,28 @@ export const estimateTooltips = (canEdit: boolean, entity: "key" | "team" = "key
: ADMIN_ONLY_TOOLTIP,
});
export const estimateRules = {
export const estimateChecks = {
perModel: {
validator: (_: unknown, value: unknown) => {
if (typeof value !== "string" || value.trim() === "") return Promise.resolve();
return parsePerModelEstimates(value) === null
? Promise.reject(new Error(INVALID_PER_MODEL_MESSAGE))
: Promise.resolve();
},
isValid: (value: unknown): boolean =>
typeof value !== "string" || value.trim() === "" ? true : parsePerModelEstimates(value) !== null,
message: INVALID_PER_MODEL_MESSAGE,
},
positive: {
validator: (_: unknown, value: unknown) => {
if (value === "" || value === null || value === undefined) return Promise.resolve();
return isPositiveInteger(Number(value))
? Promise.resolve()
: Promise.reject(new Error("Enter a positive integer"));
},
isValid: (value: unknown): boolean =>
value === "" || value === null || value === undefined ? true : isPositiveInteger(Number(value)),
message: "Enter a positive integer",
},
};
const asAntdRule = ({ isValid, message }: { isValid: (value: unknown) => boolean; message: string }) => ({
validator: (_: unknown, value: unknown) => (isValid(value) ? Promise.resolve() : Promise.reject(new Error(message))),
});
export const estimateRules = {
perModel: asAntdRule(estimateChecks.perModel),
positive: asAntdRule(estimateChecks.positive),
};
export const withNormalizedEstimates = <T extends FormValues>(values: T): FormValues => {
const { [ESTIMATE_FIELD]: estimate, [PER_MODEL_FIELD]: perModel, ...rest } = values;

View file

@ -17,3 +17,29 @@ export const keyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): s
if (allowedRoutes.includes("info_routes")) return "read_only";
return "default";
};
export const parseAllowedRoutes = (value: unknown): string[] =>
typeof value === "string" && value.trim() !== ""
? value
.split(",")
.map((route) => route.trim())
.filter((route) => route.length > 0)
: [];
export const modelSentinelOptions = (
keyTeamId: string | null | undefined,
teamLoaded: boolean,
): { value: string; label: string }[] => {
if (keyTeamId == null) return [{ value: "all-proxy-models", label: "All Proxy Models" }];
return teamLoaded ? [{ value: "all-team-models", label: "All Team Models" }] : [];
};
export const currentValuePlaceholder = (
premiumUser: boolean,
current: unknown,
premiumHint: string,
emptyHint: string,
): string => {
if (!premiumUser) return premiumHint;
return Array.isArray(current) && current.length > 0 ? `Current: ${current.join(", ")}` : emptyHint;
};

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { keyEditFormSchema } from "./keyEditFormValues";
const parse = (values: Record<string, unknown>) => keyEditFormSchema.safeParse(values);
describe("keyEditFormSchema", () => {
it("accepts an empty form", () => {
expect(parse({}).success).toBe(true);
});
it("rejects a fractional estimated output tokens value", () => {
expect(parse({ default_estimated_output_tokens: "12.5" }).success).toBe(false);
});
it("rejects a zero or negative estimated output tokens value", () => {
expect(parse({ default_estimated_output_tokens: "-5" }).success).toBe(false);
expect(parse({ default_estimated_output_tokens: 0 }).success).toBe(false);
});
it("accepts a blank or absent estimated output tokens value", () => {
expect(parse({ default_estimated_output_tokens: "" }).success).toBe(true);
expect(parse({ default_estimated_output_tokens: null }).success).toBe(true);
});
it("rejects a per-model estimate that is not a JSON object of positive integers", () => {
expect(parse({ default_estimated_output_tokens_per_model: "not json" }).success).toBe(false);
expect(parse({ default_estimated_output_tokens_per_model: '{"gpt-4": 0}' }).success).toBe(false);
});
it("accepts a per-model estimate that is a JSON object of positive integers", () => {
expect(parse({ default_estimated_output_tokens_per_model: '{"gpt-4": 4096}' }).success).toBe(true);
});
});

View file

@ -0,0 +1,203 @@
import { z } from "zod/v4";
import { KeyResponse } from "../key_team_helpers/key_list";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
import { mapInternalToDisplayNames } from "../callback_info_helpers";
import { estimateChecks, estimateFields } from "./estimatedOutputTokens";
import { canonicalBudgetDuration } from "./keyEditFieldNormalizers";
export interface McpServersAndGroups {
servers: string[];
accessGroups: string[];
toolsets: string[];
}
export interface AgentsAndGroups {
agents: string[];
accessGroups: string[];
}
export interface KeyEditFormValues {
key_alias?: string;
models?: string[];
allowed_routes?: string;
max_budget?: number | string | null;
budget_duration?: string | null;
tpm_limit?: number | string | null;
tpm_limit_type?: string | null;
rpm_limit?: number | string | null;
rpm_limit_type?: string | null;
throttle_on_budget_exceeded?: boolean;
enable_prompt_caching?: boolean;
max_parallel_requests?: number | string | null;
model_tpm_limit?: string;
model_rpm_limit?: string;
default_estimated_output_tokens?: number | string | null;
default_estimated_output_tokens_per_model?: string;
guardrails?: string[];
disable_global_guardrails?: boolean;
policies?: string[];
tags?: string[];
prompts?: string[];
access_group_ids?: string[];
allowed_passthrough_routes?: string[];
vector_stores?: string[];
mcp_servers_and_groups?: McpServersAndGroups;
mcp_tool_permissions?: Record<string, string[]>;
agents_and_groups?: AgentsAndGroups;
organization_id?: string | null;
team_id?: string | null;
logging_settings?: unknown[];
metadata?: string;
duration?: string | null;
token?: string;
disabled_callbacks?: string[];
auto_rotate?: boolean;
rotation_interval?: string;
}
const readMetadata = (keyData: KeyResponse, key: string): unknown =>
keyData.metadata != null && typeof keyData.metadata === "object"
? (keyData.metadata as Record<string, unknown>)[key]
: undefined;
export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues => ({
key_alias: keyData.key_alias,
models: keyData.models,
allowed_routes:
Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 ? keyData.allowed_routes.join(", ") : "",
max_budget: keyData.max_budget,
budget_duration: canonicalBudgetDuration(keyData.budget_duration),
tpm_limit: keyData.tpm_limit,
tpm_limit_type: (keyData as { tpm_limit_type?: string | null }).tpm_limit_type ?? null,
rpm_limit: keyData.rpm_limit,
rpm_limit_type: (keyData as { rpm_limit_type?: string | null }).rpm_limit_type ?? null,
throttle_on_budget_exceeded: Boolean(readMetadata(keyData, "throttle_on_budget_exceeded")),
enable_prompt_caching: Boolean(readMetadata(keyData, "enable_prompt_caching")),
max_parallel_requests: keyData.max_parallel_requests,
model_tpm_limit: (keyData as { model_tpm_limit?: string }).model_tpm_limit,
model_rpm_limit: (keyData as { model_rpm_limit?: string }).model_rpm_limit,
...(estimateFields(keyData.metadata as Record<string, unknown> | null | undefined) as {
default_estimated_output_tokens?: number | string | null;
default_estimated_output_tokens_per_model?: string;
}),
guardrails: readMetadata(keyData, "guardrails") as string[] | undefined,
disable_global_guardrails: Boolean(readMetadata(keyData, "disable_global_guardrails")),
policies: (keyData as { policies?: string[] }).policies,
tags: readMetadata(keyData, "tags") as string[] | undefined,
prompts: readMetadata(keyData, "prompts") as string[] | undefined,
access_group_ids: keyData.access_group_ids || [],
allowed_passthrough_routes: (keyData as { allowed_passthrough_routes?: string[] }).allowed_passthrough_routes,
vector_stores: keyData.object_permission?.vector_stores || [],
mcp_servers_and_groups: {
servers: keyData.object_permission?.mcp_servers || [],
accessGroups: keyData.object_permission?.mcp_access_groups || [],
toolsets: keyData.object_permission?.mcp_toolsets || [],
},
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
agents_and_groups: {
agents: keyData.object_permission?.agents || [],
accessGroups: keyData.object_permission?.agent_access_groups || [],
},
organization_id: keyData.organization_id,
team_id: keyData.team_id,
logging_settings: extractLoggingSettings(keyData.metadata),
metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)),
duration: (keyData as { duration?: string }).duration ?? "",
token: keyData.token || keyData.token_id,
disabled_callbacks: Array.isArray(readMetadata(keyData, "litellm_disabled_callbacks"))
? mapInternalToDisplayNames(readMetadata(keyData, "litellm_disabled_callbacks") as string[])
: [],
auto_rotate: keyData.auto_rotate || false,
rotation_interval: keyData.rotation_interval,
});
export const keyEditFormSchema = z.object({
key_alias: z.custom<string | undefined>(),
models: z.custom<string[] | undefined>(),
allowed_routes: z.custom<string | undefined>(),
max_budget: z.custom<number | string | null | undefined>(),
budget_duration: z.custom<string | null | undefined>(),
tpm_limit: z.custom<number | string | null | undefined>(),
tpm_limit_type: z.custom<string | null | undefined>(),
rpm_limit: z.custom<number | string | null | undefined>(),
rpm_limit_type: z.custom<string | null | undefined>(),
throttle_on_budget_exceeded: z.custom<boolean | undefined>(),
enable_prompt_caching: z.custom<boolean | undefined>(),
max_parallel_requests: z.custom<number | string | null | undefined>(),
model_tpm_limit: z.custom<string | undefined>(),
model_rpm_limit: z.custom<string | undefined>(),
default_estimated_output_tokens: z
.custom<number | string | null | undefined>()
.refine(estimateChecks.positive.isValid, estimateChecks.positive.message),
default_estimated_output_tokens_per_model: z
.custom<string | undefined>()
.refine(estimateChecks.perModel.isValid, estimateChecks.perModel.message),
guardrails: z.custom<string[] | undefined>(),
disable_global_guardrails: z.custom<boolean | undefined>(),
policies: z.custom<string[] | undefined>(),
tags: z.custom<string[] | undefined>(),
prompts: z.custom<string[] | undefined>(),
access_group_ids: z.custom<string[] | undefined>(),
allowed_passthrough_routes: z.custom<string[] | undefined>(),
vector_stores: z.custom<string[] | undefined>(),
mcp_servers_and_groups: z.custom<McpServersAndGroups | undefined>(),
mcp_tool_permissions: z.custom<Record<string, string[]> | undefined>(),
agents_and_groups: z.custom<AgentsAndGroups | undefined>(),
organization_id: z.custom<string | null | undefined>(),
team_id: z.custom<string | null | undefined>(),
logging_settings: z.custom<unknown[] | undefined>(),
metadata: z.custom<string | undefined>(),
duration: z.custom<string | null | undefined>(),
token: z.custom<string | undefined>(),
disabled_callbacks: z.custom<string[] | undefined>(),
auto_rotate: z.custom<boolean | undefined>(),
rotation_interval: z.custom<string | undefined>(),
});
export interface MountedFieldGates {
canViewPolicies: boolean;
canViewPrompts: boolean;
}
export const toSubmittedValues = (
values: KeyEditFormValues,
{ canViewPolicies, canViewPrompts }: MountedFieldGates,
): Record<string, unknown> => ({
key_alias: values.key_alias,
models: values.models,
allowed_routes: values.allowed_routes,
max_budget: values.max_budget,
budget_duration: values.budget_duration,
tpm_limit: values.tpm_limit,
tpm_limit_type: values.tpm_limit_type,
rpm_limit: values.rpm_limit,
rpm_limit_type: values.rpm_limit_type,
throttle_on_budget_exceeded: values.throttle_on_budget_exceeded,
enable_prompt_caching: values.enable_prompt_caching,
max_parallel_requests: values.max_parallel_requests,
model_tpm_limit: values.model_tpm_limit,
model_rpm_limit: values.model_rpm_limit,
default_estimated_output_tokens: values.default_estimated_output_tokens,
default_estimated_output_tokens_per_model: values.default_estimated_output_tokens_per_model,
guardrails: values.guardrails,
disable_global_guardrails: values.disable_global_guardrails,
...(canViewPolicies ? { policies: values.policies } : {}),
tags: values.tags,
...(canViewPrompts ? { prompts: values.prompts } : {}),
access_group_ids: values.access_group_ids,
allowed_passthrough_routes: values.allowed_passthrough_routes,
vector_stores: values.vector_stores,
mcp_servers_and_groups: values.mcp_servers_and_groups,
mcp_tool_permissions: values.mcp_tool_permissions,
agents_and_groups: values.agents_and_groups,
organization_id: values.organization_id,
team_id: values.team_id,
logging_settings: values.logging_settings,
metadata: values.metadata,
duration: values.duration,
token: values.token,
disabled_callbacks: values.disabled_callbacks,
auto_rotate: values.auto_rotate,
rotation_interval: values.rotation_interval,
});

View file

@ -3,7 +3,13 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { KeyResponse } from "../key_team_helpers/key_list";
import { getPoliciesList, getPromptsList, modelAvailableCall } from "../networking";
import {
getPassThroughEndpointsCall,
getPoliciesList,
getPromptsList,
modelAvailableCall,
vectorStoreListCall,
} from "../networking";
import { KeyEditView } from "./key_edit_view";
const can = vi.fn();
@ -98,6 +104,36 @@ vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
}),
}));
vi.mock("../mcp_server_management/MCPServerSelector", () => ({
default: ({
value,
onChange,
}: {
value?: { servers?: string[]; accessGroups?: string[]; toolsets?: string[] };
onChange?: (v: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<button
type="button"
data-testid="mcp-server-selector"
onClick={() => onChange?.({ servers: ["mcp-1"], accessGroups: [], toolsets: value?.toolsets ?? [] })}
>
pick mcp server
</button>
),
}));
vi.mock("../agent_management/AgentSelector", () => ({
default: ({ onChange }: { onChange?: (v: { agents: string[]; accessGroups: string[] }) => void }) => (
<button
type="button"
data-testid="agent-selector"
onClick={() => onChange?.({ agents: ["agent-1"], accessGroups: [] })}
>
pick agent
</button>
),
}));
vi.mock("../common_components/AccessGroupSelector", () => ({
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
<input
@ -108,24 +144,12 @@ vi.mock("../common_components/AccessGroupSelector", () => ({
),
}));
/* eslint-disable local/no-antd-class-selectors -- the "Key Type" and "Models" Form.Items wrap a noStyle nested item, so antd renders a label with no associated control and there is no accessible query for these selects */
const antdSelectorFor = (label: HTMLElement): Element =>
label.closest(".ant-form-item")!.querySelector(".ant-select-selector")!;
/* eslint-enable local/no-antd-class-selectors */
const visibleOptions = (): HTMLElement[] => screen.queryAllByRole("option");
const visibleOptions = (): HTMLElement[] =>
// eslint-disable-next-line local/no-antd-class-selectors -- antd puts role="option" only on a hidden mirror list; the visible, clickable options carry no role or accessible name
Array.from(document.querySelectorAll<HTMLElement>(".ant-select-item-option"));
const isOptionDisabled = (option: HTMLElement): boolean =>
// eslint-disable-next-line local/no-antd-class-selectors -- antd signals option disabled state only through this class; the rendered options carry no aria-disabled
option.classList.contains("ant-select-item-option-disabled");
const isOptionDisabled = (option: HTMLElement): boolean => option.getAttribute("aria-disabled") === "true";
const optionByContent = (label: string): HTMLElement | undefined =>
visibleOptions().find(
// eslint-disable-next-line local/no-antd-class-selectors -- the option's own label text lives in this child node, with no accessible equivalent
(el) => el.querySelector(".ant-select-item-option-content")?.textContent === label,
);
visibleOptions().find((el) => el.textContent === label);
describe("KeyEditView", () => {
const MOCK_KEY_DATA: KeyResponse = {
@ -348,6 +372,48 @@ describe("KeyEditView", () => {
/>,
);
it("locks the prompts control for a non-premium admin so an unsavable value cannot be entered", async () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={"test-token"}
userID={"test-user"}
userRole={"Admin"}
premiumUser={false}
/>,
);
const prompts = await screen.findByLabelText(/Prompts/);
expect(prompts).toBeDisabled();
await userEvent.type(prompts, "sneaky-prompt{Enter}");
expect(screen.queryByLabelText("sneaky-prompt")).not.toBeInTheDocument();
});
it("leaves the prompts control usable for a premium admin", async () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={"test-token"}
userID={"test-user"}
userRole={"Admin"}
premiumUser={true}
/>,
);
const prompts = await screen.findByLabelText(/Prompts/);
expect(prompts).toBeEnabled();
await userEvent.type(prompts, "allowed-prompt{Enter}");
expect(await screen.findByLabelText("allowed-prompt")).toBeInTheDocument();
});
it("should render both fields and load prompts for an admin", async () => {
renderAs("Admin");
@ -1162,7 +1228,7 @@ describe("KeyEditView", () => {
});
// The selected key type label should show "AI APIs" (not "LLM API")
await userEvent.click(antdSelectorFor(screen.getByText("Key Type")));
await userEvent.click(screen.getByLabelText("Key Type"));
await waitFor(() => {
// Verify "AI APIs" appears as an option label
@ -1310,8 +1376,8 @@ describe("KeyEditView", () => {
});
describe("models dropdown team gating", () => {
const openModelsDropdown = () => {
fireEvent.mouseDown(antdSelectorFor(screen.getByText("Models", { selector: "label" })));
const openModelsDropdown = async () => {
await userEvent.click(screen.getByLabelText("Models"));
};
it("should offer all-proxy-models but not all-team-models for a teamless key", async () => {
@ -1331,7 +1397,7 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
@ -1362,7 +1428,7 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
await waitFor(() => {
expect(screen.getAllByText("team-model-1").length).toBeGreaterThan(0);
@ -1393,7 +1459,7 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
expect(screen.queryAllByText("All Team Models")).toHaveLength(0);
expect(screen.queryAllByText("All Proxy Models")).toHaveLength(0);
@ -1420,10 +1486,9 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
const proxyOptionLabels = () =>
Array.from(document.querySelectorAll('[role="option"]')).map((option) => option.getAttribute("aria-label"));
const proxyOptionLabels = () => visibleOptions().map((option) => option.textContent);
await waitFor(() => {
expect(proxyOptionLabels()).toContain("gpt-4");
@ -1453,7 +1518,7 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
const clickOption = async (label: string) => {
const option = await waitFor(() => {
@ -1466,6 +1531,7 @@ describe("KeyEditView", () => {
await clickOption("gpt-4");
await clickOption("All Proxy Models");
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
@ -1492,7 +1558,7 @@ describe("KeyEditView", () => {
expect(screen.getByText("Models", { selector: "label" })).toBeInTheDocument();
});
openModelsDropdown();
await openModelsDropdown();
const findOption = (label: string) => optionByContent(label);
@ -1535,6 +1601,47 @@ describe("KeyEditView", () => {
/>,
);
it("refuses to save an invalid per-model estimate, and saves once it is corrected", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderEditView(MOCK_KEY_DATA, onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
const perModel = screen.getByLabelText("Estimated Output Tokens Per Model");
fireEvent.change(perModel, { target: { value: "not json" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/JSON object of positive integers/)).toBeInTheDocument();
expect(onSubmitMock).not.toHaveBeenCalled();
fireEvent.change(perModel, { target: { value: '{"gpt-4": 4096}' } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
});
it("refuses to save a fractional estimate, and saves once it is corrected", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderEditView(MOCK_KEY_DATA, onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
const estimate = screen.getByLabelText("Estimated Output Tokens");
fireEvent.change(estimate, { target: { value: "12.5" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
fireEvent.change(estimate, { target: { value: "2048" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
});
it("loads the estimates from key metadata and resubmits them unchanged", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderEditView(
@ -1777,5 +1884,297 @@ describe("KeyEditView", () => {
});
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null);
});
it("carries a typed value from every free-text and numeric control into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
const retype = async (label: RegExp | string, text: string) => {
const control = screen.getByLabelText(label);
await userEvent.clear(control);
await userEvent.type(control, text);
};
await retype("Key Alias", "typed-alias");
await retype("Max Budget (USD)", "12.5");
await retype("TPM Limit", "111");
await retype("RPM Limit", "222");
await retype("Max Parallel Requests", "3");
await retype("Model TPM Limit", '{{"gpt-4": 7}');
await retype("Model RPM Limit", '{{"gpt-4": 8}');
await retype("Metadata", '{{"typed": true}');
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).toMatchObject({
key_alias: "typed-alias",
max_budget: "12.5",
tpm_limit: "111",
rpm_limit: "222",
max_parallel_requests: "3",
model_tpm_limit: '{"gpt-4": 7}',
model_rpm_limit: '{"gpt-4": 8}',
metadata: '{"typed": true}',
});
});
it("carries every toggle driven off its default into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("switch", { name: /throttle on budget exceeded/i }));
await userEvent.click(screen.getByRole("switch", { name: /enable prompt caching/i }));
await userEvent.click(screen.getByRole("switch", { name: /disable global guardrails/i }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).toMatchObject({
throttle_on_budget_exceeded: true,
enable_prompt_caching: true,
disable_global_guardrails: true,
});
});
it("carries a tag typed into the tags control into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.type(screen.getByLabelText("Tags"), "typed-tag{Enter}");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].tags).toEqual(["test-tag", "typed-tag"]);
});
const pickFromCombobox = async (inputLabel: RegExp | string, optionName: RegExp | string) => {
await userEvent.click(screen.getByLabelText(inputLabel));
await userEvent.click(await screen.findByRole("option", { name: optionName }));
await userEvent.keyboard("{Escape}");
};
it("carries a picked guardrail into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await pickFromCombobox("Select guardrails", "guardrail-1");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].guardrails).toEqual(["guardrail-1"]);
});
it("carries a picked policy into the payload", async () => {
vi.mocked(getPoliciesList).mockResolvedValueOnce({
policies: [{ policy_name: "policy-1", version_number: 1, version_status: "production" }],
});
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await pickFromCombobox(/Select policies/, /policy-1/);
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].policies).toEqual(["policy-1"]);
});
it("carries a typed prompt into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.type(screen.getByLabelText("Prompts"), "prompt-1{Enter}");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].prompts).toEqual(["prompt-1"]);
});
it("carries the RPM rate limit type into its own payload key", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByLabelText(/RPM Rate Limit Type/));
await userEvent.click(await screen.findByTitle("Guaranteed throughput"));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
const payload = onSubmitMock.mock.calls[0][0];
expect(payload.rpm_limit_type).toBe("guaranteed_throughput");
expect(payload.tpm_limit_type).toBeNull();
});
it("carries a picked vector store into the payload", async () => {
vi.mocked(vectorStoreListCall).mockResolvedValueOnce({
data: [{ vector_store_id: "vs-1", vector_store_name: "VS One" }],
});
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await pickFromCombobox("Select vector stores", /VS One/);
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].vector_stores).toEqual(["vs-1"]);
});
it("carries a picked pass through route into the payload", async () => {
vi.mocked(getPassThroughEndpointsCall).mockResolvedValueOnce({
endpoints: [{ path: "/bria", methods: ["POST"] }],
});
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await pickFromCombobox(/allowed pass through routes/, /\/bria/);
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].allowed_passthrough_routes).toEqual(["/bria"]);
});
it("carries a picked team into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
teams={[{ team_id: "team-9", team_alias: "Team Nine" }]}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"Admin"}
premiumUser={true}
/>,
);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByLabelText("Team ID"));
await userEvent.click(await screen.findByRole("option", { name: /Team Nine/ }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].team_id).toBe("team-9");
});
it("carries a picked MCP server into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: "pick mcp server" }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].mcp_servers_and_groups.servers).toEqual(["mcp-1"]);
});
it("carries a picked agent into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: "pick agent" }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].agents_and_groups.agents).toEqual(["agent-1"]);
});
it("carries an added logging integration into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /add integration/i }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].logging_settings).toEqual([
{ callback_name: "", callback_type: "success", callback_vars: {} },
]);
});
it("resends stored budget fallbacks on an untouched save", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, {
...MOCK_KEY_DATA,
budget_fallbacks: { "gpt-4": ["gpt-4o-mini"] },
} as KeyResponse);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("budget_fallbacks", { "gpt-4": ["gpt-4o-mini"] });
});
it("omits budget fallbacks entirely for a key that has none", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).not.toHaveProperty("budget_fallbacks");
});
it("resends the stored per-tag rpm limits on an untouched save", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, {
...MOCK_KEY_DATA,
metadata: { ...MOCK_KEY_DATA.metadata, tag_rpm_limit: { "test-tag": 7 } },
} as KeyResponse);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("tag_rpm_limit", { "test-tag": 7 });
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,19 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Tooltip } from "./Tooltip";
import { SimpleTooltip } from "./tooltip";
describe("Tooltip", () => {
describe("SimpleTooltip", () => {
it("should render", () => {
render(<Tooltip content="Help text" />);
render(<SimpleTooltip content="Help text" />);
expect(screen.getByLabelText("question-circle")).toBeInTheDocument();
});
it("should render children instead of the default icon when provided", () => {
render(
<Tooltip content="Help text">
<SimpleTooltip content="Help text">
<button>Info</button>
</Tooltip>,
</SimpleTooltip>,
);
expect(screen.getByRole("button", { name: /info/i })).toBeInTheDocument();
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
@ -21,7 +21,7 @@ describe("Tooltip", () => {
it("should show tooltip content on mouse enter", async () => {
const user = userEvent.setup();
render(<Tooltip content="Help text" />);
render(<SimpleTooltip content="Help text" />);
await user.hover(screen.getByLabelText("question-circle"));
@ -30,7 +30,7 @@ describe("Tooltip", () => {
it("should hide tooltip content on mouse leave", async () => {
const user = userEvent.setup();
render(<Tooltip content="Help text" />);
render(<SimpleTooltip content="Help text" />);
await user.hover(screen.getByLabelText("question-circle"));
expect(screen.getByText("Help text")).toBeInTheDocument();
@ -40,7 +40,31 @@ describe("Tooltip", () => {
});
it("should not show tooltip content before hovering", () => {
render(<Tooltip content="Help text" />);
render(<SimpleTooltip content="Help text" />);
expect(screen.queryByText("Help text")).not.toBeInTheDocument();
});
it("should keep rendering children when there is no content to show", async () => {
const user = userEvent.setup();
render(
<SimpleTooltip content={undefined}>
<button>Pick a rubric</button>
</SimpleTooltip>,
);
const trigger = screen.getByRole("button", { name: /pick a rubric/i });
await user.hover(trigger);
expect(trigger).toBeInTheDocument();
expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
});
it("should place the tooltip on the requested side", async () => {
const user = userEvent.setup();
render(<SimpleTooltip content="Help text" side="right" />);
await user.hover(screen.getByLabelText("question-circle"));
expect(await screen.findByText("Help text")).toHaveAttribute("data-side", "right");
});
});

View file

@ -1,6 +1,8 @@
"use client";
import React from "react";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { CircleHelp } from "lucide-react";
import { cn } from "@/lib/cva.config";
@ -51,4 +53,42 @@ function TooltipContent({
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface SimpleTooltipProps {
content: React.ReactNode;
children?: React.ReactNode;
width?: string;
className?: string;
side?: React.ComponentProps<typeof TooltipContent>["side"];
}
const widthClassNames: Record<string, string> = {
"360px": "max-w-[360px]",
"500px": "max-w-[500px]",
auto: "max-w-xs",
};
const triggerClassName = (className?: string): string =>
cn(
"inline-flex cursor-help items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
);
const defaultTrigger = <CircleHelp aria-label="question-circle" className="ml-1 size-4 text-muted-foreground" />;
const SimpleTooltip: React.FC<SimpleTooltipProps> = ({ content, children, width = "auto", className, side }) =>
content === undefined || content === null || content === "" ? (
<span className={triggerClassName(className)}>{children ?? defaultTrigger}</span>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className={triggerClassName(className)} />}>
{children ?? defaultTrigger}
</TooltipTrigger>
<TooltipContent side={side} className={cn("whitespace-normal", widthClassNames[width] ?? "max-w-xs")}>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip };