mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/jovial-archimedes-1d743b
This commit is contained in:
commit
fae5aabc5c
71 changed files with 4308 additions and 641 deletions
23
.github/actions/cache-cargo-build/action.yml
vendored
23
.github/actions/cache-cargo-build/action.yml
vendored
|
|
@ -4,17 +4,16 @@ description: >-
|
|||
so only the first job on a given Cargo.lock compiles the bridge from scratch.
|
||||
|
||||
litellm builds through maturin, which compiles litellm-rust/crates/python-bridge
|
||||
in release mode before it can produce a wheel. `uv sync` therefore pays a full
|
||||
build in every job that installs the workspace: measured at 2m40s per unit shard
|
||||
on 2026-08-21, more than the whole unit tier spends running tests. Nothing caught
|
||||
it, because the uv cache holds wheels uv downloads rather than wheels it builds,
|
||||
and a path dependency whose source moves every commit could never hit that cache
|
||||
anyway. Cargo rebuilds only what changed when its target directory survives, so a
|
||||
warm job pays for the bridge crate alone.
|
||||
in the dev profile for editable installs. `uv sync` therefore pays a full build
|
||||
in every job that installs the workspace. Nothing caught it, because the uv cache
|
||||
holds wheels uv downloads rather than wheels it builds, and a path dependency
|
||||
whose source moves every commit could never hit that cache anyway. Cargo rebuilds
|
||||
only what changed when its target directory survives, so a warm job pays for the
|
||||
bridge crate alone.
|
||||
|
||||
The key namespace is separate from test-rust.yml's. Both cache the same directory,
|
||||
but that workflow fills it with debug and clippy artifacts, which a release build
|
||||
cannot reuse, and a shared key would let whichever ran first deny the other a save.
|
||||
The key namespace is separate from test-rust.yml's check and release caches. They
|
||||
cache the same directory for different workloads, and a shared key would let
|
||||
whichever ran first deny the others a save.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
|
@ -26,6 +25,6 @@ runs:
|
|||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
litellm-rust/target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('litellm-rust/Cargo.lock') }}
|
||||
key: ${{ runner.os }}-maturin-dev-${{ hashFiles('litellm-rust/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-
|
||||
${{ runner.os }}-maturin-dev-
|
||||
|
|
|
|||
77
.github/workflows/test-redis-compat.yml
vendored
Normal file
77
.github/workflows/test-redis-compat.yml
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
name: "Unit Tests: Redis Client Version Compatibility"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
paths:
|
||||
- "litellm/_redis.py"
|
||||
- "litellm/_redis_credential_provider.py"
|
||||
- "tests/test_litellm/test_redis.py"
|
||||
- "tests/test_litellm/caching/test_redis_connection_pool.py"
|
||||
- ".github/workflows/test-redis-compat.yml"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
redis-compat:
|
||||
name: "redis-py ${{ matrix.redis-version }}"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 5.3.1 is the version pinned in uv.lock (redisvl caps it below 6); the
|
||||
# newer legs prove the inspect.signature introspection in litellm/_redis.py
|
||||
# keeps extracting kwargs on the redis-py releases people actually run now.
|
||||
# Only the exact release 6.0.0 is skipped: rq (pulled by the proxy extra)
|
||||
# specifies `redis != 6`, which excludes 6.0.0 alone, so 6.4.0 stands in
|
||||
# for the 6.x line.
|
||||
redis-version: ["5.3.1", "6.4.0", "7.4.1", "8.0.1"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
|
||||
|
||||
- name: Pin redis-py to the matrix version
|
||||
env:
|
||||
REDIS_VERSION: ${{ matrix.redis-version }}
|
||||
run: |
|
||||
uv pip install "redis==${REDIS_VERSION:?}"
|
||||
uv run --no-sync python -c "import redis; assert redis.__version__ == '${REDIS_VERSION:?}', redis.__version__; print('redis-py', redis.__version__)"
|
||||
|
||||
- name: Run redis unit tests
|
||||
run: |
|
||||
uv run --no-sync pytest \
|
||||
tests/test_litellm/test_redis.py \
|
||||
tests/test_litellm/caching/test_redis_connection_pool.py \
|
||||
--tb=short -vv \
|
||||
--reruns 2 \
|
||||
--reruns-delay 1 \
|
||||
--durations=20
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "LiteLLM_ShadowEvalJob" ADD COLUMN IF NOT EXISTS "router_names" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
ALTER TABLE "LiteLLM_ShadowEvalAttempt" ADD COLUMN IF NOT EXISTS "router_name" TEXT;
|
||||
|
|
@ -1531,7 +1531,8 @@ model LiteLLM_ShadowEvalJob {
|
|||
group_id String // legs of one job share this; the API's job id
|
||||
target_type String @default("key") // key | team | user
|
||||
target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows
|
||||
router_name String // the auto-router under evaluation, in either direction
|
||||
router_name String // first (often only) auto-router under evaluation; router_names is the full set
|
||||
router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name)
|
||||
direction String @default("forward") // forward | reverse
|
||||
baseline_model String? // reverse only: the fixed model the router is judged against
|
||||
judge_model String
|
||||
|
|
@ -1555,6 +1556,7 @@ model LiteLLM_ShadowEvalAttempt {
|
|||
job_id String
|
||||
request_id String // the judged real request
|
||||
outcome String // real | shadow | tie | error
|
||||
router_name String? // the arm this verdict scores; NULL on legacy rows, meaning the job's own router
|
||||
tier String? // router's tier for the prompt, when classified
|
||||
real_model String?
|
||||
shadow_model String?
|
||||
|
|
|
|||
|
|
@ -30,3 +30,12 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"]
|
|||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "unwind"
|
||||
debug = false
|
||||
incremental = false
|
||||
strip = "symbols"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ name = "_native"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = ["extension-module"]
|
||||
default = ["abi3"]
|
||||
abi3 = ["pyo3/abi3-py310"]
|
||||
extension-module = ["pyo3/extension-module"]
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -264,13 +264,17 @@ def _plain_log_format(stdout: TextIO | None, stderr: TextIO | None) -> str:
|
|||
|
||||
|
||||
class LevelRoutingStreamHandler(logging.StreamHandler):
|
||||
"""Writes records below WARNING to stdout and WARNING and above to stderr.
|
||||
"""Writes records below WARNING and invalid-key warnings to stdout, others to stderr.
|
||||
|
||||
Collectors that derive severity from the stream report every stderr line as an error.
|
||||
Invalid-key warnings route to stdout so LITELLM_LOG=ERROR can suppress them.
|
||||
"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
preferred: Final = sys.stdout if record.levelno < logging.WARNING else sys.stderr
|
||||
is_stdout_record: Final = record.levelno < logging.WARNING or (
|
||||
record.levelno == logging.WARNING and record.name == verbose_proxy_stdout_logger.name
|
||||
)
|
||||
preferred: Final = sys.stdout if is_stdout_record else sys.stderr
|
||||
if preferred is None or getattr(preferred, "closed", False):
|
||||
self.stream = sys.stderr # rebind-ok: fall back to the pre-fix stream rather than raising per record
|
||||
else:
|
||||
|
|
@ -508,6 +512,9 @@ else:
|
|||
handler.setFormatter(formatter)
|
||||
|
||||
verbose_proxy_logger = logging.getLogger("LiteLLM Proxy")
|
||||
# Malformed virtual key rejections log through this child; LevelRoutingStreamHandler
|
||||
# writes its WARNING records to stdout. It has no handler or level of its own.
|
||||
verbose_proxy_stdout_logger: Final = verbose_proxy_logger.getChild("stdout")
|
||||
verbose_router_logger = logging.getLogger("LiteLLM Router")
|
||||
verbose_logger = logging.getLogger("LiteLLM")
|
||||
|
||||
|
|
@ -520,6 +527,7 @@ verbose_logger.addHandler(handler)
|
|||
# handlers (JSON mode, uvicorn log config, a host app's root handler).
|
||||
verbose_router_logger.addFilter(_stdout_truncation_filter)
|
||||
verbose_proxy_logger.addFilter(_stdout_truncation_filter)
|
||||
verbose_proxy_stdout_logger.addFilter(_stdout_truncation_filter)
|
||||
verbose_logger.addFilter(_stdout_truncation_filter)
|
||||
|
||||
|
||||
|
|
@ -683,6 +691,7 @@ def _turn_on_json():
|
|||
- Adds a JSON formatter to all loggers
|
||||
"""
|
||||
handler: Final = LevelRoutingStreamHandler()
|
||||
handler.setLevel(numeric_level)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
_initialize_loggers_with_handler(handler)
|
||||
# Set up exception handlers
|
||||
|
|
@ -700,12 +709,14 @@ def _disable_debugging():
|
|||
verbose_logger.disabled = True
|
||||
verbose_router_logger.disabled = True
|
||||
verbose_proxy_logger.disabled = True
|
||||
verbose_proxy_stdout_logger.disabled = True
|
||||
|
||||
|
||||
def _enable_debugging():
|
||||
verbose_logger.disabled = False
|
||||
verbose_router_logger.disabled = False
|
||||
verbose_proxy_logger.disabled = False
|
||||
verbose_proxy_stdout_logger.disabled = False
|
||||
|
||||
|
||||
def print_verbose(print_statement):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import json
|
|||
# s/o [@Frank Colson](https://www.linkedin.com/in/frank-colson-422b9b183/) for this redis implementation
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
|
@ -38,9 +39,25 @@ from ._logging import verbose_logger
|
|||
AZURE_REDIS_SCOPE: Final = "https://redis.azure.com/.default"
|
||||
|
||||
|
||||
def _get_redis_kwargs():
|
||||
arg_spec: Final = inspect.getfullargspec(redis.Redis)
|
||||
def _unwrapped_init_args(cls: type) -> frozenset[str]:
|
||||
"""Every parameter on a single class's own ``__init__``, decorator-unwrapped.
|
||||
|
||||
Unlike ``_init_arg_names`` below, this does not walk the MRO: ``redis.Redis``
|
||||
and ``redis.RedisCluster`` (sync and async) each declare every real
|
||||
constructor parameter directly on their own ``__init__``, so MRO-walking is
|
||||
unnecessary — and it actively breaks the several tests here that mock the
|
||||
class with ``patch(..., autospec=True)``, since ``inspect.getmro`` needs a
|
||||
real ``__mro__`` that an autospec'd stand-in for a class does not provide.
|
||||
|
||||
Still unwraps first: redis-py >= 7.4 decorates these ``__init__``s with
|
||||
``@deprecated_args`` too, which the same class of bug as ``_init_arg_names``
|
||||
would otherwise silently empty this allowlist through (see its docstring).
|
||||
"""
|
||||
spec: Final = inspect.getfullargspec(inspect.unwrap(cls.__init__))
|
||||
return frozenset(spec.args + spec.kwonlyargs)
|
||||
|
||||
|
||||
def _get_redis_kwargs():
|
||||
# Only allow primitive arguments
|
||||
exclude_args: Final = {
|
||||
"self",
|
||||
|
|
@ -60,7 +77,7 @@ def _get_redis_kwargs():
|
|||
"azure_client_secret",
|
||||
}
|
||||
|
||||
available_args: Final = {x for x in arg_spec.args if x not in exclude_args} | include_args
|
||||
available_args: Final = {x for x in _unwrapped_init_args(redis.Redis) if x not in exclude_args} | include_args
|
||||
|
||||
return available_args
|
||||
|
||||
|
|
@ -120,15 +137,23 @@ def _get_redis_url_kwargs(client: type | None = None) -> tuple[str, ...]:
|
|||
return tuple(x for x in _init_arg_names(connection_cls) if x not in exclude_args) + include_args
|
||||
|
||||
|
||||
def _get_redis_cluster_kwargs(client=None):
|
||||
def _get_redis_cluster_kwargs(client: type | None = None):
|
||||
"""Config kwargs the target cluster client's constructor actually accepts.
|
||||
|
||||
Defaults to the sync ``redis.RedisCluster``, but the async cluster client
|
||||
(``redis.asyncio.cluster.RedisCluster``) declares connection settings such as
|
||||
``decode_responses`` on its own constructor, where the sync class takes them
|
||||
through ``**kwargs`` and so never names them in its signature. Introspecting
|
||||
only the sync class regardless of which client is actually built silently
|
||||
drops those for every async cluster caller.
|
||||
"""
|
||||
if client is None:
|
||||
client = redis.Redis.from_url
|
||||
arg_spec: Final = inspect.getfullargspec(redis.RedisCluster)
|
||||
client = redis.RedisCluster
|
||||
|
||||
# Only allow primitive arguments
|
||||
exclude_args: Final = {"self", "connection_pool", "retry", "host", "port", "startup_nodes"}
|
||||
|
||||
available_args = {x for x in arg_spec.args if x not in exclude_args}
|
||||
available_args = {x for x in _unwrapped_init_args(client) if x not in exclude_args}
|
||||
available_args |= {
|
||||
"password",
|
||||
"username",
|
||||
|
|
@ -161,6 +186,79 @@ def _get_redis_env_kwarg_mapping():
|
|||
return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs() if x not in exclude_from_environment}
|
||||
|
||||
|
||||
def _str_to_bool(value: str) -> bool:
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _coerce_redis_kwargs_types(
|
||||
redis_kwargs: Mapping[str, object],
|
||||
client: type | tuple[type, ...] = redis.Redis,
|
||||
) -> dict[str, object]: # mutable-ok: a caller mutates the returned kwargs before constructing its client
|
||||
"""Coerces string values to the numeric/boolean type ``client``'s constructor
|
||||
declares for that parameter. ``client`` may be a tuple of client classes; a
|
||||
parameter's type is taken from the first signature that declares it, which
|
||||
lets cluster callers coerce cluster-only kwargs such as
|
||||
``cluster_error_retry_attempts`` alongside the shared connection kwargs.
|
||||
|
||||
Environment variables are always strings, and Helm ``--set`` stringifies values
|
||||
too, so a config value like ``health_check_interval`` or ``socket_timeout``
|
||||
can arrive as ``"30"``/``"5.5"`` rather than a real number. redis-py's own
|
||||
connection-health-check arithmetic (``loop.time() + self.health_check_interval``)
|
||||
then raises ``TypeError`` on every Redis operation instead of connecting.
|
||||
|
||||
``max_connections``, ``socket_timeout``, and ``socket_connect_timeout`` use an
|
||||
explicit target type rather than the parameter's own signature default: redis-py
|
||||
8.x changed the timeout defaults from ``None`` to int ``5``, so inferring the
|
||||
type from the default would make a fractional ``"5.5"`` fail ``int()`` and get
|
||||
silently dropped on 8.x while working on older versions. ``socket_keepalive``
|
||||
is explicit too: its signature default is ``None``, which carries no type to
|
||||
infer from, and leaving it a string makes ``"false"`` truthy.
|
||||
"""
|
||||
signatures: Final = tuple(inspect.signature(c) for c in (client if isinstance(client, tuple) else (client,)))
|
||||
explicit_param_types: Final = MappingProxyType(
|
||||
{
|
||||
"max_connections": int,
|
||||
"socket_timeout": float,
|
||||
"socket_connect_timeout": float,
|
||||
"socket_keepalive": bool,
|
||||
}
|
||||
)
|
||||
result: Final = dict(redis_kwargs) # mutable-ok: per-key try/except coercion below needs to drop individual keys
|
||||
for key, value in redis_kwargs.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
param = next((sig.parameters[key] for sig in signatures if key in sig.parameters), None)
|
||||
if param is None:
|
||||
continue
|
||||
explicit_type = explicit_param_types.get(key)
|
||||
if explicit_type is bool:
|
||||
result[key] = _str_to_bool(value)
|
||||
continue
|
||||
if explicit_type is not None:
|
||||
try:
|
||||
result[key] = explicit_type(value)
|
||||
except (ValueError, TypeError):
|
||||
del result[key]
|
||||
continue
|
||||
default: object = param.default # pyright: ignore[reportAny] # inspect.Parameter.default is stubbed as Any
|
||||
if default is inspect.Parameter.empty:
|
||||
continue
|
||||
# bool must be checked before int, since bool subclasses int
|
||||
if isinstance(default, bool):
|
||||
result[key] = _str_to_bool(value)
|
||||
elif isinstance(default, int):
|
||||
try:
|
||||
result[key] = int(value)
|
||||
except (ValueError, TypeError):
|
||||
del result[key]
|
||||
elif isinstance(default, float):
|
||||
try:
|
||||
result[key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
del result[key]
|
||||
return result
|
||||
|
||||
|
||||
def _redis_kwargs_from_environment():
|
||||
mapping: Final = _get_redis_env_kwarg_mapping()
|
||||
|
||||
|
|
@ -505,7 +603,12 @@ def _get_redis_client_logic(**env_overrides):
|
|||
raise ValueError("Either 'host' or 'url' must be specified for redis.")
|
||||
|
||||
# litellm.print_verbose(f"redis_kwargs: {redis_kwargs}")
|
||||
return redis_kwargs
|
||||
coercion_client: Final = (
|
||||
(redis.Redis, redis.RedisCluster, async_redis.RedisCluster)
|
||||
if redis_kwargs.get("startup_nodes")
|
||||
else redis.Redis
|
||||
)
|
||||
return _coerce_redis_kwargs_types(redis_kwargs, client=coercion_client)
|
||||
|
||||
|
||||
def init_redis_cluster(redis_kwargs) -> redis.RedisCluster:
|
||||
|
|
@ -657,7 +760,9 @@ def get_redis_client(**env_overrides):
|
|||
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
|
||||
return _init_redis_sentinel(redis_kwargs)
|
||||
|
||||
return redis.Redis(**redis_kwargs)
|
||||
return redis.Redis( # pyright: ignore[reportCallIssue] # object-valued kwargs match no overload statically
|
||||
**redis_kwargs, # pyright: ignore[reportArgumentType] # allow-listed and coerced against this signature
|
||||
)
|
||||
|
||||
|
||||
def get_redis_async_client(
|
||||
|
|
@ -669,7 +774,7 @@ def get_redis_async_client(
|
|||
if "startup_nodes" in redis_kwargs:
|
||||
from redis.cluster import ClusterNode
|
||||
|
||||
args = _get_redis_cluster_kwargs()
|
||||
args = _get_redis_cluster_kwargs(async_redis.RedisCluster)
|
||||
cluster_kwargs: Final = {}
|
||||
for arg in redis_kwargs:
|
||||
if arg in args:
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,12 @@ DEFAULT_SOFT_BUDGET: Final = float(
|
|||
) # by default all litellm proxy keys have a soft budget of 50.0
|
||||
# makes it clear this is a rate limit error for a litellm virtual key
|
||||
RATE_LIMIT_ERROR_MESSAGE_FOR_VIRTUAL_KEY: Final = "LiteLLM Virtual Key user_api_key_hash"
|
||||
# Prefix of the 401 raised when a submitted virtual key is not shaped like one.
|
||||
INVALID_VIRTUAL_KEY_ERROR_MESSAGE: Final = "LiteLLM Virtual Key expected"
|
||||
# Attribute stamped on that 401 at its raise site so log routing recognises it by
|
||||
# provenance. Message text is caller-influenceable on other 401s, so it must not
|
||||
# be used to classify.
|
||||
INVALID_VIRTUAL_KEY_ERROR_MARKER: Final = "_litellm_invalid_virtual_key_error"
|
||||
|
||||
# Python garbage collection threshold configuration
|
||||
# Format: "gen0,gen1,gen2" e.g., "1000,50,50"
|
||||
|
|
|
|||
|
|
@ -115,9 +115,11 @@ class SpeechToCompletionBridgeHandler:
|
|||
**request_data,
|
||||
)
|
||||
|
||||
requested_response_format: Final = optional_params.get("response_format")
|
||||
if isinstance(result, ModelResponse):
|
||||
return self.transformation_handler.transform_response(
|
||||
model_response=result,
|
||||
response_format=requested_response_format if isinstance(requested_response_format, str) else None,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unmapped response type. Got type: {type(result)}")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ def _completion_response_cost(model_response: "ModelResponse") -> float | None:
|
|||
|
||||
|
||||
GEMINI_TTS_CHAT_AUDIO_FORMAT: Final = "pcm16"
|
||||
GEMINI_TTS_RAW_RESPONSE_FORMAT: Final = "pcm"
|
||||
GEMINI_TTS_SUPPORTED_RESPONSE_FORMATS: Final = frozenset({"wav", GEMINI_TTS_RAW_RESPONSE_FORMAT})
|
||||
|
||||
|
||||
class ChatAudioParam(TypedDict):
|
||||
|
|
@ -29,6 +31,26 @@ class ChatAudioParam(TypedDict):
|
|||
|
||||
|
||||
class SpeechToCompletionBridgeTransformationHandler:
|
||||
def _validate_response_format(
|
||||
self, model: str, custom_llm_provider: str, optional_params: Mapping[str, object]
|
||||
) -> None:
|
||||
if not self._is_gemini_tts_model(model):
|
||||
return
|
||||
response_format: Final = optional_params.get("response_format")
|
||||
if not isinstance(response_format, str) or response_format in GEMINI_TTS_SUPPORTED_RESPONSE_FORMATS:
|
||||
return
|
||||
from litellm.exceptions import BadRequestError
|
||||
|
||||
supported: Final = ", ".join(sorted(GEMINI_TTS_SUPPORTED_RESPONSE_FORMATS))
|
||||
raise BadRequestError(
|
||||
message=(
|
||||
f"Gemini TTS only produces raw PCM16 audio, so response_format='{response_format}'"
|
||||
f" is not supported. Supported response formats: {supported}."
|
||||
),
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
def _chat_completion_params(self, optional_params: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
|
|
@ -67,6 +89,7 @@ class SpeechToCompletionBridgeTransformationHandler:
|
|||
litellm_logging_obj: "LiteLLMLoggingObj",
|
||||
custom_llm_provider: str,
|
||||
) -> dict:
|
||||
self._validate_response_format(model, custom_llm_provider, optional_params)
|
||||
user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input}
|
||||
return_kwargs: Final = {
|
||||
"model": model,
|
||||
|
|
@ -125,7 +148,14 @@ class SpeechToCompletionBridgeTransformationHandler:
|
|||
"""Check if the model is a Gemini TTS model that returns PCM16 data."""
|
||||
return "gemini" in model.lower() and ("tts" in model.lower() or "preview-tts" in model.lower())
|
||||
|
||||
def transform_response(self, model_response: "ModelResponse") -> "HttpxBinaryResponseContent":
|
||||
def _gemini_tts_response_body(self, decoded_audio: bytes, response_format: str | None) -> tuple[bytes, str]:
|
||||
if response_format == GEMINI_TTS_RAW_RESPONSE_FORMAT:
|
||||
return decoded_audio, "audio/pcm"
|
||||
return self._convert_pcm16_to_wav(decoded_audio), "audio/wav"
|
||||
|
||||
def transform_response(
|
||||
self, model_response: "ModelResponse", response_format: str | None
|
||||
) -> "HttpxBinaryResponseContent":
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
|
@ -136,23 +166,17 @@ class SpeechToCompletionBridgeTransformationHandler:
|
|||
audio_part: Final = cast(Choices, model_response.choices[0]).message.audio
|
||||
if audio_part is None:
|
||||
raise ValueError("No audio part found in the response")
|
||||
audio_content: Final = audio_part.data
|
||||
decoded_audio: Final = base64.b64decode(audio_part.data)
|
||||
|
||||
# Decode base64 to get binary content
|
||||
binary_data = base64.b64decode(audio_content)
|
||||
|
||||
# Check if this is a Gemini TTS model that returns raw PCM16 data
|
||||
model: Final = getattr(model_response, "model", "")
|
||||
headers: Final = {}
|
||||
if self._is_gemini_tts_model(model):
|
||||
# Convert PCM16 to WAV format for proper audio file playback
|
||||
binary_data = self._convert_pcm16_to_wav(binary_data)
|
||||
headers["Content-Type"] = "audio/wav"
|
||||
else:
|
||||
headers["Content-Type"] = "audio/mpeg"
|
||||
|
||||
# Create an httpx.Response object
|
||||
response: Final = httpx.Response(status_code=200, content=binary_data, headers=headers)
|
||||
content, content_type = (
|
||||
self._gemini_tts_response_body(decoded_audio, response_format)
|
||||
if self._is_gemini_tts_model(model)
|
||||
else (decoded_audio, "audio/mpeg")
|
||||
)
|
||||
response: Final = httpx.Response(
|
||||
status_code=200, content=content, headers=MappingProxyType({"Content-Type": content_type})
|
||||
)
|
||||
binary_response: Final = HttpxBinaryResponseContent(response)
|
||||
binary_response.set_response_cost(_completion_response_cost(model_response))
|
||||
return binary_response
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"""Shadow Eval Logger: samples a shadowed key's successful LLM requests (chat completions,
|
||||
Anthropic Messages, and Responses API surfaces, each normalized to chat shape), duplicates
|
||||
each against the job's other arm in a detached task (the auto-router for a forward job, the
|
||||
fixed baseline model for a reverse one), blind-judges real vs shadow, and appends one
|
||||
``LiteLLM_ShadowEvalAttempt`` row (verdict or error) as the feature's only hot-path write.
|
||||
each through every shadow arm in one detached task (each candidate auto-router for a
|
||||
forward job, the fixed baseline model for a reverse one), blind-judges real vs each arm,
|
||||
and appends one ``LiteLLM_ShadowEvalAttempt`` row per arm (verdict or error) as the
|
||||
feature's only hot-path write. A multi-router job's arms therefore score the identical
|
||||
sampled requests against the identical real responses, which is what makes their win
|
||||
rates comparable head-to-head.
|
||||
Counts, status, and spend derive from those rows at read time, so nothing can disagree
|
||||
across pods or stop races; the hook reads active jobs through a short-TTL cache."""
|
||||
|
||||
|
|
@ -498,12 +501,16 @@ def _decision_classifier_cost(metadata: Mapping[str, object]) -> float:
|
|||
return float(raw) if isinstance(raw, (int, float)) else 0.0
|
||||
|
||||
|
||||
def _request_was_routed_by(request_metadata: Mapping[str, object], router_name: str) -> bool:
|
||||
"""Whether the router under evaluation served this request, which is what decides
|
||||
the direction it belongs to. A forward job skips its own router's traffic, since
|
||||
duplicating it would compare the router to itself: guaranteed ties, judge spend for
|
||||
zero information. A reverse job samples exactly that traffic and nothing else."""
|
||||
return _routing_decision(request_metadata).get("router_model_name") == router_name
|
||||
def _direction_admits(request_metadata: Mapping[str, object], job: "ActiveShadowEvalJob") -> bool:
|
||||
"""Whether this request belongs to the job's direction. A forward job skips traffic
|
||||
any of its candidate routers served: duplicating a router's own request compares it
|
||||
to itself (guaranteed ties), and judging a sibling against another candidate's live
|
||||
response would score candidates against each other instead of against the incumbent.
|
||||
A reverse job samples exactly its one router's traffic and nothing else."""
|
||||
routed_by: Final = _routing_decision(request_metadata).get("router_model_name")
|
||||
if job.direction == "reverse":
|
||||
return routed_by == job.router_name
|
||||
return routed_by not in job.arm_router_names
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -546,6 +553,7 @@ class ActiveShadowEvalJob(BaseModel):
|
|||
|
||||
id: str
|
||||
router_name: str
|
||||
router_names: tuple[str, ...] = ()
|
||||
direction: ShadowEvalDirection = "forward"
|
||||
baseline_model: str | None = None
|
||||
shadow_percentage: float
|
||||
|
|
@ -567,12 +575,25 @@ class ActiveShadowEvalJob(BaseModel):
|
|||
raise ValueError("baseline_model is set for exactly the reverse jobs")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reverse_evaluates_one_router(self) -> "ActiveShadowEvalJob":
|
||||
"""A reverse row naming several routers is unsamplable (there is no one traffic
|
||||
slice they share) and fails closed."""
|
||||
if self.direction == "reverse" and len(self.arm_router_names) > 1:
|
||||
raise ValueError("a reverse job evaluates exactly one router")
|
||||
return self
|
||||
|
||||
@property
|
||||
def shadow_target(self) -> str:
|
||||
"""The model the duplicated arm calls: the router itself for a forward job, the
|
||||
fixed baseline for a reverse one. Total because the validator above pins
|
||||
def arm_router_names(self) -> tuple[str, ...]:
|
||||
"""The job's full router set; rows from before router_names existed hold it in
|
||||
router_name alone. The one place that reading lives on the sampling side."""
|
||||
return self.router_names or (self.router_name,)
|
||||
|
||||
def arm_target(self, arm_router: str) -> str:
|
||||
"""The model one duplicated arm calls: the candidate router itself for a forward
|
||||
job, the fixed baseline for a reverse one. Total because the validator above pins
|
||||
baseline_model to reverse jobs and only those."""
|
||||
return self.baseline_model or self.router_name
|
||||
return self.baseline_model or arm_router
|
||||
|
||||
|
||||
def _as_active_job(record: object, attempts: int, spend: float) -> ActiveShadowEvalJob | None:
|
||||
|
|
@ -696,7 +717,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
now >= job.ends_at
|
||||
or job.attempts + self._job_starts.get(job.id, 0) >= job.max_turns
|
||||
or (job.max_budget is not None and job.spend >= job.max_budget)
|
||||
or _request_was_routed_by(request_metadata, job.router_name) != (job.direction == "reverse")
|
||||
or not _direction_admits(request_metadata, job)
|
||||
):
|
||||
continue
|
||||
if not _sample_hits(request_id, job.id, job.shadow_percentage):
|
||||
|
|
@ -773,7 +794,10 @@ class ShadowEvalLogger(CustomLogger):
|
|||
if self._inflight_shadow_tasks >= _MAX_CONCURRENT_SHADOW_TASKS:
|
||||
self._record_funnel(job.id, "shed")
|
||||
continue
|
||||
self._job_starts[job.id] = self._job_starts.get(job.id, 0) + 1
|
||||
# One start writes one attempt row per arm, and max_turns is a row
|
||||
# ceiling, so admission must pre-count every arm or a multi-router
|
||||
# job overshoots the valve N-fold within a cache generation.
|
||||
self._job_starts[job.id] = self._job_starts.get(job.id, 0) + len(job.arm_router_names)
|
||||
self._inflight_shadow_tasks += 1
|
||||
asyncio.create_task(
|
||||
self._run_shadow_eval(
|
||||
|
|
@ -812,32 +836,74 @@ class ShadowEvalLogger(CustomLogger):
|
|||
shadow_params: Mapping[str, object],
|
||||
parent_metadata: Mapping[str, object],
|
||||
) -> None:
|
||||
"""Budget gate -> shadow call -> blind judge -> one attempt row, and every exit
|
||||
in exactly one coverage bucket: the gates that decline to spend on an admitted
|
||||
sample (no DB to record into, an over-budget key, an unverifiable or exhausted
|
||||
eval budget) count it withheld, so eligible traffic still reconciles as
|
||||
not_sampled + unjudgeable + shed + withheld + attempt rows. The prisma gate sits
|
||||
above the dispatch so no provider spend happens without a place to record the
|
||||
outcome, and the budget read lives here rather than in the success hook."""
|
||||
"""Budget gates once per sampled request, then every router arm in turn: shadow
|
||||
call -> blind judge -> one attempt row stamped with the arm. The gates that
|
||||
decline to spend on an admitted sample (no DB to record into, an over-budget key,
|
||||
an unverifiable or exhausted eval budget) count the REQUEST withheld before any
|
||||
arm runs, so funnel counters stay per-request and a leg's eligible traffic still
|
||||
reconciles as not_sampled + unjudgeable + shed + withheld + sampled requests,
|
||||
where each sampled request writes one attempt row per arm. A budget crossed
|
||||
mid-loop lets the remaining arms overshoot by one round, the same class of
|
||||
overshoot as the samples already in flight when the cap is crossed. The prisma
|
||||
gate sits above the dispatch so no provider spend happens without a place to
|
||||
record the outcome, and the budget read lives here rather than in the success
|
||||
hook."""
|
||||
prisma: Final = self._prisma_provider()
|
||||
if prisma is None:
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if await _key_or_team_is_over_budget(parent_metadata):
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if job.max_budget is not None:
|
||||
try:
|
||||
spend: Final = await self._read_job_spend(_job_spend_counter_key(job.id), job.spend, job.max_budget)
|
||||
except Exception as e: # noqa: BLE001 # unverifiable budget: skip the sample rather than spend on it
|
||||
verbose_logger.warning("shadow_eval: budget unverifiable for %s, sample skipped: %s", job.id, e)
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if spend >= job.max_budget:
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
for arm_router in job.arm_router_names:
|
||||
await self._run_shadow_arm(
|
||||
prisma=prisma,
|
||||
job=job,
|
||||
arm_router=arm_router,
|
||||
request_id=request_id,
|
||||
messages=messages,
|
||||
real_text=real_text,
|
||||
real_model=real_model,
|
||||
real_cost=real_cost,
|
||||
real_classifier_cost=real_classifier_cost,
|
||||
real_cache_hit=real_cache_hit,
|
||||
control_tier=control_tier,
|
||||
shadow_params=shadow_params,
|
||||
parent_metadata=parent_metadata,
|
||||
)
|
||||
|
||||
async def _run_shadow_arm(
|
||||
self,
|
||||
prisma: "PrismaClient",
|
||||
job: ActiveShadowEvalJob,
|
||||
arm_router: str,
|
||||
request_id: str,
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
real_text: str,
|
||||
real_model: str,
|
||||
real_cost: float,
|
||||
real_classifier_cost: float,
|
||||
real_cache_hit: bool,
|
||||
control_tier: str | None,
|
||||
shadow_params: Mapping[str, object],
|
||||
parent_metadata: Mapping[str, object],
|
||||
) -> None:
|
||||
"""One arm's pipeline: shadow call -> blind judge -> one attempt row, every exit
|
||||
recording this arm's outcome, so one arm's fault never silences a sibling arm."""
|
||||
try:
|
||||
if prisma is None:
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if await _key_or_team_is_over_budget(parent_metadata):
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if job.max_budget is not None:
|
||||
try:
|
||||
spend: Final = await self._read_job_spend(_job_spend_counter_key(job.id), job.spend, job.max_budget)
|
||||
except Exception as e: # noqa: BLE001 # unverifiable budget: skip the sample rather than spend on it
|
||||
verbose_logger.warning("shadow_eval: budget unverifiable for %s, sample skipped: %s", job.id, e)
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
if spend >= job.max_budget:
|
||||
self._record_funnel(job.id, "withheld")
|
||||
return
|
||||
shadow: Final = await self._call_router_shadow(job.shadow_target, messages, shadow_params, parent_metadata)
|
||||
shadow: Final = await self._call_router_shadow(
|
||||
job.arm_target(arm_router), messages, shadow_params, parent_metadata
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # detached task: nothing billed yet, record and never raise
|
||||
verbose_logger.debug("shadow_eval: pipeline failed for %s: %s", request_id, e)
|
||||
await self._record_attempt(
|
||||
|
|
@ -845,6 +911,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
job,
|
||||
request_id,
|
||||
control_tier,
|
||||
router_name=arm_router,
|
||||
outcome="error",
|
||||
error=f"pipeline error: {e}",
|
||||
real_cost=real_cost,
|
||||
|
|
@ -858,6 +925,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
job,
|
||||
request_id,
|
||||
control_tier,
|
||||
router_name=arm_router,
|
||||
outcome="error",
|
||||
error=shadow.error,
|
||||
shadow_cost=shadow.cost,
|
||||
|
|
@ -882,6 +950,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
job,
|
||||
request_id,
|
||||
control_tier,
|
||||
router_name=arm_router,
|
||||
outcome="error",
|
||||
error=verdict.error,
|
||||
shadow=shadow,
|
||||
|
|
@ -898,6 +967,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
job,
|
||||
request_id,
|
||||
control_tier,
|
||||
router_name=arm_router,
|
||||
outcome=verdict.preference,
|
||||
shadow=shadow,
|
||||
real_model=real_model,
|
||||
|
|
@ -916,6 +986,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
job,
|
||||
request_id,
|
||||
control_tier,
|
||||
router_name=arm_router,
|
||||
outcome="error",
|
||||
error=f"pipeline error: {e}",
|
||||
shadow=shadow,
|
||||
|
|
@ -933,6 +1004,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
request_id: str,
|
||||
control_tier: str | None,
|
||||
*,
|
||||
router_name: str,
|
||||
outcome: str,
|
||||
real_cost: float,
|
||||
real_classifier_cost: float,
|
||||
|
|
@ -955,6 +1027,7 @@ class ShadowEvalLogger(CustomLogger):
|
|||
data={ # mutable-ok: Prisma payload
|
||||
"job_id": job.id,
|
||||
"request_id": request_id,
|
||||
"router_name": router_name,
|
||||
"outcome": outcome,
|
||||
"tier": control_tier if job.direction == "reverse" else (shadow.tier if shadow else None),
|
||||
"real_model": real_model or None,
|
||||
|
|
|
|||
|
|
@ -416,15 +416,25 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
if not tools:
|
||||
return None
|
||||
|
||||
if call_type in (CallTypes.responses, CallTypes.aresponses):
|
||||
return self._convert_responses_tools(kwargs=kwargs, tools=tools)
|
||||
|
||||
# Check if any tool is a web search tool (native or already LiteLLM standard)
|
||||
has_websearch: Final = any(is_web_search_tool(t) for t in tools)
|
||||
|
||||
is_responses_call: Final = call_type in (CallTypes.responses, CallTypes.aresponses)
|
||||
has_websearch: Final = (
|
||||
any(is_web_search_tool_responses(tool) for tool in tools)
|
||||
if is_responses_call
|
||||
else any(is_web_search_tool(tool) for tool in tools)
|
||||
)
|
||||
if not has_websearch:
|
||||
return None
|
||||
|
||||
if self.search_tool_name:
|
||||
try:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
except ImportError:
|
||||
llm_router = None
|
||||
self._select_search_tool_from_router(llm_router=llm_router)
|
||||
|
||||
if is_responses_call:
|
||||
return self._convert_responses_tools(kwargs=kwargs, tools=tools)
|
||||
|
||||
verbose_logger.debug("WebSearchInterception: Converting native web_search tools to LiteLLM standard")
|
||||
|
||||
# If the client sent an Anthropic-native web_search_* tool, mark the
|
||||
|
|
@ -1631,9 +1641,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
return None
|
||||
|
||||
def _select_search_tool_from_router(self, llm_router: object) -> "_SearchToolConfig | None":
|
||||
if llm_router is None or not hasattr(llm_router, "search_tools"):
|
||||
return None
|
||||
search_tools: Final = list(getattr(llm_router, "search_tools") or [])
|
||||
search_tools: Final = list(getattr(llm_router, "search_tools", []) or [])
|
||||
return self._select_search_tool_from_list(search_tools=search_tools, source="router")
|
||||
|
||||
def _select_search_tool_from_list(
|
||||
|
|
@ -1643,20 +1651,26 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
) -> "_SearchToolConfig | None":
|
||||
if self.search_tool_name:
|
||||
matching_tools = [tool for tool in search_tools if tool.get("search_tool_name") == self.search_tool_name]
|
||||
if matching_tools:
|
||||
search_provider = (matching_tools[0].get("litellm_params", {}) or {}).get("search_provider")
|
||||
verbose_logger.debug(
|
||||
"WebSearchInterception: Found search tool '%s' from %s with provider '%s'",
|
||||
self.search_tool_name,
|
||||
source,
|
||||
search_provider,
|
||||
if not matching_tools:
|
||||
raise ValueError(f"Configured search tool '{self.search_tool_name}' was not found")
|
||||
|
||||
selected_tool: Final = matching_tools[0]
|
||||
litellm_params: Final = selected_tool.get("litellm_params")
|
||||
selected_search_provider: Final = (
|
||||
litellm_params.get("search_provider") if isinstance(litellm_params, Mapping) else None
|
||||
)
|
||||
if not isinstance(selected_search_provider, str) or not selected_search_provider.strip():
|
||||
raise ValueError(
|
||||
f"Configured search tool '{self.search_tool_name}' does not define a valid search provider"
|
||||
)
|
||||
return matching_tools[0]
|
||||
|
||||
verbose_logger.debug(
|
||||
"WebSearchInterception: Search tool '%s' not found in %s, falling back to first available or perplexity",
|
||||
"WebSearchInterception: Found search tool '%s' from %s with provider '%s'",
|
||||
self.search_tool_name,
|
||||
source,
|
||||
selected_search_provider,
|
||||
)
|
||||
return selected_tool
|
||||
|
||||
if search_tools:
|
||||
first_tool: Final = search_tools[0]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import os
|
|||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.files import get_file_mime_type_from_extension
|
||||
from litellm.types.files import (
|
||||
AUDIO_FILE_TYPES,
|
||||
FILE_EXTENSIONS,
|
||||
FILE_MIME_TYPES,
|
||||
FileType,
|
||||
get_file_mime_type_from_extension,
|
||||
)
|
||||
from litellm.types.utils import FileTypes
|
||||
|
||||
|
||||
|
|
@ -323,3 +329,75 @@ def calculate_request_duration(file: FileTypes) -> float | None:
|
|||
except Exception:
|
||||
# Silently fail if duration extraction fails
|
||||
return None
|
||||
|
||||
|
||||
DEFAULT_SPEECH_MEDIA_TYPE: Final = "audio/mpeg"
|
||||
|
||||
|
||||
def _speech_media_type_for_response_format(response_format: str) -> str | None:
|
||||
file_type: Final = next(
|
||||
(candidate for candidate, extensions in FILE_EXTENSIONS.items() if response_format.lower() in extensions),
|
||||
None,
|
||||
)
|
||||
if file_type is None or file_type not in AUDIO_FILE_TYPES:
|
||||
return None
|
||||
return FILE_MIME_TYPES[file_type]
|
||||
|
||||
|
||||
def resolve_speech_media_type(upstream_content_type: str | None, response_format: str | None) -> str:
|
||||
upstream_media_type: Final = (upstream_content_type or "").split(";", 1)[0].strip().lower()
|
||||
if upstream_media_type.startswith("audio/"):
|
||||
return upstream_media_type
|
||||
requested_media_type: Final = (
|
||||
None if response_format is None else _speech_media_type_for_response_format(response_format)
|
||||
)
|
||||
return requested_media_type or DEFAULT_SPEECH_MEDIA_TYPE
|
||||
|
||||
|
||||
_OGG_OPUS_HEAD_WINDOW: Final = 64
|
||||
_ADTS_SYNC_AND_LAYER_MASK: Final = 0xF6
|
||||
_ADTS_SYNC_AND_LAYER: Final = 0xF0
|
||||
_ADTS_SAMPLE_RATE_INDEX_LIMIT: Final = 13
|
||||
_MPEG_SYNC_MASK: Final = 0xE0
|
||||
_MPEG_LAYER_MASK: Final = 0x06
|
||||
_MPEG_RESERVED_VERSION: Final = 0x01
|
||||
_MPEG_INVALID_BITRATE_INDEX: Final = 0x0F
|
||||
_MPEG_RESERVED_SAMPLE_RATE_INDEX: Final = 0x03
|
||||
|
||||
|
||||
def _adts_aac_frame_media_type(header: bytes) -> str | None:
|
||||
sample_rate_index: Final = (header[2] >> 2) & 0x0F
|
||||
return FILE_MIME_TYPES[FileType.AAC] if sample_rate_index < _ADTS_SAMPLE_RATE_INDEX_LIMIT else None
|
||||
|
||||
|
||||
def _mpeg_audio_frame_media_type(header: bytes) -> str | None:
|
||||
version: Final = (header[1] >> 3) & 0x03
|
||||
layer: Final = header[1] & _MPEG_LAYER_MASK
|
||||
bitrate_index: Final = header[2] >> 4
|
||||
sample_rate_index: Final = (header[2] >> 2) & 0x03
|
||||
if (
|
||||
(header[1] & _MPEG_SYNC_MASK) != _MPEG_SYNC_MASK
|
||||
or version == _MPEG_RESERVED_VERSION
|
||||
or layer == 0
|
||||
or bitrate_index == _MPEG_INVALID_BITRATE_INDEX
|
||||
or sample_rate_index == _MPEG_RESERVED_SAMPLE_RATE_INDEX
|
||||
):
|
||||
return None
|
||||
return FILE_MIME_TYPES[FileType.MP3]
|
||||
|
||||
|
||||
def speech_media_type_from_audio_bytes(audio: bytes) -> str | None:
|
||||
if audio[:4] == b"RIFF" and audio[8:12] == b"WAVE":
|
||||
return FILE_MIME_TYPES[FileType.WAV]
|
||||
if audio[:4] == b"fLaC":
|
||||
return FILE_MIME_TYPES[FileType.FLAC]
|
||||
if audio[:4] == b"OggS":
|
||||
is_opus: Final = b"OpusHead" in audio[:_OGG_OPUS_HEAD_WINDOW]
|
||||
return FILE_MIME_TYPES[FileType.OPUS if is_opus else FileType.OGG]
|
||||
if audio[:3] == b"ID3":
|
||||
return FILE_MIME_TYPES[FileType.MP3]
|
||||
if len(audio) < 3 or audio[0] != 0xFF:
|
||||
return None
|
||||
if (audio[1] & _ADTS_SYNC_AND_LAYER_MASK) == _ADTS_SYNC_AND_LAYER:
|
||||
return _adts_aac_frame_media_type(audio)
|
||||
return _mpeg_audio_frame_media_type(audio)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,41 @@ def is_non_content_values_set(message: AllMessageValues) -> bool:
|
|||
return any(message.get(key, None) is not None for key in message if key not in ignore_keys)
|
||||
|
||||
|
||||
_IMAGE_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_image", "image"})
|
||||
_IMAGE_SCAN_MAX_DEPTH: Final = 4
|
||||
|
||||
|
||||
def _content_parts_contain_image(parts: Sequence[object]) -> bool:
|
||||
"""Depth-bounded frontier walk over nested content lists, iterative because the repo bans
|
||||
recursion; an Anthropic tool_result nests its image parts exactly one level down."""
|
||||
frontier = parts # rebind-ok: depth-bounded frontier walk
|
||||
for _ in range(_IMAGE_SCAN_MAX_DEPTH):
|
||||
if any(isinstance(part, Mapping) and part.get("type") in _IMAGE_CONTENT_PART_TYPES for part in frontier):
|
||||
return True
|
||||
frontier = tuple( # rebind-ok: depth-bounded frontier walk
|
||||
nested
|
||||
for part in frontier
|
||||
if isinstance(part, Mapping)
|
||||
for content in (part.get("content"),)
|
||||
if isinstance(content, list)
|
||||
for nested in content
|
||||
)
|
||||
if not frontier:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def request_contains_image_content(messages: Sequence[Mapping[str, object]]) -> bool:
|
||||
"""Whether any message carries an image content part, across the dialects that reach
|
||||
pre-routing hooks untranslated: chat-completions ``image_url``, Responses ``input_image``,
|
||||
and Anthropic Messages ``image``, including images nested inside ``tool_result`` blocks."""
|
||||
return any(
|
||||
isinstance(content, list) and _content_parts_contain_image(content)
|
||||
for message in messages
|
||||
for content in (message.get("content"),)
|
||||
)
|
||||
|
||||
|
||||
def _audio_or_image_in_message_content(message: AllMessageValues) -> bool:
|
||||
"""
|
||||
Checks if message content contains an image or audio
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/s
|
|||
|
||||
import base64
|
||||
from collections.abc import Coroutine
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Union
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.audio_utils.utils import (
|
||||
speech_media_type_from_audio_bytes,
|
||||
)
|
||||
from litellm.llms.base_llm.text_to_speech.transformation import (
|
||||
BaseTextToSpeechConfig,
|
||||
TextToSpeechRequestData,
|
||||
|
|
@ -457,12 +461,11 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
|
|||
if not response_content:
|
||||
raise ValueError("No audioContent in Vertex AI TTS response")
|
||||
|
||||
# Decode base64 to get binary content
|
||||
binary_data: Final = base64.b64decode(response_content)
|
||||
|
||||
# Create an httpx.Response object with the binary data
|
||||
media_type: Final = speech_media_type_from_audio_bytes(binary_data)
|
||||
response: Final = httpx.Response(
|
||||
status_code=200,
|
||||
headers=None if media_type is None else MappingProxyType({"content-type": media_type}),
|
||||
content=binary_data,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
Handles Authentication Errors
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._logging import verbose_proxy_logger, verbose_proxy_stdout_logger
|
||||
from litellm.constants import EMPTY_MAPPING
|
||||
from litellm.integrations.otel.runtime import seed_request_identity
|
||||
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
|
||||
|
|
@ -18,7 +19,11 @@ from litellm.proxy._types import (
|
|||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import _get_request_ip_address
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
_get_request_ip_address,
|
||||
is_invalid_virtual_key_error,
|
||||
mark_invalid_virtual_key_error,
|
||||
)
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.types.services import ServiceTypes
|
||||
|
||||
|
|
@ -36,6 +41,41 @@ else:
|
|||
Span = Any
|
||||
|
||||
|
||||
def _as_proxy_exception(e: Exception) -> ProxyException:
|
||||
"""Convert an authentication failure into the ProxyException the client receives."""
|
||||
if isinstance(e, litellm.BudgetExceededError):
|
||||
return ProxyException(
|
||||
message=e.message,
|
||||
type=ProxyErrorTypes.budget_exceeded,
|
||||
param=None,
|
||||
code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS),
|
||||
)
|
||||
if isinstance(e, HTTPException):
|
||||
return ProxyException(
|
||||
message=getattr(e, "detail", f"Authentication Error({e})"),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_401_UNAUTHORIZED),
|
||||
)
|
||||
if isinstance(e, ProxyException):
|
||||
return e
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
|
||||
return ProxyException(
|
||||
message=(
|
||||
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
|
||||
),
|
||||
type=ProxyErrorTypes.no_db_connection,
|
||||
param="None",
|
||||
code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return ProxyException(
|
||||
message="Authentication Error, " + str(e),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
def _with_requester_ip_address(request_data: dict[str, object], requester_ip: str | None) -> dict[str, object]:
|
||||
"""Auth gate rejections are raised before `add_litellm_data_to_request` records the
|
||||
caller IP, so their failure logs would otherwise carry no IP nor key/user identity."""
|
||||
|
|
@ -110,16 +150,21 @@ class UserAPIKeyAuthExceptionHandler:
|
|||
request=request,
|
||||
use_x_forwarded_for=general_settings.get("use_x_forwarded_for") is True,
|
||||
)
|
||||
log_fn: Final = (
|
||||
verbose_proxy_logger.error
|
||||
if is_expected_client_error(e) and not litellm.log_client_error_tracebacks
|
||||
else verbose_proxy_logger.exception
|
||||
)
|
||||
log_fn(
|
||||
|
||||
# Log authentication failures before identity seeding and callbacks, so the log
|
||||
# survives a raising callback pipeline. Classify and route malformed virtual-key
|
||||
# rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR).
|
||||
log_extra: Final = {"requester_ip": requester_ip}
|
||||
is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e)
|
||||
is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks
|
||||
logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger
|
||||
logger.log(
|
||||
logging.WARNING if is_quiet_log else logging.ERROR,
|
||||
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s",
|
||||
e,
|
||||
requester_ip,
|
||||
extra={"requester_ip": requester_ip},
|
||||
exc_info=True if litellm.log_client_error_tracebacks or not is_expected_client_error(e) else None,
|
||||
extra=log_extra,
|
||||
)
|
||||
|
||||
# Log this exception to OTEL, Datadog etc. Reuse the identity resolved
|
||||
|
|
@ -167,35 +212,13 @@ class UserAPIKeyAuthExceptionHandler:
|
|||
if transformed_exception is not None:
|
||||
e = transformed_exception
|
||||
|
||||
if isinstance(e, litellm.BudgetExceededError):
|
||||
raise ProxyException(
|
||||
message=e.message,
|
||||
type=ProxyErrorTypes.budget_exceeded,
|
||||
param=None,
|
||||
code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS),
|
||||
final_exception: Final = mark_invalid_virtual_key_error(_as_proxy_exception(e), is_invalid_virtual_key)
|
||||
# If a quiet-logged malformed-key transform yields non-401, escalate to ERROR
|
||||
if is_quiet_log and str(final_exception.code) != str(status.HTTP_401_UNAUTHORIZED):
|
||||
verbose_proxy_logger.error(
|
||||
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s",
|
||||
final_exception,
|
||||
requester_ip,
|
||||
extra=log_extra,
|
||||
)
|
||||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "detail", f"Authentication Error({e})"),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_401_UNAUTHORIZED),
|
||||
)
|
||||
elif isinstance(e, ProxyException):
|
||||
raise e
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
|
||||
raise ProxyException(
|
||||
message=(
|
||||
"Service Unavailable, the authentication database is "
|
||||
"temporarily unreachable. Please retry shortly."
|
||||
),
|
||||
type=ProxyErrorTypes.no_db_connection,
|
||||
param="None",
|
||||
code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
raise ProxyException(
|
||||
message="Authentication Error, " + str(e),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
raise final_exception
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.constants import (
|
||||
BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY,
|
||||
EMPTY_MAPPING,
|
||||
INVALID_VIRTUAL_KEY_ERROR_MARKER,
|
||||
MINIMUM_CUSTOM_KEY_LENGTH,
|
||||
STANDARD_CUSTOMER_ID_HEADERS,
|
||||
)
|
||||
|
|
@ -34,6 +35,43 @@ from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
|
|||
from litellm.types.utils import CustomPricingLiteLLMParams
|
||||
|
||||
|
||||
def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
|
||||
"""True when an authentication error rejects a malformed virtual key.
|
||||
|
||||
Classifies only by the marker stamped where that 401 is raised. Message
|
||||
content is never inspected: other 401s interpolate caller-supplied values
|
||||
(vector store ids, organization ids) into their messages, so a phrase
|
||||
match would let a request body demote an authorization failure to the
|
||||
quiet log path.
|
||||
"""
|
||||
if not isinstance(exception, (HTTPException, ProxyException)):
|
||||
return False
|
||||
|
||||
code: Final[object] = getattr(exception, "code", None)
|
||||
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
|
||||
if str(status_code) != str(status.HTTP_401_UNAUTHORIZED):
|
||||
return False
|
||||
|
||||
return getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True
|
||||
|
||||
|
||||
def mark_invalid_virtual_key_error(exception: ProxyException, is_invalid_virtual_key: bool) -> ProxyException:
|
||||
"""Return an independently marked malformed-key exception after callback transformations."""
|
||||
if not is_invalid_virtual_key or str(exception.code) != str(status.HTTP_401_UNAUTHORIZED):
|
||||
return exception
|
||||
marked_exception: Final = ProxyException(
|
||||
message=exception.message,
|
||||
type=exception.type,
|
||||
param=exception.param,
|
||||
code=exception.code,
|
||||
headers=exception.headers.copy(),
|
||||
openai_code=None if exception.openai_code is None else str(exception.openai_code),
|
||||
provider_specific_fields=exception.provider_specific_fields,
|
||||
)
|
||||
setattr(marked_exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, True)
|
||||
return marked_exception
|
||||
|
||||
|
||||
def _get_request_ip_address(request: Request, use_x_forwarded_for: bool | None = False) -> str | None:
|
||||
client_ip = None
|
||||
if use_x_forwarded_for is True and "x-forwarded-for" in request.headers:
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ import fastapi
|
|||
import orjson
|
||||
from fastapi import HTTPException, Request, WebSocket, status
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
from starlette.exceptions import WebSocketException
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger, verbose_proxy_logger
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm.constants import (
|
||||
GLOBAL_PROXY_SPEND_CACHE_KEY,
|
||||
INVALID_VIRTUAL_KEY_ERROR_MARKER,
|
||||
INVALID_VIRTUAL_KEY_ERROR_MESSAGE,
|
||||
LITELLM_PROXY_BUDGET_NAME,
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS,
|
||||
)
|
||||
|
|
@ -65,6 +68,7 @@ from litellm.proxy.auth.auth_utils import (
|
|||
get_model_from_request,
|
||||
get_request_route,
|
||||
get_request_route_template,
|
||||
is_invalid_virtual_key_error,
|
||||
iter_request_fallback_targets,
|
||||
normalize_request_route,
|
||||
pre_db_read_auth_checks,
|
||||
|
|
@ -539,6 +543,8 @@ async def user_api_key_auth_websocket(websocket: WebSocket):
|
|||
try:
|
||||
return await user_api_key_auth(request=request, api_key=f"Bearer {api_key}")
|
||||
except Exception as e:
|
||||
if is_invalid_virtual_key_error(e):
|
||||
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
|
||||
verbose_proxy_logger.exception(e)
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
|
@ -1867,13 +1873,17 @@ async def _user_api_key_auth_builder(
|
|||
_masked_key: Final = f"{api_key[:4]}****{api_key[-4:]}" if len(api_key) > 8 else "****"
|
||||
if not api_key.startswith("sk-"):
|
||||
_hint = _JWT_AUTH_DISABLED_HINT if not enable_jwt_auth and JWTHandler.is_jwt(token=api_key) else ""
|
||||
raise HTTPException(
|
||||
_malformed_key_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=(
|
||||
f"LiteLLM Virtual Key expected. Received={_masked_key}, "
|
||||
f"{INVALID_VIRTUAL_KEY_ERROR_MESSAGE}. Received={_masked_key}, "
|
||||
f"expected to start with 'sk-'.{_hint}"
|
||||
),
|
||||
) # prevent token hashes from being used
|
||||
# Stamp provenance here so log routing classifies this 401 by
|
||||
# where it was raised, never by its message text.
|
||||
setattr(_malformed_key_error, INVALID_VIRTUAL_KEY_ERROR_MARKER, True)
|
||||
raise _malformed_key_error
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"litellm.proxy.proxy_server.user_api_key_auth(): Warning - Key is not a string. Got type={}".format(
|
||||
|
|
|
|||
|
|
@ -833,7 +833,7 @@ def _judge_collisions_for_team(
|
|||
return tuple(
|
||||
(role, model)
|
||||
for role, model in (
|
||||
*_router_arm_models(llm_router, data.router_name),
|
||||
*(arm for name in data.router_names for arm in _router_arm_models(llm_router, name)),
|
||||
*((("baseline", data.baseline_model),) if data.baseline_model is not None else ()),
|
||||
)
|
||||
if judge & judge_target(llm_router, model, team_id).models
|
||||
|
|
@ -904,7 +904,7 @@ class _AttemptAggRow(BaseModel):
|
|||
|
||||
_ATTEMPT_AGG_ROWS: Final = TypeAdapter(list[_AttemptAggRow])
|
||||
|
||||
_ATTEMPT_AGG_SELECT: Final = """
|
||||
_ATTEMPT_AGG_COLUMNS: Final = """
|
||||
COUNT(*)::int AS turn_count,
|
||||
COUNT(*) FILTER (WHERE outcome = 'real')::int AS real_wins,
|
||||
COUNT(*) FILTER (WHERE outcome = 'shadow')::int AS shadow_wins,
|
||||
|
|
@ -913,15 +913,34 @@ _ATTEMPT_AGG_SELECT: Final = """
|
|||
COALESCE(SUM(real_cost + real_classifier_cost) FILTER (WHERE real_cost IS NOT NULL AND NOT real_cache_hit), 0)::float AS real_spend,
|
||||
COALESCE(SUM(shadow_cost + shadow_classifier_cost) FILTER (WHERE real_cost IS NOT NULL AND NOT real_cache_hit), 0)::float AS shadow_spend,
|
||||
COUNT(*) FILTER (WHERE real_cache_hit)::int AS cache_hit_turns
|
||||
"""
|
||||
|
||||
_ATTEMPT_AGG_SELECT: Final = (
|
||||
_ATTEMPT_AGG_COLUMNS
|
||||
+ """
|
||||
FROM "LiteLLM_ShadowEvalAttempt"
|
||||
WHERE job_id = ANY($1::text[]) AND outcome != 'error'
|
||||
GROUP BY 1
|
||||
"""
|
||||
)
|
||||
|
||||
_ATTEMPT_AGG_BY_TIER_SQL: Final = "SELECT COALESCE(tier, 'UNCLASSIFIED') AS grp," + _ATTEMPT_AGG_SELECT
|
||||
_ATTEMPT_AGG_BY_MODEL_SQL: Final = "SELECT COALESCE(real_model, 'unknown') AS grp," + _ATTEMPT_AGG_SELECT
|
||||
_ATTEMPT_AGG_BY_LEG_SQL: Final = "SELECT job_id AS grp," + _ATTEMPT_AGG_SELECT
|
||||
|
||||
# Attempt rows from before arm stamping carry no router_name; they belong to the job's
|
||||
# own router, which the join reads off the leg.
|
||||
_ATTEMPT_AGG_BY_ROUTER_SQL: Final = (
|
||||
"SELECT COALESCE(a.router_name, j.router_name) AS grp,"
|
||||
+ _ATTEMPT_AGG_COLUMNS
|
||||
+ """
|
||||
FROM "LiteLLM_ShadowEvalAttempt" a
|
||||
JOIN "LiteLLM_ShadowEvalJob" j ON j.id = a.job_id
|
||||
WHERE a.job_id = ANY($1::text[]) AND a.outcome != 'error'
|
||||
GROUP BY 1
|
||||
"""
|
||||
)
|
||||
|
||||
# These guards derive spend from attempt rows, the cross-pod authority; the sampler also
|
||||
# reads the live counter, so admission can stop before a row-based guard would fire (safe
|
||||
# direction, and mid-deploy rows from old pods price as judge-only until the deploy ends).
|
||||
|
|
@ -1060,6 +1079,7 @@ class _LegRow(BaseModel):
|
|||
target_type: ShadowEvalTargetType
|
||||
target_id: str
|
||||
router_name: str
|
||||
router_names: tuple[str, ...] = ()
|
||||
direction: ShadowEvalDirection
|
||||
baseline_model: str | None = None
|
||||
judge_model: str
|
||||
|
|
@ -1071,6 +1091,12 @@ class _LegRow(BaseModel):
|
|||
stopped_at: datetime | None = None
|
||||
stopped_by: str | None = None
|
||||
|
||||
@property
|
||||
def arm_router_names(self) -> tuple[str, ...]:
|
||||
"""The job's full router set; rows from before router_names existed hold it in
|
||||
router_name alone. The one place that reading lives on the endpoint side."""
|
||||
return self.router_names or (self.router_name,)
|
||||
|
||||
@field_validator("created_at", "ends_at", "stopped_at")
|
||||
@classmethod
|
||||
def _as_aware_utc(cls, value: datetime | None) -> datetime | None:
|
||||
|
|
@ -1123,7 +1149,7 @@ def _group_response(
|
|||
)
|
||||
for leg in sorted(legs, key=lambda leg: (leg.target_type, leg.target_id))
|
||||
),
|
||||
router_name=first.router_name,
|
||||
router_names=first.arm_router_names,
|
||||
direction=first.direction,
|
||||
baseline_model=first.baseline_model,
|
||||
judge_model=first.judge_model,
|
||||
|
|
@ -1252,6 +1278,9 @@ async def _shadow_eval_results(
|
|||
for slice in _slices(by_leg)
|
||||
}
|
||||
)
|
||||
by_router: Final = _ATTEMPT_AGG_ROWS.validate_python(
|
||||
await _query_raw(prisma_client, _ATTEMPT_AGG_BY_ROUTER_SQL, leg_ids) or ()
|
||||
)
|
||||
total_turns: Final = sum(r.turn_count for r in by_tier)
|
||||
funnel_rows: Final = await _query_raw(prisma_client, _FUNNEL_TOTALS_SQL, leg_ids)
|
||||
counted: Final = _FunnelTotalsRow.model_validate(funnel_rows[0]) if funnel_rows else None
|
||||
|
|
@ -1261,6 +1290,7 @@ async def _shadow_eval_results(
|
|||
result: Final = ShadowEvalResult(
|
||||
by_tier=_slices(by_tier),
|
||||
by_current_model=_slices(by_model),
|
||||
by_router=_slices(by_router),
|
||||
overall_shadow_win_rate_pct=_pct_of(sum(r.shadow_wins for r in by_tier), total_turns),
|
||||
overall_tie_rate_pct=_pct_of(sum(r.ties for r in by_tier), total_turns),
|
||||
sampled_real_spend=sum(r.real_spend for r in by_tier),
|
||||
|
|
@ -1314,8 +1344,15 @@ async def start_shadow_eval(
|
|||
_require_admin_writer(user_api_key_dict, "start a shadow eval")
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
if llm_router is None or not _is_configured_pre_routing_strategy(llm_router, data.router_name):
|
||||
raise HTTPException(status_code=400, detail=f"'{data.router_name}' is not a configured auto-router")
|
||||
unconfigured: Final = tuple(
|
||||
name
|
||||
for name in data.router_names
|
||||
if llm_router is None or not _is_configured_pre_routing_strategy(llm_router, name)
|
||||
)
|
||||
if unconfigured:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Not a configured auto-router: {', '.join(repr(n) for n in unconfigured)}"
|
||||
)
|
||||
token_rows: Final = (
|
||||
await _verification_tokens(prisma_client).find_many(
|
||||
where={"token": {"in": list(data.api_key_ids)}} # mutable-ok: Prisma filter
|
||||
|
|
@ -1416,7 +1453,9 @@ async def start_shadow_eval(
|
|||
ends_at: Final = now + timedelta(days=data.duration_days)
|
||||
shared_config: Final = { # mutable-ok: Prisma payload
|
||||
"group_id": group_id,
|
||||
"router_name": data.router_name,
|
||||
# a pre-router_names pod samples router_name alone, so it must be a real arm
|
||||
"router_name": data.router_names[0],
|
||||
"router_names": list(data.router_names), # mutable-ok: Prisma payload
|
||||
"direction": data.direction,
|
||||
"baseline_model": data.baseline_model,
|
||||
"judge_model": data.judge_model,
|
||||
|
|
@ -1477,7 +1516,7 @@ async def start_shadow_eval(
|
|||
)
|
||||
for target_type, target_id in sorted(requested_targets)
|
||||
),
|
||||
router_name=data.router_name,
|
||||
router_names=data.router_names,
|
||||
direction=data.direction,
|
||||
baseline_model=data.baseline_model,
|
||||
judge_model=data.judge_model,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import secrets
|
|||
import traceback
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypeVar, cast
|
||||
|
||||
import fastapi
|
||||
|
|
@ -111,6 +112,7 @@ from litellm.proxy.management_helpers.team_member_permission_checks import (
|
|||
TeamMemberPermissionChecks,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
|
||||
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
get_ui_settings_cached,
|
||||
|
|
@ -3578,6 +3580,63 @@ async def _build_model_max_budget_usage(
|
|||
)
|
||||
|
||||
|
||||
def _window_max_budget(window: Mapping[str, object]) -> float | None:
|
||||
"""A window's max_budget as a float; None when absent or unparseable."""
|
||||
value: Final = window.get("max_budget")
|
||||
if not isinstance(value, (int, float, str)):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def _budget_window_usage(
|
||||
window: Mapping[str, object], api_key_hash: str
|
||||
) -> tuple[str, Mapping[str, object]] | None:
|
||||
"""
|
||||
(budget_duration, usage entry) for one budget window; None when the window
|
||||
has no budget_duration to key it by.
|
||||
|
||||
Reads the same cross-pod counter (spend:key:{hashed_token}:window:{budget_duration})
|
||||
that _virtual_key_multi_budget_check enforces against, passing the same
|
||||
window_duration + window_start so a stale-low counter is re-checked against
|
||||
the LiteLLM_BudgetWindowSpend row instead of a spend-log aggregate.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
duration: Final = window.get("budget_duration")
|
||||
if not isinstance(duration, str) or not duration:
|
||||
return None
|
||||
spend: Final = await get_current_spend(
|
||||
counter_key=f"spend:key:{api_key_hash}:window:{duration}",
|
||||
fallback_spend=0.0,
|
||||
max_budget=_window_max_budget(window),
|
||||
window_entity_type="Key",
|
||||
window_entity_id=api_key_hash,
|
||||
window_duration=duration,
|
||||
window_start=get_budget_window_start(window),
|
||||
)
|
||||
return duration, MappingProxyType({"current_spend": round(spend, 4)})
|
||||
|
||||
|
||||
async def _build_budget_limits_usage(
|
||||
budget_limits: Sequence[object] | str | None, api_key_hash: str
|
||||
) -> Mapping[str, Mapping[str, object]] | None:
|
||||
"""
|
||||
Current-window spend per budget window, keyed by budget_duration, reported
|
||||
next to the stored budget_limits (which is returned untouched). None when
|
||||
the key has no windows, so the field only appears on keys that have them.
|
||||
"""
|
||||
windows: Final = _budget_limit_windows(budget_limits)
|
||||
if not windows:
|
||||
return None
|
||||
usages: Final = await asyncio.gather(
|
||||
*(_budget_window_usage(window=window, api_key_hash=api_key_hash) for window in windows)
|
||||
)
|
||||
return MappingProxyType({duration: usage for duration, usage in (u for u in usages if u is not None)})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/v2/key/info",
|
||||
tags=["key management"],
|
||||
|
|
@ -3620,7 +3679,6 @@ async def info_key_fn_v2(
|
|||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"message": "Malformed request. No keys passed in."},
|
||||
)
|
||||
|
||||
# Resolve key_aliases to tokens so we never pass token=None (unbounded query)
|
||||
tokens_to_query: Final = list(data.keys) if data.keys else []
|
||||
if data.key_aliases:
|
||||
|
|
@ -3662,6 +3720,13 @@ async def info_key_fn_v2(
|
|||
model_max_budget=model_max_budget,
|
||||
user_api_key_cache=model_max_budget_limiter.dual_cache,
|
||||
)
|
||||
if k_token_hash:
|
||||
budget_limits_usage = await _build_budget_limits_usage(
|
||||
budget_limits=k_dict.get("budget_limits"),
|
||||
api_key_hash=k_token_hash,
|
||||
)
|
||||
if budget_limits_usage is not None:
|
||||
k_dict["budget_limits_usage"] = budget_limits_usage
|
||||
|
||||
filtered_key_info.append(k_dict)
|
||||
return {"key": data.keys, "info": filtered_key_info}
|
||||
|
|
@ -3698,6 +3763,10 @@ async def info_key_fn(
|
|||
- model_max_budget: dict - Per-model budgets, e.g. {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
|
||||
- model_max_budget_usage: dict | None - Current-window spend per model, present only when
|
||||
the key has per-model budgets
|
||||
- budget_limits: list | None - Concurrent budget windows, exactly as stored
|
||||
- budget_limits_usage: dict | None - Current-window spend per budget window, e.g.
|
||||
{"1h": {"current_spend": 0.0009}}, present only when the key has budget windows
|
||||
(read from the same cross-pod spend counter the budget enforcement uses)
|
||||
- models: list - Model_name's the key is allowed to call
|
||||
- tpm_limit / rpm_limit: int | None - Tokens and requests per minute limits
|
||||
- metadata: dict - Metadata for the key, e.g. {"team": "core-infra"}
|
||||
|
|
@ -3777,6 +3846,12 @@ async def info_key_fn(
|
|||
model_max_budget=model_max_budget,
|
||||
user_api_key_cache=model_max_budget_limiter.dual_cache,
|
||||
)
|
||||
budget_limits_usage: Final = await _build_budget_limits_usage(
|
||||
budget_limits=key_info.get("budget_limits"),
|
||||
api_key_hash=key_token_hash,
|
||||
)
|
||||
if budget_limits_usage is not None:
|
||||
key_info["budget_limits_usage"] = budget_limits_usage
|
||||
|
||||
# Attach object_permission if object_permission_id is set
|
||||
key_info = await attach_object_permission_to_dict(key_info, prisma_client)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import posixpath
|
|||
import traceback
|
||||
from base64 import b64encode
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
|
@ -47,6 +48,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
get_metadata_variable_name_from_kwargs,
|
||||
get_or_create_metadata_bucket,
|
||||
)
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import validate_no_callback_env_reference
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
|
@ -78,7 +80,10 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
wrap_passthrough_sse_bytes_with_keepalive_pings,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
LiteLLMProxyRequestSetup,
|
||||
_get_dynamic_logging_metadata, # pyright: ignore[reportPrivateUsage] # shared proxy helper, same import style as _read_request_body above
|
||||
)
|
||||
from litellm.proxy.utils import normalize_route_for_root_path
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
|
@ -90,7 +95,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
|||
EndpointType,
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, Usage
|
||||
|
||||
from .streaming_handler import PassThroughStreamingHandler
|
||||
from .success_handler import PassThroughEndpointLogging
|
||||
|
|
@ -99,6 +104,9 @@ from .upstream_usage_headers import (
|
|||
apply_upstream_reported_usage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
pass_through_endpoint_logging: Final = PassThroughEndpointLogging()
|
||||
|
|
@ -752,6 +760,67 @@ def _build_passthrough_failure_request_payload(
|
|||
return request_payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TeamCallbackWiring:
|
||||
success_callbacks: "list[str | Callable | CustomLogger] | None" = None # mutable-ok: Logging.__init__ arg
|
||||
failure_callbacks: "list[str | Callable | CustomLogger] | None" = None # mutable-ok: Logging.__init__ arg
|
||||
logging_kwargs: dict[str, str | dict[str, str]] | None = None # mutable-ok: Logging.__init__ arg
|
||||
|
||||
|
||||
def _resolve_team_callback_wiring(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_config: "ProxyConfig",
|
||||
route_description: str,
|
||||
) -> _TeamCallbackWiring:
|
||||
"""Resolve key/team dynamic logging callbacks for a passthrough request.
|
||||
|
||||
Mirrors add_litellm_data_to_request: callback_vars are unpacked top-level
|
||||
(read by initialize_standard_callback_dynamic_params) and also stamped on
|
||||
the proxy-owned trusted-vars field (read by get_trusted_callback_params).
|
||||
|
||||
Fails open: a callback resolution or validation error is logged at error
|
||||
level and the request proceeds without dynamic callbacks, since a broken
|
||||
logging config must not fail the customer's upstream call (and the
|
||||
websocket is already accepted by the time this runs on that path). The
|
||||
env-reference check runs here because the deprecated callback_settings
|
||||
branch skips AddTeamCallback validation, and Logging.__init__ would
|
||||
otherwise reject the vars mid-request.
|
||||
"""
|
||||
try:
|
||||
callback_settings_obj: Final = _get_dynamic_logging_metadata(
|
||||
user_api_key_dict=user_api_key_dict, proxy_config=proxy_config
|
||||
)
|
||||
if callback_settings_obj and callback_settings_obj.callback_vars:
|
||||
for (
|
||||
item
|
||||
) in callback_settings_obj.callback_vars.items(): # rebind-ok: dict.items iteration for env-ref validation
|
||||
validate_no_callback_env_reference(item[0], item[1], source="key/team callback metadata")
|
||||
except Exception: # noqa: BLE001 - a broken logging config must never fail the passthrough request
|
||||
verbose_proxy_logger.exception(
|
||||
"%s: failed to resolve team logging callbacks, continuing without them",
|
||||
route_description,
|
||||
)
|
||||
return _TeamCallbackWiring()
|
||||
if callback_settings_obj is None:
|
||||
return _TeamCallbackWiring()
|
||||
callback_vars: Final = callback_settings_obj.callback_vars
|
||||
success_callbacks: Final = callback_settings_obj.success_callback
|
||||
failure_callbacks: Final = callback_settings_obj.failure_callback
|
||||
logging_kwargs: Final = (
|
||||
None
|
||||
if not callback_vars
|
||||
else { # mutable-ok: Logging arg
|
||||
**callback_vars,
|
||||
TRUSTED_CALLBACK_VARS_FIELD: callback_vars,
|
||||
}
|
||||
)
|
||||
return _TeamCallbackWiring(
|
||||
success_callbacks=None if success_callbacks is None else [*success_callbacks], # mutable-ok: Logging arg
|
||||
failure_callbacks=None if failure_callbacks is None else [*failure_callbacks], # mutable-ok: Logging arg
|
||||
logging_kwargs=logging_kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _log_passthrough_upstream_failure(
|
||||
response: httpx.Response,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -845,7 +914,7 @@ async def pass_through_request(
|
|||
from litellm.proxy.pass_through_endpoints.passthrough_guardrails import (
|
||||
PassthroughGuardrailHandler,
|
||||
)
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
from litellm.proxy.proxy_server import proxy_config, proxy_logging_obj
|
||||
|
||||
#########################################################
|
||||
# Initialize variables
|
||||
|
|
@ -930,6 +999,11 @@ async def pass_through_request(
|
|||
# read e.g. ``chat gpt-4o`` instead of ``chat unknown``.
|
||||
passthrough_model: Final = (_parsed_body.get("model") if isinstance(_parsed_body, dict) else None) or "unknown"
|
||||
start_time: Final = datetime.now()
|
||||
team_callbacks: Final = _resolve_team_callback_wiring(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=proxy_config,
|
||||
route_description="pass_through_endpoint",
|
||||
)
|
||||
logging_obj = Logging(
|
||||
model=passthrough_model,
|
||||
messages=[{"role": "user", "content": safe_dumps(_parsed_body)}],
|
||||
|
|
@ -938,6 +1012,9 @@ async def pass_through_request(
|
|||
start_time=start_time,
|
||||
litellm_call_id=litellm_call_id,
|
||||
function_id="1245",
|
||||
dynamic_success_callbacks=team_callbacks.success_callbacks,
|
||||
dynamic_failure_callbacks=team_callbacks.failure_callbacks,
|
||||
kwargs=team_callbacks.logging_kwargs,
|
||||
)
|
||||
|
||||
# Store passthrough guardrails config on logging_obj for field targeting
|
||||
|
|
@ -2022,7 +2099,7 @@ async def websocket_passthrough_request(
|
|||
setup_model_rewriter: Optional rewrite of the setup frame's model before it reaches the upstream
|
||||
"""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
from litellm.proxy.proxy_server import proxy_config, proxy_logging_obj
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
|
|
@ -2055,6 +2132,11 @@ async def websocket_passthrough_request(
|
|||
upstream_headers[header_name] = header_value
|
||||
|
||||
# Initialize logging object similar to HTTP passthrough
|
||||
team_callbacks: Final = _resolve_team_callback_wiring(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=proxy_config,
|
||||
route_description="websocket_passthrough",
|
||||
)
|
||||
logging_obj: Final = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "WebSocket connection"}],
|
||||
|
|
@ -2063,6 +2145,9 @@ async def websocket_passthrough_request(
|
|||
start_time=start_time,
|
||||
litellm_call_id=litellm_call_id,
|
||||
function_id="websocket_passthrough",
|
||||
dynamic_success_callbacks=team_callbacks.success_callbacks,
|
||||
dynamic_failure_callbacks=team_callbacks.failure_callbacks,
|
||||
kwargs=team_callbacks.logging_kwargs,
|
||||
)
|
||||
|
||||
# Create passthrough logging payload
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import (
|
|||
validated_max_agentic_loops,
|
||||
)
|
||||
from litellm.litellm_core_utils.asyncify import asyncify
|
||||
from litellm.litellm_core_utils.audio_utils.utils import resolve_speech_media_type
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
_get_parent_otel_span_from_kwargs,
|
||||
get_litellm_metadata_from_kwargs,
|
||||
|
|
@ -11061,15 +11062,14 @@ async def audio_speech(
|
|||
if callback_headers:
|
||||
custom_headers.update(callback_headers)
|
||||
|
||||
# Determine media type based on model type
|
||||
media_type = "audio/mpeg" # Default for OpenAI TTS
|
||||
request_model: Final = data.get("model", "")
|
||||
if request_model:
|
||||
request_model_lower: Final = request_model.lower()
|
||||
if "gemini" in request_model_lower and (
|
||||
"tts" in request_model_lower or "preview-tts" in request_model_lower
|
||||
):
|
||||
media_type = "audio/wav" # Gemini TTS returns WAV format after conversion
|
||||
requested_format: Final = data.get("response_format")
|
||||
upstream_content_type: Final = (
|
||||
response.response.headers.get("content-type") if isinstance(response, HttpxBinaryResponseContent) else None
|
||||
)
|
||||
media_type: Final = resolve_speech_media_type(
|
||||
upstream_content_type=upstream_content_type,
|
||||
response_format=requested_format if isinstance(requested_format, str) else None,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
_audio_speech_chunk_generator(response),
|
||||
|
|
@ -11085,7 +11085,15 @@ async def audio_speech(
|
|||
)
|
||||
verbose_proxy_logger.error("litellm.proxy.proxy_server.audio_speech(): Exception occured - %s", e)
|
||||
verbose_proxy_logger.debug(traceback.format_exc())
|
||||
raise e
|
||||
if isinstance(e, (ProxyException, HTTPException)):
|
||||
raise e
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", f"{e}"),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
openai_code=getattr(e, "code", None),
|
||||
code=getattr(e, "status_code", 500),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
|
|||
|
|
@ -1531,7 +1531,8 @@ model LiteLLM_ShadowEvalJob {
|
|||
group_id String // legs of one job share this; the API's job id
|
||||
target_type String @default("key") // key | team | user
|
||||
target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows
|
||||
router_name String // the auto-router under evaluation, in either direction
|
||||
router_name String // first (often only) auto-router under evaluation; router_names is the full set
|
||||
router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name)
|
||||
direction String @default("forward") // forward | reverse
|
||||
baseline_model String? // reverse only: the fixed model the router is judged against
|
||||
judge_model String
|
||||
|
|
@ -1555,6 +1556,7 @@ model LiteLLM_ShadowEvalAttempt {
|
|||
job_id String
|
||||
request_id String // the judged real request
|
||||
outcome String // real | shadow | tie | error
|
||||
router_name String? // the arm this verdict scores; NULL on legacy rows, meaning the job's own router
|
||||
tier String? // router's tier for the prompt, when classified
|
||||
real_model String?
|
||||
shadow_model String?
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@ model_list:
|
|||
|
||||
# Fallback model if tier cannot be determined
|
||||
default_model: gpt-4o
|
||||
|
||||
# Replace a routed model that cannot take image input (default: false)
|
||||
modality_routing: true
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
@ -178,6 +181,25 @@ response = litellm.completion(
|
|||
|
||||
## Special Behaviors
|
||||
|
||||
### Modality-based capability routing
|
||||
|
||||
The classifier reads text alone, so a request carrying an image can classify cheap and land on a
|
||||
text-only model, which rejects it with a provider 400 no fallback catches. With
|
||||
`modality_routing: true`, one gate inspects every decided placement: when the routed model is
|
||||
explicitly declared `supports_vision: false` (deployment `model_info` first, the model cost map
|
||||
otherwise; unmapped names stay routable, and a multi-deployment group must accept on every
|
||||
deployment), the request is re-placed on the nearest HIGHER tier holding a capable model, with
|
||||
routing plugins still applied to the re-pick, then on `default_model` (never on plugin routers
|
||||
and never for a plan-floored decision), and otherwise rejected with a clear 400 naming the
|
||||
router. The walk only ever goes up, so a plan-mode floor cannot be undercut; a router whose only
|
||||
vision model sits below the decided tier gets the 400 and an actionable message instead.
|
||||
|
||||
A same-tier re-pick keeps the decision's cause and adds `modality:image` to `signals`; a tier
|
||||
change or default takeover records `cause: modality_escalation` with the displaced placement
|
||||
(`modality_escalated_from:<TIER>` or `modality_displaced_default_model`). Escalations are never
|
||||
pinned by session affinity, and a KEPT session pin bypasses the gate entirely: a session pinned
|
||||
to a text-only model keeps it even when an image arrives.
|
||||
|
||||
### Heuristic-first chaining
|
||||
|
||||
`classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from litellm.constants import EMPTY_MAPPING, RETURN_RAW_MODEL_NAME_METADATA_KEY
|
|||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
|
||||
from litellm.llms.base_llm.base_utils import type_to_response_format_param
|
||||
from litellm.types.utils import (
|
||||
|
|
@ -738,6 +739,10 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
|
|||
size shrinks again the moment the client compacts: pinning the escalated tier would hold the
|
||||
session on the big-window model long after the oversized context that forced it is gone. The
|
||||
gate re-fires per request, so leaving these unpinned costs nothing but the classifier call.
|
||||
|
||||
A modality escalation is transient the same way: it describes what this one call carries (an
|
||||
image), not what the session's traffic looks like, and pinning it would hold every following
|
||||
text turn on the vision-capable model the image forced.
|
||||
"""
|
||||
return decision is None or (
|
||||
decision.get("cause")
|
||||
|
|
@ -745,6 +750,7 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
|
|||
"default_model_fallback",
|
||||
"plan_mode",
|
||||
"housekeeping",
|
||||
"modality_escalation",
|
||||
)
|
||||
and not decision.get("context_escalated")
|
||||
)
|
||||
|
|
@ -2274,6 +2280,175 @@ class ComplexityRouter(CustomLogger):
|
|||
return pinned_model
|
||||
return self.get_model_for_tier(escalated_tier)
|
||||
|
||||
def _model_accepts_image_input(self, model_name: str) -> bool:
|
||||
"""Whether a routed model or pool entry can serve an image request.
|
||||
|
||||
Resolved through the deployments that would actually serve the name; a name with no
|
||||
deployment on the router is served by the SDK directly and is checked against the model
|
||||
cost map itself. Only an explicit supports_vision false excludes, a deployment-level
|
||||
model_info override first and the map otherwise, so unmapped custom names stay routable.
|
||||
|
||||
A multi-deployment group must accept on EVERY deployment: the router picks a deployment
|
||||
inside the group after this gate runs, so a mixed group marked eligible could still hand
|
||||
the image to its text-only member and fail with the exact 400 the gate exists to prevent.
|
||||
"""
|
||||
from litellm.utils import is_vision_explicitly_disabled
|
||||
|
||||
def deployment_accepts(deployment: Mapping[str, Any]) -> bool:
|
||||
declared: Final = (deployment.get("model_info") or EMPTY_MAPPING).get("supports_vision")
|
||||
if declared is not None:
|
||||
return declared is True
|
||||
litellm_model: Final = (deployment.get("litellm_params") or EMPTY_MAPPING).get("model") or model_name
|
||||
return not is_vision_explicitly_disabled(litellm_model)
|
||||
|
||||
deployments: Final = self.litellm_router_instance.get_model_list(model_name=model_name)
|
||||
if not deployments:
|
||||
return not is_vision_explicitly_disabled(model_name)
|
||||
return all(deployment_accepts(deployment) for deployment in deployments)
|
||||
|
||||
def _modality_eligible_models(self) -> frozenset[str]:
|
||||
"""Every configured pool entry, plus default_model, that can serve an image request."""
|
||||
names: Final = frozenset(entry for pool in self._tier_pools().values() for entry in pool) | frozenset(
|
||||
name for name in (self.config.default_model,) if name
|
||||
)
|
||||
return frozenset(name for name in names if self._model_accepts_image_input(name))
|
||||
|
||||
async def _gate_response_modality(
|
||||
self,
|
||||
response: PreRoutingHookResponse,
|
||||
messages: list[dict[str, Any]] | None, # mutable-ok: forwarded verbatim to the list-typed re-pick
|
||||
resolved_messages: Sequence[Mapping[str, object]] | None,
|
||||
request_kwargs: dict, # mutable-ok: same shape the hook receives
|
||||
) -> PreRoutingHookResponse:
|
||||
"""Replace a routed model that cannot accept this request's image input.
|
||||
|
||||
The single modality owner, applied to the decided response at the hook's exits so every
|
||||
routing path is covered uniformly. A KEPT session pin is exempt by design (its cause);
|
||||
replacement picks and every other path are just responses. The re-placement walks
|
||||
UPWARD-ONLY from the decision's tier (so a plan-mode floor can never be undercut), picks
|
||||
through `_pick_model_for_tier` so routing plugins still apply, then falls to
|
||||
default_model (never on plugin routers, and never on a plan-floored decision, since
|
||||
default_model carries no tier guarantee), else raises the clear 400. The rewritten
|
||||
decision keeps its cause on a same-tier repick and becomes modality_escalation when the
|
||||
tier moved or default_model took over, with the displaced placement in signals.
|
||||
"""
|
||||
decision: Final = response.routing_decision
|
||||
if (
|
||||
not self.config.modality_routing
|
||||
or not resolved_messages
|
||||
or response.model is None
|
||||
or (decision is not None and decision.get("cause") == "session_affinity_pin")
|
||||
or not request_contains_image_content(resolved_messages)
|
||||
or self._model_accepts_image_input(response.model)
|
||||
):
|
||||
return response
|
||||
eligible: Final = self._modality_eligible_models()
|
||||
names: Final = self.config.tier_names()
|
||||
pools: Final = self._tier_pools()
|
||||
decided: Final = decision.get("tier") if decision is not None else None
|
||||
start: Final = names.index(decided) if isinstance(decided, str) and decided in names else 0
|
||||
capable: Final = next(
|
||||
(name for name in names[start:] if any(entry in eligible for entry in pools.get(name, ()))), None
|
||||
)
|
||||
if capable is not None:
|
||||
new_tier: ComplexityTier | str | None = capable if self.config.has_custom_tiers else ComplexityTier(capable)
|
||||
repick_messages: Final = list(resolved_messages) # mutable-ok: the pick's param is list-typed
|
||||
new_model = await self._pick_model_for_tier(
|
||||
new_tier,
|
||||
messages,
|
||||
repick_messages, # pyright: ignore[reportArgumentType] # hook-resolved message dicts; the pick only reads them
|
||||
request_kwargs,
|
||||
allowed_models=tuple(entry for entry in pools.get(capable, ()) if entry in eligible),
|
||||
)
|
||||
elif self._modality_default_model_usable(request_kwargs, resolved_messages, eligible):
|
||||
new_tier = None
|
||||
new_model = self._placed_default_model()
|
||||
else:
|
||||
import litellm
|
||||
|
||||
raise litellm.BadRequestError(
|
||||
message=(
|
||||
f"Auto-router {self.model_name} received a request with image input, but no model "
|
||||
f"at or above the decided tier accepts images and modality_routing is enabled. "
|
||||
f"Tiers checked: {', '.join(names[start:])}. Add a vision-capable model to a tier, "
|
||||
f"or set a vision-capable default_model, or remove the image content."
|
||||
),
|
||||
model=self.model_name,
|
||||
llm_provider="",
|
||||
)
|
||||
self._restamp_adaptive_choice(request_kwargs, response.model, new_model)
|
||||
same_tier: Final = capable is not None and decided == capable
|
||||
base_cause: Final = (decision.get("cause") if decision is not None else None) or "default_fallback"
|
||||
displaced_default: Final = decided is None and response.model == self.config.default_model
|
||||
markers: Final = (
|
||||
"modality:image",
|
||||
*((f"modality_escalated_from:{decided}",) if not same_tier and isinstance(decided, str) else ()),
|
||||
*(("modality_displaced_default_model",) if not same_tier and displaced_default else ()),
|
||||
)
|
||||
old_signals: Final = tuple(decision.get("signals") or ()) if decision is not None else ()
|
||||
new_decision: Final = self._build_routing_decision(
|
||||
routed_model=new_model,
|
||||
cause=base_cause if same_tier else "modality_escalation",
|
||||
tier=new_tier,
|
||||
score=decision.get("score") if decision is not None else None,
|
||||
signals=(*old_signals, *markers),
|
||||
matched_keyword=decision.get("matched_keyword") if decision is not None else None,
|
||||
escalation_keyword=decision.get("escalation_keyword") if decision is not None else None,
|
||||
escalated=bool(decision.get("escalated", False)) if decision is not None else False,
|
||||
classifier_model=decision.get("classifier_model") if decision is not None else None,
|
||||
classifier_cost=decision.get("classifier_cost") if decision is not None else None,
|
||||
conversation_continuing=bool(decision.get("conversation_continuing", True))
|
||||
if decision is not None
|
||||
else True,
|
||||
tier_litellm_params=self._litellm_params_for_model(new_tier, new_model),
|
||||
context_escalation_original_tier=(
|
||||
decision.get("context_escalation_original_tier") if decision is not None else None
|
||||
),
|
||||
)
|
||||
from litellm.types.router import PreRoutingHookResponse as HookResponse
|
||||
|
||||
return HookResponse(
|
||||
model=new_model,
|
||||
messages=response.messages,
|
||||
litellm_params=self._litellm_params_for_model(new_tier, new_model),
|
||||
routing_decision=new_decision,
|
||||
)
|
||||
|
||||
def _modality_default_model_usable(
|
||||
self,
|
||||
request_kwargs: Mapping[str, object],
|
||||
resolved_messages: Sequence[Mapping[str, object]] | None,
|
||||
eligible: frozenset[str],
|
||||
) -> bool:
|
||||
"""default_model may serve a gated request only when it is configured, plugin-free
|
||||
(it is never checked against the plugin pipeline), capability-eligible, and the turn
|
||||
carries no plan-mode sentinel. The sentinel is re-detected here rather than read off
|
||||
the decision record, because the record only marks turns the floor RAISED; a sentinel
|
||||
turn already at or above the floor keeps its ordinary cause, and default_model carries
|
||||
no tier the floor could vouch for on any sentinel turn."""
|
||||
return (
|
||||
bool(self.config.default_model)
|
||||
and not self.config.plugins
|
||||
and self.config.default_model in eligible
|
||||
and self._matched_plan_mode_signal(request_kwargs, resolved_messages) is None
|
||||
)
|
||||
|
||||
def _placed_default_model(self) -> str:
|
||||
"""The default_model behind a usable-default verdict; the raise is the type-level
|
||||
proof, not a reachable path."""
|
||||
model: Final = self.config.default_model
|
||||
if model is None:
|
||||
raise ValueError(f"Auto-router {self.model_name}: modality gate routed to an unset default_model")
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def _restamp_adaptive_choice(request_kwargs: Mapping[str, object], old_model: str, new_model: str) -> None:
|
||||
"""The adaptive feedback loop reads its chosen-model marker from request metadata; a
|
||||
gate rewrite must move the marker with the model or rewards land on the displaced one."""
|
||||
metadata: Final = request_kwargs.get("metadata")
|
||||
if isinstance(metadata, dict) and metadata.get("adaptive_router_chosen_model") == old_model:
|
||||
metadata["adaptive_router_chosen_model"] = new_model
|
||||
|
||||
def _lexical_tier_override(self, user_message: str) -> KeywordOverride | None:
|
||||
"""When keyword_tier_rules match literally, the most-severe matched tier wins.
|
||||
|
||||
|
|
@ -2655,25 +2830,30 @@ class ComplexityRouter(CustomLogger):
|
|||
session_tier_litellm_params: Final = self._litellm_params_for_model(routed_pin_tier, routed_model)
|
||||
has_original_messages: Final = messages is not None and len(messages) > 0
|
||||
return self._with_session_deployment_affinity(
|
||||
PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
litellm_params=session_tier_litellm_params,
|
||||
routing_decision=self._build_routing_decision(
|
||||
routed_model=routed_model,
|
||||
cause=cause,
|
||||
tier=routed_pin_tier,
|
||||
matched_keyword=pin_plan_sentinel if plan_floored else None,
|
||||
escalation_keyword=pin_escalation_keyword,
|
||||
escalated=escalated,
|
||||
conversation_continuing=conversation_continuing,
|
||||
tier_litellm_params=session_tier_litellm_params,
|
||||
context_escalation_original_tier=pin_context_original_tier,
|
||||
await self._gate_response_modality(
|
||||
PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
litellm_params=session_tier_litellm_params,
|
||||
routing_decision=self._build_routing_decision(
|
||||
routed_model=routed_model,
|
||||
cause=cause,
|
||||
tier=routed_pin_tier,
|
||||
matched_keyword=pin_plan_sentinel if plan_floored else None,
|
||||
escalation_keyword=pin_escalation_keyword,
|
||||
escalated=escalated,
|
||||
conversation_continuing=conversation_continuing,
|
||||
tier_litellm_params=session_tier_litellm_params,
|
||||
context_escalation_original_tier=pin_context_original_tier,
|
||||
),
|
||||
),
|
||||
messages,
|
||||
resolved_messages,
|
||||
request_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
response: Final = await self._classify_and_route(
|
||||
routed_response: Final = await self._classify_and_route(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
|
|
@ -2682,6 +2862,11 @@ class ComplexityRouter(CustomLogger):
|
|||
conversation_continuing=conversation_continuing,
|
||||
resolved_messages=resolved_messages,
|
||||
)
|
||||
response: Final = (
|
||||
await self._gate_response_modality(routed_response, messages, resolved_messages, request_kwargs)
|
||||
if routed_response is not None
|
||||
else None
|
||||
)
|
||||
# Sentinel presence, not the plan_mode cause, gates the pin write: a plan-mode turn
|
||||
# classified at or above the floor keeps its ordinary cause, yet on an adaptive router
|
||||
# the hard floor constrained its pick, so pinning it would carry a plan-mode-shaped
|
||||
|
|
|
|||
|
|
@ -848,6 +848,18 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"drift plus the response tokens."
|
||||
),
|
||||
)
|
||||
modality_routing: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Route image-bearing requests only to models that can accept image input. The "
|
||||
"classifier reads text alone, so an image request whose text classifies cheap "
|
||||
"otherwise lands on a text-only model and fails with a provider 400. When enabled, "
|
||||
"a routed model explicitly declared supports_vision false (deployment model_info "
|
||||
"or the model cost map; unmapped names stay routable) is replaced by the nearest "
|
||||
"HIGHER tier holding a capable model, then default_model, else a clear 400. A kept "
|
||||
"session-affinity pin still wins even when an image arrives."
|
||||
),
|
||||
)
|
||||
|
||||
# Semantic (embedding) matching for keyword_tier_rules instead of literal text matching
|
||||
semantic_keyword_matching: bool = Field(
|
||||
|
|
|
|||
|
|
@ -251,8 +251,12 @@ DEFAULT_SHADOW_EVAL_JUDGE_MODEL: Final[str] = "anthropic/claude-sonnet-5"
|
|||
|
||||
# Sample-count ceiling written on every new job: a zero-cost error loop (a shadow arm that
|
||||
# fails before billing) never consumes spend budget, so it must terminate on count instead.
|
||||
# A multi-router job writes one attempt row per router arm, so the valve is reached
|
||||
# proportionally sooner; it is a safety valve, not a sample budget.
|
||||
SHADOW_EVAL_TURN_VALVE: Final[int] = 10_000
|
||||
|
||||
SHADOW_EVAL_MAX_ROUTERS: Final[int] = 4
|
||||
|
||||
|
||||
class StartShadowEvalRequest(BaseModel):
|
||||
"""Start duplicating one or more targets' traffic for blind comparison against an auto-router.
|
||||
|
|
@ -288,7 +292,24 @@ class StartShadowEvalRequest(BaseModel):
|
|||
"to across all their teams: JWT requests carrying their subject claim and virtual keys they own"
|
||||
),
|
||||
)
|
||||
router_name: str = Field(description="The auto-router under evaluation, in either direction")
|
||||
router_name: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The auto-router under evaluation, in either direction: the single-router spelling of "
|
||||
"router_names. Provide exactly one of the two fields"
|
||||
),
|
||||
)
|
||||
router_names: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
max_length=SHADOW_EVAL_MAX_ROUTERS,
|
||||
description=(
|
||||
"The auto-routers under evaluation, at most "
|
||||
f"{SHADOW_EVAL_MAX_ROUTERS}. Every sampled request runs through every router listed and each "
|
||||
"arm is judged independently against the same real response, so routers compare head-to-head "
|
||||
"on identical traffic. More than one router requires direction 'forward'. After validation "
|
||||
"this field always carries the full deduplicated set, whichever spelling the caller used"
|
||||
),
|
||||
)
|
||||
direction: ShadowEvalDirection = Field(
|
||||
default="forward",
|
||||
description=(
|
||||
|
|
@ -332,7 +353,8 @@ class StartShadowEvalRequest(BaseModel):
|
|||
"Per-target USD budget for the eval's own overhead, the shadow-arm and judge calls, priced with "
|
||||
"the same figures the spend pipeline bills. EACH scoped target samples until its recorded eval "
|
||||
"spend reaches this, so a job over N targets spends at most about N times max_budget; in-flight "
|
||||
"samples can overshoot the cap by one sampling cache window"
|
||||
"samples can overshoot the cap by one sampling cache window. Every router arm draws from the "
|
||||
"same per-target budget, so a multi-router job reaches it proportionally sooner"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -373,6 +395,23 @@ class StartShadowEvalRequest(BaseModel):
|
|||
raise ValueError("baseline_model is only meaningful when direction is 'reverse'")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _resolve_router_set(self) -> "StartShadowEvalRequest":
|
||||
"""Whichever spelling the caller used, router_names leaves validation as the full
|
||||
deduplicated set, so every downstream reader consumes one field."""
|
||||
if (self.router_name is None) == (not self.router_names):
|
||||
raise ValueError("provide exactly one of router_name or router_names")
|
||||
single: Final = () if self.router_name is None else (self.router_name,)
|
||||
routers: Final = tuple(dict.fromkeys(self.router_names or single))
|
||||
if not all(name.strip() for name in routers):
|
||||
raise ValueError("router names must be non-empty strings")
|
||||
if len(routers) > 1 and self.direction == "reverse":
|
||||
raise ValueError("a reverse job evaluates one router against baseline_model; pass a single router")
|
||||
# A returned model_copy is ignored on the __init__ construction path, so the
|
||||
# normalization must land as a self attribute store to hold for every caller.
|
||||
self.router_names = routers
|
||||
return self
|
||||
|
||||
|
||||
class ShadowEvalSlice(BaseModel):
|
||||
"""Judge outcomes for one slice of a job's verdicts: a router tier, one of the
|
||||
|
|
@ -428,15 +467,28 @@ class ShadowEvalResult(BaseModel):
|
|||
"and in reverse the models the router itself picked"
|
||||
)
|
||||
)
|
||||
by_router: tuple[ShadowEvalSlice, ...] = Field(
|
||||
default=(),
|
||||
description=(
|
||||
"One slice per router arm, grouped on the router name. Every arm of a multi-router job is "
|
||||
"judged against the same real responses over the same sampled requests, so these slices "
|
||||
"compare routers head-to-head: like-for-like win rates and spends on identical traffic. "
|
||||
"Verdicts from before arm stamping existed count toward the job's own router"
|
||||
),
|
||||
)
|
||||
overall_shadow_win_rate_pct: float
|
||||
overall_tie_rate_pct: float
|
||||
sampled_real_spend: float = Field(
|
||||
default=0.0,
|
||||
description="USD the real arm billed across all judged turns, cache-served turns excluded",
|
||||
description=(
|
||||
"USD the real arm billed across all judged turns, cache-served turns excluded. A judged turn "
|
||||
"is one (request, router arm) verdict, so a multi-router job counts the real response once per "
|
||||
"arm it was judged against; per-router comparisons read by_router"
|
||||
),
|
||||
)
|
||||
sampled_shadow_spend: float = Field(
|
||||
default=0.0,
|
||||
description="USD the shadow arm billed across the same turns, judge excluded, like for like",
|
||||
description="USD the shadow arms billed across the same turns, judge excluded, like for like",
|
||||
)
|
||||
not_sampled_count: int | None = Field(
|
||||
default=None,
|
||||
|
|
@ -540,7 +592,13 @@ class ShadowEvalJobResponse(BaseModel):
|
|||
min_length=1,
|
||||
description="The targets whose traffic this job evaluates, and only theirs, each with its own budget",
|
||||
)
|
||||
router_name: str
|
||||
router_names: tuple[str, ...] = Field(
|
||||
min_length=1,
|
||||
description=(
|
||||
"Every auto-router this job runs as a shadow arm. Multi-router jobs sample one slice of "
|
||||
"traffic and judge every arm against the same real responses"
|
||||
),
|
||||
)
|
||||
direction: ShadowEvalDirection = "forward"
|
||||
baseline_model: str | None = None
|
||||
judge_model: str
|
||||
|
|
@ -562,6 +620,13 @@ class ShadowEvalJobResponse(BaseModel):
|
|||
last_error: str | None = Field(default=None, description="Most recent attempt error; detail endpoint only")
|
||||
results: ShadowEvalResult | None = Field(default=None, description="Stratified verdicts; detail endpoint only")
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def router_name(self) -> str:
|
||||
"""The first router, kept for callers that predate router_names; derived so the
|
||||
two fields can never disagree."""
|
||||
return self.router_names[0]
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def status(self) -> ShadowEvalStatus:
|
||||
|
|
|
|||
|
|
@ -2840,6 +2840,10 @@ RoutingDecisionCause = Literal[
|
|||
# never called. The matched sentinel rides in matched_keyword. Distinct from the keyword causes,
|
||||
# which are operator-authored rules; these sentinels ship with the router.
|
||||
"housekeeping",
|
||||
# modality_routing replaced the decided placement: the request carries an image and the
|
||||
# routed model does not accept image input, so the nearest higher capable tier or
|
||||
# default_model served instead. The displaced placement rides in signals.
|
||||
"modality_escalation",
|
||||
"session_affinity_pin",
|
||||
"session_affinity_escalation",
|
||||
# classification_mode 'user_turn': the request is an agent loop's continuation turn (no new
|
||||
|
|
|
|||
|
|
@ -2660,10 +2660,19 @@ def _is_explicitly_disabled_factory(model: str, custom_llm_provider: str | None,
|
|||
``_supports_factory`` so caching, fallback, and normalisation improvements
|
||||
apply here automatically.
|
||||
"""
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
|
||||
|
||||
try:
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
declared: Final = declared_authenticating_provider(model, custom_llm_provider)
|
||||
if declared is not None:
|
||||
model = model.removeprefix(
|
||||
f"{declared}/"
|
||||
) # rebind-ok: mirrors get_llm_provider's split without its OAuth flow
|
||||
custom_llm_provider = declared # rebind-ok: same
|
||||
else:
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
model_info: Final = _get_model_info_helper(model=model, custom_llm_provider=custom_llm_provider)
|
||||
val: Final = model_info.get(key)
|
||||
if val is False:
|
||||
|
|
@ -2751,6 +2760,15 @@ def supports_computer_use(model: str, custom_llm_provider: str | None = None) ->
|
|||
)
|
||||
|
||||
|
||||
def is_vision_explicitly_disabled(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
"""True only when supports_vision is explicitly declared false for the model.
|
||||
|
||||
The opt-out mirror of :func:`supports_vision`: a missing declaration reads as not
|
||||
disabled, so unknown or newly added models stay eligible for image routing.
|
||||
"""
|
||||
return _is_explicitly_disabled_factory(model, custom_llm_provider, "supports_vision")
|
||||
|
||||
|
||||
def supports_vision(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
"""
|
||||
Check if the given model supports vision and return a boolean value.
|
||||
|
|
@ -9415,6 +9433,10 @@ class ProviderConfigManager:
|
|||
|
||||
return RunwayMLTextToSpeechConfig()
|
||||
elif litellm.LlmProviders.VERTEX_AI == provider:
|
||||
if "gemini" in model:
|
||||
# Gemini TTS uses the speech_to_completion bridge, and Google Cloud TTS param
|
||||
# mapping would drop response_format before the bridge sees it (LIT-6501)
|
||||
return None
|
||||
from litellm.llms.vertex_ai.text_to_speech.transformation import (
|
||||
VertexAITextToSpeechConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ healthcheck = [
|
|||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["maturin==1.9.4"]
|
||||
requires = ["maturin==1.15.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[tool.maturin]
|
||||
|
|
@ -275,6 +275,9 @@ manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml"
|
|||
module-name = "litellm.rust_bridge._native"
|
||||
python-source = "."
|
||||
bindings = "pyo3"
|
||||
features = ["extension-module"]
|
||||
profile = "release"
|
||||
editable-profile = "dev"
|
||||
include = ["litellm/proxy/_experimental/out/**"]
|
||||
exclude = [
|
||||
"litellm/proxy/enterprise",
|
||||
|
|
|
|||
|
|
@ -1531,7 +1531,8 @@ model LiteLLM_ShadowEvalJob {
|
|||
group_id String // legs of one job share this; the API's job id
|
||||
target_type String @default("key") // key | team | user
|
||||
target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows
|
||||
router_name String // the auto-router under evaluation, in either direction
|
||||
router_name String // first (often only) auto-router under evaluation; router_names is the full set
|
||||
router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name)
|
||||
direction String @default("forward") // forward | reverse
|
||||
baseline_model String? // reverse only: the fixed model the router is judged against
|
||||
judge_model String
|
||||
|
|
@ -1555,6 +1556,7 @@ model LiteLLM_ShadowEvalAttempt {
|
|||
job_id String
|
||||
request_id String // the judged real request
|
||||
outcome String // real | shadow | tie | error
|
||||
router_name String? // the arm this verdict scores; NULL on legacy rows, meaning the job's own router
|
||||
tier String? // router's tier for the prompt, when classified
|
||||
real_model String?
|
||||
shadow_model String?
|
||||
|
|
|
|||
|
|
@ -64,14 +64,12 @@
|
|||
- {id: mgmt.budget.list_v1.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "management_v1/budgets.py:129", rationale: "Budget enumeration the Budgets page can page, sort and filter"}
|
||||
- {id: mgmt.budget.list_v1.admin_only, module: mgmt, tier: P1, surface: api, assertions: [admin_only], source: "management_v1/budgets.py:129", rationale: "A caller without admin view is refused, not served an empty page"}
|
||||
- {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"}
|
||||
- {id: mgmt.cache_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cache_settings_endpoints.py", rationale: "Cache config (smoke). Deliberately uncovered: the previous test read the live settings and wrote them back, which proves nothing (identical values in, so a no-op POST still passes) while being able to break the deployment. /cache/settings persists what it receives and that row outranks YAML cache_params, re-applied on a timer, so a write that omits ssl or redis_startup_nodes turns a TLS cluster into a plaintext standalone node and every later Redis call hangs. That took out 60 of 72 tests on 2026-07-25. GET cannot round-trip it either: it resolves the stored row overlaid with REDIS_* env and never reads YAML, so on a fresh deploy it cannot see YAML ssl to echo back. A safe test needs an isolated proxy, or LIT-4816 fixed so a partial write cannot downgrade transport. Do not re-add a read-then-write-back test against a shared proxy."}
|
||||
- {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"}
|
||||
- {id: mgmt.router_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "router_settings_endpoints.py", rationale: "Router config (smoke)"}
|
||||
- {id: mgmt.jwt_key_mapping.new.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "jwt_key_mapping_endpoints.py", rationale: "JWT->key mapping (smoke)"}
|
||||
- {id: mgmt.compliance.gdpr.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "compliance_endpoints.py", rationale: "GDPR ops (smoke)"}
|
||||
- {id: mgmt.tool_management.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "tool_management_endpoints.py", rationale: "Tool inventory (smoke)"}
|
||||
- {id: mgmt.fallback_management.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "fallback_management_endpoints.py", rationale: "Fallback config (smoke)"}
|
||||
- {id: mgmt.config_override.hashicorp_vault.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "config_override_endpoints.py", rationale: "Vault integration (smoke)"}
|
||||
- {id: mgmt.workflow.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "workflow_management_endpoints.py", rationale: "Workflow tracking (smoke)"}
|
||||
- {id: mgmt.credential_migration.check.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:4252", rationale: "Encryption migration (smoke)"}
|
||||
- {id: mgmt.credential.new.serves_request, module: mgmt, tier: P1, surface: api, assertions: [serves_request], source: "credential_endpoints/endpoints.py:42", rationale: "Stored credential resolves into a deployment and serves a live /messages request"}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ so a read-back reflects the change. Router settings, which mutate global proxy
|
|||
state, are exercised with a benign, self-restoring change so a shared proxy is left
|
||||
as it was found.
|
||||
|
||||
Cache settings are deliberately not covered here; see the rationale on
|
||||
mgmt.cache_settings.update.happy_path in coverage_registry/mgmt.yaml before adding
|
||||
a test for that route.
|
||||
Cache settings and the Vault config override are deliberately not covered here.
|
||||
Both routes reconfigure the whole proxy: /cache/settings persists what it receives
|
||||
into a row that outranks the YAML cache_params and is re-applied on a timer, and
|
||||
/config_overrides/hashicorp_vault swaps the process-wide secret manager. Neither can
|
||||
be exercised safely against the shared proxy the suites run on, so they need an
|
||||
isolated proxy before a test lands. Do not add a read-then-write-back test for
|
||||
either one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
"""
|
||||
Regression tests for Redis connection pool leak fixes (RC1-RC5).
|
||||
|
||||
Tests are pure unit tests — no Redis server required.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import redis.asyncio as async_redis
|
||||
|
||||
from litellm._redis import get_redis_async_client, get_redis_connection_pool
|
||||
from litellm._redis import (
|
||||
_coerce_redis_kwargs_types,
|
||||
_get_redis_client_logic,
|
||||
_get_redis_env_kwarg_mapping,
|
||||
get_redis_async_client,
|
||||
get_redis_connection_pool,
|
||||
)
|
||||
|
||||
|
||||
def test_url_config_uses_passed_pool():
|
||||
|
|
@ -60,16 +59,14 @@ def test_max_connections_url_config_string_value(monkeypatch):
|
|||
assert pool.max_connections == 25
|
||||
|
||||
|
||||
def test_max_connections_url_config_invalid_value():
|
||||
"""Invalid max_connections should be silently ignored, falling back
|
||||
to the pool default (50 for BlockingConnectionPool)."""
|
||||
with patch("litellm._redis._get_redis_client_logic") as mock_logic:
|
||||
mock_logic.return_value = {
|
||||
"url": "redis://localhost:6379/0",
|
||||
"max_connections": "not_a_number",
|
||||
}
|
||||
def test_max_connections_url_config_invalid_value(monkeypatch):
|
||||
"""Invalid max_connections from an env var should be silently dropped,
|
||||
falling back to the pool default (50 for BlockingConnectionPool)."""
|
||||
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.delenv("REDIS_HOST", raising=False)
|
||||
monkeypatch.setenv("REDIS_MAX_CONNECTIONS", "not_a_number")
|
||||
|
||||
pool = get_redis_connection_pool()
|
||||
pool = get_redis_connection_pool()
|
||||
|
||||
# BlockingConnectionPool default is 50
|
||||
assert pool.max_connections == 50
|
||||
|
|
@ -128,3 +125,173 @@ async def test_disconnect_idempotent():
|
|||
|
||||
await cache.disconnect()
|
||||
await cache.disconnect() # should not raise
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_int():
|
||||
"""String values for int-typed Redis params are coerced to int."""
|
||||
result = _coerce_redis_kwargs_types({"health_check_interval": "30", "port": "6380", "db": "1"})
|
||||
assert result["health_check_interval"] == 30
|
||||
assert isinstance(result["health_check_interval"], int)
|
||||
assert result["port"] == 6380
|
||||
assert result["db"] == 1
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_bool():
|
||||
"""String values for bool-typed Redis params are coerced to bool."""
|
||||
result = _coerce_redis_kwargs_types({"ssl": "true", "decode_responses": "false"})
|
||||
assert result["ssl"] is True
|
||||
assert result["decode_responses"] is False
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_none_default_numeric():
|
||||
"""String values for known None-default numeric params are coerced."""
|
||||
result = _coerce_redis_kwargs_types({"max_connections": "20", "socket_timeout": "5.5"})
|
||||
assert result["max_connections"] == 20
|
||||
assert isinstance(result["max_connections"], int)
|
||||
assert result["socket_timeout"] == 5.5
|
||||
assert isinstance(result["socket_timeout"], float)
|
||||
|
||||
|
||||
def _redis_signature_pre_8x(
|
||||
socket_timeout=None,
|
||||
socket_connect_timeout=None,
|
||||
max_connections=None,
|
||||
health_check_interval=0,
|
||||
):
|
||||
"""Stand-in for the redis-py <= 7.x Redis signature, where the timeout defaults are None."""
|
||||
|
||||
|
||||
def _redis_signature_8x(
|
||||
socket_timeout=5,
|
||||
socket_connect_timeout=5,
|
||||
max_connections=None,
|
||||
health_check_interval=0,
|
||||
):
|
||||
"""Stand-in for the redis-py 8.x Redis signature, where the timeout defaults became int 5."""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"client",
|
||||
[_redis_signature_pre_8x, _redis_signature_8x],
|
||||
ids=["redis-py<=7.x", "redis-py-8.x"],
|
||||
)
|
||||
def test_coerce_fractional_socket_timeout_survives_signature_default_change(client):
|
||||
"""redis-py 8.x changed socket_timeout's default from None to int 5. Deriving the
|
||||
target type from the signature default made int("5.5") raise, so the key was dropped
|
||||
and REDIS_SOCKET_TIMEOUT=5.5 silently disappeared on 8.x."""
|
||||
result = _coerce_redis_kwargs_types(
|
||||
{"socket_timeout": "5.5", "socket_connect_timeout": "2.5", "max_connections": "20"},
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert result["socket_timeout"] == pytest.approx(5.5)
|
||||
assert isinstance(result["socket_timeout"], float)
|
||||
assert result["socket_connect_timeout"] == pytest.approx(2.5)
|
||||
assert isinstance(result["socket_connect_timeout"], float)
|
||||
assert result["max_connections"] == 20
|
||||
assert isinstance(result["max_connections"], int)
|
||||
|
||||
|
||||
def test_coerce_invalid_socket_timeout_is_still_dropped():
|
||||
"""Garbage must not survive the explicit-type path; Redis falls back to its own default."""
|
||||
result = _coerce_redis_kwargs_types({"socket_timeout": "not_a_number"}, client=_redis_signature_8x)
|
||||
|
||||
assert "socket_timeout" not in result
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_invalid_drops_key():
|
||||
"""A string that cannot be coerced to the expected numeric type is dropped."""
|
||||
result = _coerce_redis_kwargs_types({"health_check_interval": "not_a_number"})
|
||||
assert "health_check_interval" not in result
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_non_string_unchanged():
|
||||
"""Non-string values pass through without modification."""
|
||||
result = _coerce_redis_kwargs_types({"health_check_interval": 30, "ssl": True})
|
||||
assert result["health_check_interval"] == 30
|
||||
assert result["ssl"] is True
|
||||
|
||||
|
||||
def test_health_check_interval_from_env_is_int(monkeypatch):
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
monkeypatch.setenv("REDIS_HEALTH_CHECK_INTERVAL", "30")
|
||||
|
||||
pool = get_redis_connection_pool()
|
||||
|
||||
assert pool is not None
|
||||
interval = pool.connection_kwargs.get("health_check_interval")
|
||||
assert interval == 30
|
||||
assert isinstance(interval, int), f"Expected int, got {type(interval)}: {interval!r}"
|
||||
|
||||
|
||||
def _signature_without_defaults(testkey):
|
||||
"""Stand-in for a client whose parameter declares no default at all."""
|
||||
|
||||
|
||||
def _signature_with_float_default(myparam=1.0):
|
||||
"""Stand-in for a client whose parameter declares a float default."""
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_empty_default_param_unchanged():
|
||||
"""String params whose signature entry has no default (inspect.Parameter.empty) are left as-is."""
|
||||
result = _coerce_redis_kwargs_types({"testkey": "some_value"}, client=_signature_without_defaults)
|
||||
|
||||
assert result["testkey"] == "some_value"
|
||||
assert isinstance(result["testkey"], str)
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_float_valid():
|
||||
"""String values for params whose signature default is a float are coerced to float."""
|
||||
result = _coerce_redis_kwargs_types({"myparam": "3.14"}, client=_signature_with_float_default)
|
||||
|
||||
assert result["myparam"] == pytest.approx(3.14)
|
||||
assert isinstance(result["myparam"], float)
|
||||
|
||||
|
||||
def test_coerce_redis_kwargs_types_float_invalid_drops_key():
|
||||
"""An unconvertible string for a float-default param is dropped from the result."""
|
||||
result = _coerce_redis_kwargs_types({"myparam": "not_a_float"}, client=_signature_with_float_default)
|
||||
|
||||
assert "myparam" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[("false", False), ("true", True), ("0", False), ("1", True)],
|
||||
)
|
||||
def test_coerce_socket_keepalive_string(raw, expected):
|
||||
"""socket_keepalive's signature default is None, so it needs an explicit bool
|
||||
coercion: a leftover "false" string is truthy and enables keepalive."""
|
||||
result = _coerce_redis_kwargs_types({"socket_keepalive": raw})
|
||||
|
||||
assert result["socket_keepalive"] is expected
|
||||
|
||||
|
||||
def test_get_redis_client_logic_coerces_cluster_only_kwargs(monkeypatch):
|
||||
"""Cluster-only kwargs (absent from redis.Redis's signature) must still be
|
||||
coerced when routing to a cluster, or Helm-stringified values reach
|
||||
RedisCluster as strings."""
|
||||
for envvar in (*_get_redis_env_kwarg_mapping(), "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
|
||||
monkeypatch.delenv(envvar, raising=False)
|
||||
|
||||
result = _get_redis_client_logic(
|
||||
startup_nodes='[{"host": "localhost", "port": 7000}]',
|
||||
cluster_error_retry_attempts="5",
|
||||
require_full_coverage="false",
|
||||
health_check_interval="30",
|
||||
)
|
||||
|
||||
assert result["cluster_error_retry_attempts"] == 5
|
||||
assert isinstance(result["cluster_error_retry_attempts"], int)
|
||||
assert result["require_full_coverage"] is False
|
||||
assert result["health_check_interval"] == 30
|
||||
assert isinstance(result["health_check_interval"], int)
|
||||
|
||||
|
||||
def test_get_redis_client_logic_raises_without_host_or_url(monkeypatch):
|
||||
"""_get_redis_client_logic raises ValueError when neither host nor url is provided."""
|
||||
for envvar in (*_get_redis_env_kwarg_mapping(), "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
|
||||
monkeypatch.delenv(envvar, raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="Either 'host' or 'url' must be specified for redis"):
|
||||
_get_redis_client_logic()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import base64
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -8,8 +9,17 @@ from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
|
|||
from litellm.endpoints.speech.speech_to_completion_bridge.transformation import (
|
||||
SpeechToCompletionBridgeTransformationHandler,
|
||||
)
|
||||
from litellm.types.utils import ChatCompletionAudioResponse, Choices, Message, ModelResponse
|
||||
|
||||
GEMINI_TTS_MODEL: Final = "gemini-3.1-flash-tts-preview"
|
||||
PCM_BYTES: Final = b"\x01\x02\x03\x04" * 6
|
||||
|
||||
|
||||
def _model_response(model: str, pcm: bytes) -> ModelResponse:
|
||||
audio: Final = ChatCompletionAudioResponse(
|
||||
data=base64.b64encode(pcm).decode(), expires_at=0, transcript="hello"
|
||||
)
|
||||
return ModelResponse(model=model, choices=[Choices(message=Message(content=None, audio=audio))])
|
||||
|
||||
|
||||
def _bridge_request(response_format: str | None) -> dict:
|
||||
|
|
@ -28,7 +38,7 @@ def _bridge_request(response_format: str | None) -> dict:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["wav", "mp3", "pcm", None])
|
||||
@pytest.mark.parametrize("response_format", ["wav", "pcm", None])
|
||||
def test_gemini_tts_request_keeps_speech_response_format_out_of_chat_params(response_format: str | None) -> None:
|
||||
request: Final = _bridge_request(response_format)
|
||||
|
||||
|
|
@ -60,3 +70,48 @@ def test_non_gemini_request_forwards_speech_response_format_as_audio_format() ->
|
|||
|
||||
assert "response_format" not in request
|
||||
assert request["audio"] == {"voice": "alloy", "format": "wav"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["mp3", "flac", "opus", "aac"])
|
||||
def test_gemini_tts_request_rejects_formats_gemini_cannot_produce(response_format: str) -> None:
|
||||
with pytest.raises(litellm.BadRequestError) as excinfo:
|
||||
_bridge_request(response_format)
|
||||
|
||||
assert excinfo.value.status_code == 400
|
||||
assert response_format in str(excinfo.value)
|
||||
assert "pcm" in str(excinfo.value)
|
||||
assert "wav" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_gemini_tts_pcm_response_returns_raw_pcm_bytes() -> None:
|
||||
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
|
||||
model_response=_model_response(GEMINI_TTS_MODEL, PCM_BYTES),
|
||||
response_format="pcm",
|
||||
)
|
||||
|
||||
assert response.response.content == PCM_BYTES
|
||||
assert response.response.headers["content-type"] == "audio/pcm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["wav", None])
|
||||
def test_gemini_tts_wav_and_default_responses_wrap_pcm_in_wav(response_format: str | None) -> None:
|
||||
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
|
||||
model_response=_model_response(GEMINI_TTS_MODEL, PCM_BYTES),
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
body: Final = response.response.content
|
||||
assert body[:4] == b"RIFF"
|
||||
assert body[8:12] == b"WAVE"
|
||||
assert body[44:] == PCM_BYTES
|
||||
assert response.response.headers["content-type"] == "audio/wav"
|
||||
|
||||
|
||||
def test_non_gemini_response_keeps_original_bytes_and_mpeg_content_type() -> None:
|
||||
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
|
||||
model_response=_model_response("gpt-4o-audio-preview", PCM_BYTES),
|
||||
response_format="mp3",
|
||||
)
|
||||
|
||||
assert response.response.content == PCM_BYTES
|
||||
assert response.response.headers["content-type"] == "audio/mpeg"
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ def _job_record(job: ActiveShadowEvalJob, target_type="key", target_id="key-hash
|
|||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
router_name=job.router_name,
|
||||
router_names=job.router_names,
|
||||
direction=job.direction,
|
||||
baseline_model=job.baseline_model,
|
||||
shadow_percentage=job.shadow_percentage,
|
||||
|
|
@ -81,6 +82,7 @@ def _router(
|
|||
shadow_text="shadow answer",
|
||||
judge_json='{"preference": "A", "confidence": 0.9, "reasoning": "x"}',
|
||||
classifier_cost=None,
|
||||
sibling_router_texts=None,
|
||||
):
|
||||
"""One mock router serving the shadow call first, the judge call second, told apart by
|
||||
the internal-origin stamp rather than the model, since a reverse job's shadow arm names
|
||||
|
|
@ -100,6 +102,15 @@ def _router(
|
|||
decision["classifier_cost"] = classifier_cost
|
||||
kwargs["metadata"]["routing_decision"] = decision
|
||||
return {"choices": [{"message": {"content": shadow_text}}], "usage": {"completion_tokens": 5}}
|
||||
if sibling_router_texts and kwargs["model"] in sibling_router_texts:
|
||||
kwargs["metadata"]["routing_decision"] = {
|
||||
"tier_label": "MEDIUM",
|
||||
"routed_model": f"{kwargs['model']}-pick",
|
||||
}
|
||||
return {
|
||||
"choices": [{"message": {"content": sibling_router_texts[kwargs["model"]]}}],
|
||||
"usage": {"completion_tokens": 5},
|
||||
}
|
||||
return ModelResponse(
|
||||
model=kwargs["model"],
|
||||
choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": shadow_text}}],
|
||||
|
|
@ -1210,29 +1221,36 @@ class TestJobValidation:
|
|||
{"direction": "reverse"},
|
||||
{"baseline_model": "baseline-model"},
|
||||
{"direction": "sideways", "baseline_model": "baseline-model"},
|
||||
{"direction": "reverse", "baseline_model": "baseline-model", "router_names": ("a", "b")},
|
||||
],
|
||||
ids=["reverse-without-baseline", "forward-with-baseline", "unknown-direction"],
|
||||
ids=["reverse-without-baseline", "forward-with-baseline", "unknown-direction", "reverse-with-router-set"],
|
||||
)
|
||||
def test_unsamplable_shapes_are_rejected(self, overrides):
|
||||
with pytest.raises(ValidationError):
|
||||
_job(**overrides)
|
||||
|
||||
def test_shadow_target_follows_direction(self):
|
||||
assert _job().shadow_target == "my-router"
|
||||
assert _reverse_job().shadow_target == "baseline-model"
|
||||
def test_arm_target_follows_direction(self):
|
||||
assert _job().arm_target("my-router") == "my-router"
|
||||
assert _reverse_job().arm_target("my-router") == "baseline-model"
|
||||
|
||||
def test_rows_from_before_router_names_carry_their_set_in_router_name(self):
|
||||
assert _job().arm_router_names == ("my-router",)
|
||||
assert _job(router_names=("my-router", "alt-router")).arm_router_names == ("my-router", "alt-router")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDirection:
|
||||
@pytest.mark.parametrize(
|
||||
"job,routed_by,sampled",
|
||||
"job,routed_by,attempt_rows",
|
||||
[
|
||||
(_job(), None, True),
|
||||
(_job(), "my-router", False),
|
||||
(_job(), "other-router", True),
|
||||
(_reverse_job(), "my-router", True),
|
||||
(_reverse_job(), None, False),
|
||||
(_reverse_job(), "other-router", False),
|
||||
(_job(), None, 1),
|
||||
(_job(), "my-router", 0),
|
||||
(_job(), "other-router", 1),
|
||||
(_reverse_job(), "my-router", 1),
|
||||
(_reverse_job(), None, 0),
|
||||
(_reverse_job(), "other-router", 0),
|
||||
(_job(router_names=("my-router", "alt-router")), "alt-router", 0),
|
||||
(_job(router_names=("my-router", "alt-router")), "other-router", 2),
|
||||
],
|
||||
ids=[
|
||||
"forward-samples-unrouted",
|
||||
|
|
@ -1241,20 +1259,24 @@ class TestDirection:
|
|||
"reverse-samples-its-own-router",
|
||||
"reverse-skips-unrouted",
|
||||
"reverse-skips-another-router",
|
||||
"forward-skips-any-candidates-own-traffic",
|
||||
"forward-multi-samples-once-per-arm",
|
||||
],
|
||||
)
|
||||
async def test_direction_decides_which_traffic_is_sampled(self, job, routed_by, sampled):
|
||||
async def test_direction_decides_which_traffic_is_sampled(self, job, routed_by, attempt_rows):
|
||||
"""The two directions partition the key's traffic: whatever one samples, the other
|
||||
skips, so a key running both never judges the same turn twice for the same reason."""
|
||||
skips, so a key running both never judges the same turn twice for the same reason.
|
||||
A multi-router job extends the forward skip to every candidate: a request one
|
||||
candidate served must not be judged as the incumbent against another candidate."""
|
||||
prisma = _prisma()
|
||||
logger = _logger(router=_router(), prisma=prisma, jobs=(job,))
|
||||
logger = _logger(router=_router(sibling_router_texts={"alt-router": "alt answer"}), prisma=prisma, jobs=(job,))
|
||||
|
||||
await logger.async_log_success_event(
|
||||
_success_kwargs(request_metadata=_routed_by(routed_by) if routed_by else {}), RESPONSE, None, None
|
||||
)
|
||||
await _drain(logger)
|
||||
|
||||
assert prisma.db.litellm_shadowevalattempt.create.await_count == int(sampled)
|
||||
assert prisma.db.litellm_shadowevalattempt.create.await_count == attempt_rows
|
||||
|
||||
async def test_reverse_duplicates_against_the_baseline_model(self):
|
||||
prisma = _prisma()
|
||||
|
|
@ -1316,6 +1338,134 @@ class TestDirection:
|
|||
assert logger._job_starts == {"forward-job": 1, "reverse-job": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMultiRouterArms:
|
||||
async def test_every_arm_judges_the_same_request_and_stamps_its_own_row(self):
|
||||
"""One sampled request, one row per candidate router, both judged against the same
|
||||
real response: the paired comparison that makes multi-router win rates comparable."""
|
||||
prisma = _prisma()
|
||||
router = _router(sibling_router_texts={"alt-router": "alt answer"})
|
||||
logger = _logger(router=router, prisma=prisma)
|
||||
|
||||
await logger._run_shadow_eval(
|
||||
job=_job(router_names=("my-router", "alt-router")),
|
||||
request_id="req-1",
|
||||
messages=({"role": "user", "content": "hi"},),
|
||||
real_text="real answer",
|
||||
real_model="claude-opus",
|
||||
real_cost=0.001,
|
||||
real_classifier_cost=0.0,
|
||||
real_cache_hit=False,
|
||||
control_tier=None,
|
||||
shadow_params={},
|
||||
parent_metadata={},
|
||||
)
|
||||
|
||||
rows = [call.kwargs["data"] for call in prisma.db.litellm_shadowevalattempt.create.await_args_list]
|
||||
assert [row["router_name"] for row in rows] == ["my-router", "alt-router"]
|
||||
assert {row["request_id"] for row in rows} == {"req-1"}
|
||||
assert [row["shadow_model"] for row in rows] == ["cheap-model", "alt-router-pick"]
|
||||
assert all(row["outcome"] in ("real", "shadow", "tie") for row in rows)
|
||||
assert all(row["real_cost"] == 0.001 for row in rows)
|
||||
|
||||
async def test_a_single_router_job_stamps_its_router_on_the_row(self):
|
||||
prisma = _prisma()
|
||||
logger = _logger(router=_router(), prisma=prisma)
|
||||
|
||||
await logger._run_shadow_eval(
|
||||
job=_job(),
|
||||
request_id="req-1",
|
||||
messages=({"role": "user", "content": "hi"},),
|
||||
real_text="real answer",
|
||||
real_model="claude-opus",
|
||||
real_cost=0.0,
|
||||
real_classifier_cost=0.0,
|
||||
real_cache_hit=False,
|
||||
control_tier=None,
|
||||
shadow_params={},
|
||||
parent_metadata={},
|
||||
)
|
||||
|
||||
row = prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"]
|
||||
assert row["router_name"] == "my-router"
|
||||
|
||||
async def test_one_arms_failure_never_silences_the_sibling(self):
|
||||
prisma = _prisma()
|
||||
router = _router(sibling_router_texts={"alt-router": "alt answer"})
|
||||
healthy = router.acompletion.side_effect
|
||||
|
||||
async def first_arm_explodes(**kwargs):
|
||||
if kwargs["model"] == "my-router":
|
||||
raise RuntimeError("provider exploded")
|
||||
return await healthy(**kwargs)
|
||||
|
||||
router.acompletion.side_effect = first_arm_explodes
|
||||
logger = _logger(router=router, prisma=prisma)
|
||||
|
||||
await logger._run_shadow_eval(
|
||||
job=_job(router_names=("my-router", "alt-router")),
|
||||
request_id="req-1",
|
||||
messages=({"role": "user", "content": "hi"},),
|
||||
real_text="real answer",
|
||||
real_model="claude-opus",
|
||||
real_cost=0.0,
|
||||
real_classifier_cost=0.0,
|
||||
real_cache_hit=False,
|
||||
control_tier=None,
|
||||
shadow_params={},
|
||||
parent_metadata={},
|
||||
)
|
||||
|
||||
rows = [call.kwargs["data"] for call in prisma.db.litellm_shadowevalattempt.create.await_args_list]
|
||||
assert [row["router_name"] for row in rows] == ["my-router", "alt-router"]
|
||||
assert rows[0]["outcome"] == "error"
|
||||
assert "provider exploded" in rows[0]["error"]
|
||||
assert rows[1]["outcome"] in ("real", "shadow", "tie")
|
||||
|
||||
async def test_the_turn_valve_counts_every_arm_a_start_will_write(self):
|
||||
"""max_turns is a row ceiling and one sampled request writes one row per arm, so
|
||||
admission pre-counts the arms: a two-arm job with two turns of budget admits one
|
||||
request, not two."""
|
||||
prisma = _prisma()
|
||||
router = _router(sibling_router_texts={"alt-router": "alt answer"})
|
||||
logger = _logger(
|
||||
router=router, prisma=prisma, jobs=(_job(router_names=("my-router", "alt-router"), max_turns=2),)
|
||||
)
|
||||
|
||||
await logger.async_log_success_event(_success_kwargs(request_id="req-1"), RESPONSE, None, None)
|
||||
await logger.async_log_success_event(_success_kwargs(request_id="req-2"), RESPONSE, None, None)
|
||||
await _drain(logger)
|
||||
|
||||
rows = [call.kwargs["data"] for call in prisma.db.litellm_shadowevalattempt.create.await_args_list]
|
||||
assert {row["request_id"] for row in rows} == {"req-1"}
|
||||
assert len(rows) == 2
|
||||
|
||||
async def test_a_withheld_request_runs_no_arm_and_counts_once(self):
|
||||
"""The budget gates run once per sampled request, before any arm: funnel counters
|
||||
stay per-request, so coverage math is arm-count independent."""
|
||||
prisma = _prisma()
|
||||
router = _router(sibling_router_texts={"alt-router": "alt answer"})
|
||||
logger = _logger(router=router, prisma=prisma)
|
||||
|
||||
await logger._run_shadow_eval(
|
||||
job=_job(router_names=("my-router", "alt-router"), max_budget=1.0, spend=2.0),
|
||||
request_id="req-1",
|
||||
messages=({"role": "user", "content": "hi"},),
|
||||
real_text="real answer",
|
||||
real_model="claude-opus",
|
||||
real_cost=0.0,
|
||||
real_classifier_cost=0.0,
|
||||
real_cache_hit=False,
|
||||
control_tier=None,
|
||||
shadow_params={},
|
||||
parent_metadata={},
|
||||
)
|
||||
|
||||
router.acompletion.assert_not_called()
|
||||
prisma.db.litellm_shadowevalattempt.create.assert_not_called()
|
||||
assert logger._test_funnel == [("job-1", "withheld")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestActiveJobsFailClosed:
|
||||
async def test_a_row_the_sampler_cannot_read_is_dropped_not_guessed(self):
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from litellm.integrations.websearch_interception.handler import (
|
|||
)
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, ProxyException, UserAPIKeyAuth
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.types.utils import CallTypes, LlmProviders
|
||||
|
||||
|
||||
def test_initialize_from_proxy_config():
|
||||
|
|
@ -230,6 +230,124 @@ async def test_execute_search_passes_selected_search_tool_litellm_params(monkeyp
|
|||
assert forwarded_kwargs["max_retries"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("search_tools", "error"),
|
||||
[
|
||||
pytest.param(None, "was not found", id="router-not-configured"),
|
||||
pytest.param(
|
||||
[{"search_tool_name": "other-search", "litellm_params": {"search_provider": "tavily"}}],
|
||||
"was not found",
|
||||
id="requested-tool-not-configured",
|
||||
),
|
||||
pytest.param(
|
||||
[{"search_tool_name": "parallel-search", "litellm_params": "not-a-mapping"}],
|
||||
"does not define a valid search provider",
|
||||
id="invalid-parameters",
|
||||
),
|
||||
pytest.param(
|
||||
[{"search_tool_name": "parallel-search", "litellm_params": {}}],
|
||||
"does not define a valid search provider",
|
||||
id="missing-provider",
|
||||
),
|
||||
pytest.param(
|
||||
[{"search_tool_name": "parallel-search", "litellm_params": {"search_provider": " "}}],
|
||||
"does not define a valid search provider",
|
||||
id="whitespace-provider",
|
||||
),
|
||||
pytest.param(
|
||||
[{"search_tool_name": "parallel-search", "litellm_params": {"search_provider": 123}}],
|
||||
"does not define a valid search provider",
|
||||
id="invalid-provider",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_execute_search_rejects_invalid_explicit_search_tool(monkeypatch, search_tools, error):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(search_tool_name="parallel-search")
|
||||
router = None if search_tools is None else MagicMock(search_tools=search_tools)
|
||||
mock_asearch = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
with pytest.raises(ValueError, match=f"Configured search tool 'parallel-search' {error}"):
|
||||
await logger._execute_search("what is litellm")
|
||||
|
||||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_search_honors_explicit_parallel_search_tool(monkeypatch):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(search_tool_name="parallel-search")
|
||||
router = MagicMock(
|
||||
search_tools=[
|
||||
{
|
||||
"search_tool_name": "other-search",
|
||||
"litellm_params": {"search_provider": "tavily", "api_key": "other-key"},
|
||||
},
|
||||
{
|
||||
"search_tool_name": "parallel-search",
|
||||
"litellm_params": {"search_provider": "parallel_ai", "api_key": "parallel-key"},
|
||||
},
|
||||
],
|
||||
)
|
||||
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
await logger._execute_search("what is litellm")
|
||||
|
||||
mock_asearch.assert_awaited_once_with(
|
||||
query="what is litellm",
|
||||
search_provider="parallel_ai",
|
||||
api_key="parallel-key",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("search_tools", "expected_search_kwargs"),
|
||||
[
|
||||
pytest.param(None, {"search_provider": "perplexity"}, id="router-not-configured"),
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"search_tool_name": "first-search",
|
||||
"litellm_params": {"search_provider": "tavily", "api_key": "first-key"},
|
||||
},
|
||||
{
|
||||
"search_tool_name": "parallel-search",
|
||||
"litellm_params": {"search_provider": "parallel_ai", "api_key": "parallel-key"},
|
||||
},
|
||||
],
|
||||
{"search_provider": "tavily", "api_key": "first-key"},
|
||||
id="first-configured-tool",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_execute_search_preserves_implicit_provider_selection(monkeypatch, search_tools, expected_search_kwargs):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger()
|
||||
router = None if search_tools is None else MagicMock(search_tools=search_tools)
|
||||
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
await logger._execute_search("what is litellm")
|
||||
|
||||
mock_asearch.assert_awaited_once_with(query="what is litellm", **expected_search_kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_search_attributes_spend_to_the_calling_key(monkeypatch):
|
||||
"""An intercepted search is billed and logged against the key that made the LLM request.
|
||||
|
|
@ -397,6 +515,72 @@ async def test_execute_search_enforces_team_search_tool_permission(monkeypatch):
|
|||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("call_type", "web_search_tool"),
|
||||
[
|
||||
pytest.param(
|
||||
CallTypes.acompletion,
|
||||
{"type": "web_search_20250305", "name": "web_search"},
|
||||
id="chat-completion",
|
||||
),
|
||||
pytest.param(CallTypes.responses, {"type": "web_search"}, id="responses"),
|
||||
pytest.param(CallTypes.aresponses, {"type": "web_search"}, id="async-responses"),
|
||||
pytest.param(
|
||||
CallTypes.anthropic_messages,
|
||||
{"type": "web_search_20250305", "name": "web_search"},
|
||||
id="anthropic-messages",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_deployment_hook_dispatcher_propagates_missing_explicit_search_tool(
|
||||
monkeypatch, call_type, web_search_tool
|
||||
):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.utils import async_pre_call_deployment_hook
|
||||
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="parallel-search")
|
||||
mock_asearch = AsyncMock()
|
||||
kwargs = {
|
||||
"model": "bedrock/claude-sonnet-4",
|
||||
"tools": [web_search_tool],
|
||||
"custom_llm_provider": "bedrock",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"llm_router",
|
||||
MagicMock(search_tools=[{"search_tool_name": "other-search", "litellm_params": {"search_provider": "tavily"}}]),
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", [logger])
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
with pytest.raises(ValueError, match="Configured search tool 'parallel-search' was not found"):
|
||||
await async_pre_call_deployment_hook(kwargs=kwargs, call_type=call_type.value)
|
||||
|
||||
assert kwargs["tools"] == [web_search_tool]
|
||||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deployment_hook_skips_explicit_tool_validation_for_non_search_responses(monkeypatch):
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="parallel-search")
|
||||
monkeypatch.setattr(proxy_server, "llm_router", MagicMock(search_tools=[]))
|
||||
|
||||
result = await logger.async_pre_call_deployment_hook(
|
||||
kwargs={
|
||||
"tools": [{"type": "function", "name": "calculator"}],
|
||||
"custom_llm_provider": "bedrock",
|
||||
},
|
||||
call_type=CallTypes.aresponses,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_pre_call_deployment_hook_provider_from_top_level_kwargs():
|
||||
"""Test that async_pre_call_deployment_hook finds custom_llm_provider at top-level kwargs.
|
||||
|
|
|
|||
|
|
@ -1433,3 +1433,50 @@ class TestFlattenTopLevelSchemaCombinators:
|
|||
flatten_top_level_schema_combinators(schema)
|
||||
|
||||
assert schema == snapshot
|
||||
|
||||
|
||||
class TestRequestContainsImageContent:
|
||||
"""One detector for every dialect that reaches pre-routing hooks untranslated."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"part",
|
||||
[
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,aGk="},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGk="}},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_1",
|
||||
"content": [{"type": "image", "source": {"type": "base64", "data": "aGk="}}],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_detects_every_image_dialect_including_tool_results(self, part):
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
|
||||
|
||||
messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}, part]}]
|
||||
assert request_contains_image_content(messages) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"messages",
|
||||
[
|
||||
[{"role": "user", "content": "plain string"}],
|
||||
[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
[{"role": "user", "content": [{"type": "input_audio", "input_audio": {"data": "x"}}]}],
|
||||
[{"role": "user", "content": [{"type": "tool_result", "content": [{"type": "text", "text": "ok"}]}]}],
|
||||
[{"role": "user", "content": None}],
|
||||
[],
|
||||
],
|
||||
)
|
||||
def test_ignores_text_audio_and_degenerate_shapes(self, messages):
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
|
||||
|
||||
assert request_contains_image_content(messages) is False
|
||||
|
||||
def test_hostile_nesting_is_depth_bounded(self):
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
|
||||
|
||||
nested: dict = {"type": "image", "source": {"type": "base64", "data": "aGk="}}
|
||||
for _ in range(50):
|
||||
nested = {"type": "tool_result", "content": [nested]}
|
||||
assert request_contains_image_content([{"role": "user", "content": [nested]}]) is False
|
||||
|
|
|
|||
|
|
@ -347,3 +347,65 @@ class TestNormalizeTranscriptionLanguageToBcp47:
|
|||
)
|
||||
|
||||
assert normalize_transcription_language_to_bcp47(language) == expected
|
||||
|
||||
|
||||
class TestResolveSpeechMediaType:
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_content_type", "response_format", "expected"),
|
||||
[
|
||||
("audio/wav", None, "audio/wav"),
|
||||
("AUDIO/WAV", None, "audio/wav"),
|
||||
("audio/flac; charset=binary", "mp3", "audio/flac"),
|
||||
("application/json", "flac", "audio/flac"),
|
||||
("application/octet-stream", "pcm", "audio/pcm"),
|
||||
(None, "wav", "audio/wav"),
|
||||
(None, "WAV", "audio/wav"),
|
||||
(None, "opus", "audio/opus"),
|
||||
(None, "aac", "audio/aac"),
|
||||
(None, "mp3", "audio/mpeg"),
|
||||
(None, "mp4", "audio/mpeg"),
|
||||
(None, "bogus", "audio/mpeg"),
|
||||
(None, None, "audio/mpeg"),
|
||||
("", None, "audio/mpeg"),
|
||||
],
|
||||
)
|
||||
def test_resolution(self, upstream_content_type, response_format, expected):
|
||||
from litellm.litellm_core_utils.audio_utils.utils import resolve_speech_media_type
|
||||
|
||||
resolved = resolve_speech_media_type(
|
||||
upstream_content_type=upstream_content_type,
|
||||
response_format=response_format,
|
||||
)
|
||||
assert resolved == expected
|
||||
|
||||
|
||||
class TestSpeechMediaTypeFromAudioBytes:
|
||||
@pytest.mark.parametrize(
|
||||
("audio", "expected"),
|
||||
[
|
||||
(b"RIFF\x24\x00\x00\x00WAVEfmt ", "audio/wav"),
|
||||
(b"fLaC\x00\x00\x00\x22", "audio/flac"),
|
||||
(b"OggS" + b"\x00" * 24 + b"OpusHead", "audio/opus"),
|
||||
(b"OggS" + b"\x00" * 24 + b"\x01vorbis", "audio/ogg"),
|
||||
(b"ID3\x04\x00\x00\x00\x00\x00\x00", "audio/mpeg"),
|
||||
(b"\xff\xfb\x90\x64", "audio/mpeg"),
|
||||
(b"\xff\xf3\x80\x00", "audio/mpeg"),
|
||||
(b"\xff\xf1\x50\x80", "audio/aac"),
|
||||
(b"\xff\xf9\x50\x80", "audio/aac"),
|
||||
(b"RIFF\x24\x00\x00\x00AVI LIST", None),
|
||||
(b"\xff\xff\xff\xff\xff\xff", None),
|
||||
(b"\xff\xfb\xf0\x00", None),
|
||||
(b"\xff\xfb\x9c\x00", None),
|
||||
(b"\xff\xeb\x90\x00", None),
|
||||
(b"\xff\xf1\xf4\x80", None),
|
||||
(b"\xff\x00\x00\x00", None),
|
||||
(b"\x00\x01\x02\x03\x04\x05", None),
|
||||
(b"\xff\xfb", None),
|
||||
(b"\xff", None),
|
||||
(b"", None),
|
||||
],
|
||||
)
|
||||
def test_sniffing(self, audio, expected):
|
||||
from litellm.litellm_core_utils.audio_utils.utils import speech_media_type_from_audio_bytes
|
||||
|
||||
assert speech_media_type_from_audio_bytes(audio) == expected
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import base64
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -126,6 +127,48 @@ class TestVertexAITextToSpeechConfig:
|
|||
assert voice_dict == voice_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("audio", "expected_content_type"),
|
||||
[
|
||||
(b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00", "audio/wav"),
|
||||
(b"\xff\xfb\x90\x64\x00\x00\x00\x00", "audio/mpeg"),
|
||||
(b"OggS" + b"\x00" * 24 + b"OpusHead", "audio/opus"),
|
||||
(b"fLaC\x00\x00\x00\x22", "audio/flac"),
|
||||
],
|
||||
)
|
||||
def test_transform_text_to_speech_response_labels_content_type(audio, expected_content_type):
|
||||
raw_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={"audioContent": base64.b64encode(audio).decode()},
|
||||
)
|
||||
|
||||
result = VertexAITextToSpeechConfig().transform_text_to_speech_response(
|
||||
model="vertex_ai/chirp",
|
||||
raw_response=raw_response,
|
||||
logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.response.headers["content-type"] == expected_content_type
|
||||
assert result.response.content == audio
|
||||
|
||||
|
||||
def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled():
|
||||
raw_pcm = b"\x00\x01\x02\x03\x04\x05\x06\x07"
|
||||
raw_response = httpx.Response(
|
||||
status_code=200,
|
||||
json={"audioContent": base64.b64encode(raw_pcm).decode()},
|
||||
)
|
||||
|
||||
result = VertexAITextToSpeechConfig().transform_text_to_speech_response(
|
||||
model="vertex_ai/chirp",
|
||||
raw_response=raw_response,
|
||||
logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert "content-type" not in result.response.headers
|
||||
assert result.response.content == raw_pcm
|
||||
|
||||
|
||||
@patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post")
|
||||
@patch.object(VertexAITextToSpeechConfig, "_ensure_access_token")
|
||||
@patch.object(VertexAITextToSpeechConfig, "_get_token_and_url")
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from prisma.errors import (
|
|||
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER
|
||||
from litellm.exceptions import BudgetExceededError
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
|
||||
|
|
@ -703,23 +704,43 @@ async def test_auth_failure_ip_stamp_does_not_mutate_callers_request_data():
|
|||
assert request_data == {"model": "gpt-4o"}
|
||||
|
||||
|
||||
def _marked_malformed_key_error() -> HTTPException:
|
||||
"""Build the malformed-key 401 as its raise site does: marker stamped on it."""
|
||||
error = HTTPException(status_code=401, detail="LiteLLM Virtual Key expected. Received=test")
|
||||
setattr(error, INVALID_VIRTUAL_KEY_ERROR_MARKER, True)
|
||||
return error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"auth_error,expect_traceback",
|
||||
"auth_error,expect_traceback,expect_level",
|
||||
[
|
||||
pytest.param(
|
||||
ProxyException(
|
||||
message="Authentication Error", type=ProxyErrorTypes.auth_error, param=None, code=401
|
||||
),
|
||||
False,
|
||||
"ERROR",
|
||||
id="expected_401_no_traceback",
|
||||
),
|
||||
pytest.param(ValueError("unexpected internal error"), True, id="unexpected_error_keeps_traceback"),
|
||||
pytest.param(ValueError("unexpected internal error"), True, "ERROR", id="unexpected_error_keeps_traceback"),
|
||||
pytest.param(
|
||||
_marked_malformed_key_error(),
|
||||
False,
|
||||
"WARNING",
|
||||
id="malformed_virtual_key_warning_no_traceback",
|
||||
),
|
||||
pytest.param(
|
||||
HTTPException(status_code=401, detail="LiteLLM Virtual Key expected. Received=test"),
|
||||
False,
|
||||
"ERROR",
|
||||
id="phrase_without_marker_stays_loud",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_handle_authentication_error_traceback_only_for_unexpected_errors(auth_error, expect_traceback, caplog):
|
||||
async def test_handle_authentication_error_traceback_only_for_unexpected_errors(auth_error, expect_traceback, expect_level, caplog):
|
||||
"""Regression for LIT-6043: expected 4xx auth rejections must not format a
|
||||
traceback via logger.exception; unexpected errors must keep it."""
|
||||
traceback via logger.exception; malformed virtual keys log at WARNING."""
|
||||
handler = UserAPIKeyAuthExceptionHandler()
|
||||
|
||||
with (
|
||||
|
|
@ -740,8 +761,8 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
|
|||
try:
|
||||
try:
|
||||
raise auth_error
|
||||
except (ProxyException, ValueError) as caught:
|
||||
with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException):
|
||||
except (ProxyException, ValueError, HTTPException) as caught:
|
||||
with caplog.at_level(expect_level, logger="LiteLLM Proxy"), pytest.raises((ProxyException, HTTPException)):
|
||||
await handler._handle_authentication_error(
|
||||
caught,
|
||||
MagicMock(),
|
||||
|
|
@ -756,3 +777,6 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
|
|||
records = [r for r in caplog.records if "user_api_key_auth(): Exception occured" in r.getMessage()]
|
||||
assert len(records) == 1
|
||||
assert (records[0].exc_info is not None) is expect_traceback
|
||||
assert records[0].levelname == expect_level
|
||||
expected_logger_name = "LiteLLM Proxy.stdout" if expect_level == "WARNING" else "LiteLLM Proxy"
|
||||
assert records[0].name == expected_logger_name
|
||||
|
|
|
|||
|
|
@ -881,6 +881,7 @@ def _leg_record(**overrides: object) -> MagicMock:
|
|||
"target_type": "key",
|
||||
"target_id": "key-hash",
|
||||
"router_name": "my-router",
|
||||
"router_names": (),
|
||||
"direction": "forward",
|
||||
"baseline_model": None,
|
||||
"judge_model": "anthropic/claude-sonnet-5",
|
||||
|
|
@ -931,6 +932,7 @@ def _shadow_prisma(
|
|||
legs=(),
|
||||
agg_rows=None,
|
||||
by_leg_rows=None,
|
||||
by_router_rows=None,
|
||||
known_keys=("key-hash", "key-hash-2"),
|
||||
key_teams=None,
|
||||
known_teams=None,
|
||||
|
|
@ -1030,6 +1032,7 @@ def _shadow_prisma(
|
|||
"target_type",
|
||||
"target_id",
|
||||
"router_name",
|
||||
"router_names",
|
||||
"direction",
|
||||
"baseline_model",
|
||||
"judge_model",
|
||||
|
|
@ -1065,6 +1068,8 @@ def _shadow_prisma(
|
|||
return [{"judged_count": 10, "error_count": 2, "judge_spend": 0.031}]
|
||||
if "SELECT job_id AS grp" in sql:
|
||||
return by_leg_rows if by_leg_rows is not None else []
|
||||
if "COALESCE(a.router_name" in sql:
|
||||
return by_router_rows if by_router_rows is not None else []
|
||||
if 'FROM "LiteLLM_ShadowEvalFunnel"' in sql:
|
||||
return prisma.funnel_rows
|
||||
return agg_rows if agg_rows is not None else []
|
||||
|
|
@ -1121,7 +1126,15 @@ async def test_start_shadow_eval_writes_one_leg_per_key_in_one_statement(monkeyp
|
|||
prisma.db.litellm_shadowevaljob.create_many.assert_awaited_once()
|
||||
rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"]
|
||||
assert [(row["target_type"], row["target_id"]) for row in rows] == [("key", "key-hash"), ("key", "key-hash-2")]
|
||||
assert len({frozenset((k, v) for k, v in row.items() if k not in ("target_id", "id")) for row in rows}) == 1
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
frozenset((k, tuple(v) if isinstance(v, list) else v) for k, v in row.items() if k not in ("target_id", "id"))
|
||||
for row in rows
|
||||
}
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert len({row["id"] for row in rows}) == len(rows)
|
||||
assert len({row["group_id"] for row in rows}) == 1
|
||||
assert all(row["max_turns"] == SHADOW_EVAL_TURN_VALVE and row["created_by"] == "admin" for row in rows)
|
||||
|
|
@ -1138,6 +1151,63 @@ async def test_start_shadow_eval_writes_one_leg_per_key_in_one_statement(monkeyp
|
|||
assert all(target.max_turns == SHADOW_EVAL_TURN_VALVE for target in response.targets)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_shadow_eval_multi_router_writes_the_set_on_every_leg(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A multi-router job stores the full set in router_names and the first router in
|
||||
router_name, so a rolling-deploy pod that predates router_names still runs a valid
|
||||
single-arm eval and its unstamped attempt rows attribute to that first router."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
_configure_anthropic_sdk_judge(monkeypatch)
|
||||
prisma = _shadow_prisma()
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
|
||||
|
||||
response = await start_shadow_eval(
|
||||
_start_request(router_name=None, router_names=("my-router", "classifier-router")), ADMIN
|
||||
)
|
||||
|
||||
rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"]
|
||||
assert all(row["router_name"] == "my-router" for row in rows)
|
||||
assert all(row["router_names"] == ["my-router", "classifier-router"] for row in rows)
|
||||
assert response.router_names == ("my-router", "classifier-router")
|
||||
assert response.router_name == "my-router"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_shadow_eval_rejects_an_unconfigured_router_in_the_set(monkeypatch: pytest.MonkeyPatch):
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
_configure_anthropic_sdk_judge(monkeypatch)
|
||||
prisma = _shadow_prisma()
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
|
||||
|
||||
with pytest.raises(HTTPException, match="not-a-router") as exc:
|
||||
await start_shadow_eval(_start_request(router_name=None, router_names=("my-router", "not-a-router")), ADMIN)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
prisma.db.litellm_shadowevaljob.create_many.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_judge_collision_is_found_on_every_router_of_the_set(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The judge-as-candidate guard walks every candidate router: a judge that serves an
|
||||
arm of the SECOND router still poisons the whole job's win rates."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
_configure_anthropic_sdk_judge(monkeypatch)
|
||||
prisma = _shadow_prisma()
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
|
||||
|
||||
with pytest.raises(HTTPException, match="also an arm") as exc:
|
||||
await start_shadow_eval(_start_request(router_name=None, router_names=("my-router", "sonnet-router")), ADMIN)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
prisma.db.litellm_shadowevaljob.create_many.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_shadow_eval_rejects_an_uncredentialed_sdk_judge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import litellm
|
||||
|
|
@ -1798,6 +1868,63 @@ async def test_get_shadow_eval_job_pools_counts_and_slices_results_per_key(monke
|
|||
assert error_where == {"job_id": {"in": ["leg-1", "leg-2"]}, "outcome": "error"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_shadow_eval_job_slices_results_per_router(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A multi-router job's detail carries one slice per arm, aggregated by the arm
|
||||
stamped on each attempt row, with unstamped legacy rows attributed to the job's own
|
||||
router by the read (the COALESCE against the leg's router_name)."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
def agg(grp: str, wins: int) -> dict[str, object]:
|
||||
return {
|
||||
"grp": grp,
|
||||
"turn_count": 4,
|
||||
"real_wins": 4 - wins,
|
||||
"shadow_wins": wins,
|
||||
"ties": 0,
|
||||
"avg_confidence": 0.8,
|
||||
"real_spend": 0.08,
|
||||
"shadow_spend": 0.02,
|
||||
"cache_hit_turns": 0,
|
||||
}
|
||||
|
||||
prisma = _shadow_prisma(
|
||||
legs=[_leg_record(router_names=("my-router", "alt-router"))],
|
||||
agg_rows=[agg("SIMPLE", 3)],
|
||||
by_router_rows=[agg("my-router", 1), agg("alt-router", 3)],
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
|
||||
response = await get_shadow_eval_job("job-1", VIEWER)
|
||||
|
||||
assert response.router_names == ("my-router", "alt-router")
|
||||
assert response.router_name == "my-router"
|
||||
assert [(s.group, s.shadow_win_rate_pct) for s in response.results.by_router] == [
|
||||
("my-router", 25.0),
|
||||
("alt-router", 75.0),
|
||||
]
|
||||
router_sql = next(
|
||||
call.args[0] for call in prisma.db.query_raw.await_args_list if "COALESCE(a.router_name" in call.args[0]
|
||||
)
|
||||
assert "COALESCE(a.router_name, j.router_name)" in router_sql
|
||||
assert 'JOIN "LiteLLM_ShadowEvalJob" j ON j.id = a.job_id' in router_sql
|
||||
assert "a.job_id = ANY($1::text[])" in router_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_responses_resolve_router_names_with_legacy_fallback(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Rows from before router_names existed carry their whole set in router_name."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
prisma = _shadow_prisma(legs=[_leg_record(router_names=())])
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
|
||||
response = await get_shadow_eval_job("job-1", VIEWER)
|
||||
|
||||
assert response.router_names == ("my-router",)
|
||||
assert response.router_name == "my-router"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_shadow_eval_job_404s_and_gates_on_role(monkeypatch: pytest.MonkeyPatch):
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
|
|
|||
|
|
@ -14142,6 +14142,311 @@ async def test_info_key_fn_v2_budget_table_fallback(monkeypatch):
|
|||
mock_prisma_client.db.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_reports_budget_limits_usage(monkeypatch):
|
||||
"""
|
||||
/key/info reports current-window spend per budget window under budget_limits_usage,
|
||||
keyed by budget_duration and read from the same counter enforcement uses, while
|
||||
budget_limits itself comes back exactly as stored.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import LiteLLM_VerificationToken
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
test_key_token = "hashed_token_window_test"
|
||||
budget_limits = [
|
||||
{
|
||||
"reset_at": "2026-08-15T18:00:00+00:00",
|
||||
"max_budget": 2.0,
|
||||
"budget_duration": "1h",
|
||||
}
|
||||
]
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_user_api_key_cache = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
|
||||
)
|
||||
mock_get_current_spend = AsyncMock(return_value=0.73)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
mock_key_info = MagicMock(spec=LiteLLM_VerificationToken)
|
||||
mock_key_info.token = test_key_token
|
||||
mock_key_info.object_permission_id = None
|
||||
mock_key_info.user_id = "user-w"
|
||||
mock_key_info.team_id = None
|
||||
mock_key_info.litellm_budget_table = None
|
||||
mock_key_info.model_dump.return_value = {
|
||||
"token": test_key_token,
|
||||
"budget_limits": [dict(w) for w in budget_limits],
|
||||
"user_id": "user-w",
|
||||
"team_id": None,
|
||||
"object_permission_id": None,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_key_info.dict.return_value = mock_key_info.model_dump.return_value
|
||||
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=mock_key_info
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-test-window-key",
|
||||
)
|
||||
|
||||
result = await info_key_fn(
|
||||
key="sk-test-window-key",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert result["info"]["budget_limits"] == budget_limits
|
||||
assert result["info"]["budget_limits_usage"] == {"1h": {"current_spend": 0.73}}
|
||||
|
||||
mock_get_current_spend.assert_awaited_once()
|
||||
call_kwargs = mock_get_current_spend.await_args.kwargs
|
||||
assert call_kwargs["counter_key"] == f"spend:key:{test_key_token}:window:1h"
|
||||
assert call_kwargs["max_budget"] == 2.0
|
||||
assert call_kwargs["window_entity_type"] == "Key"
|
||||
assert call_kwargs["window_entity_id"] == test_key_token
|
||||
assert call_kwargs["window_duration"] == "1h"
|
||||
assert call_kwargs["window_start"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_no_budget_limits_skips_spend_lookup(monkeypatch):
|
||||
"""Keys without budget windows get no budget_limits_usage field and trigger no spend lookup."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import LiteLLM_VerificationToken
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
test_key_token = "hashed_token_no_windows"
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_user_api_key_cache = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
|
||||
)
|
||||
mock_get_current_spend = AsyncMock(return_value=0.0)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
mock_key_info = MagicMock(spec=LiteLLM_VerificationToken)
|
||||
mock_key_info.token = test_key_token
|
||||
mock_key_info.object_permission_id = None
|
||||
mock_key_info.user_id = "user-nw"
|
||||
mock_key_info.team_id = None
|
||||
mock_key_info.litellm_budget_table = None
|
||||
mock_key_info.model_dump.return_value = {
|
||||
"token": test_key_token,
|
||||
"budget_limits": None,
|
||||
"user_id": "user-nw",
|
||||
"team_id": None,
|
||||
"object_permission_id": None,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_key_info.dict.return_value = mock_key_info.model_dump.return_value
|
||||
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=mock_key_info
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-test-no-window-key",
|
||||
)
|
||||
|
||||
result = await info_key_fn(
|
||||
key="sk-test-no-window-key",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert result["info"]["budget_limits"] is None
|
||||
assert "budget_limits_usage" not in result["info"]
|
||||
mock_get_current_spend.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_v2_reports_budget_limits_usage(monkeypatch):
|
||||
"""/v2/key/info reports budget_limits_usage per window and leaves budget_limits as stored."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import KeyRequest, LiteLLM_VerificationToken
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
info_key_fn_v2,
|
||||
)
|
||||
|
||||
test_key_token = "hashed_token_v2_window_test"
|
||||
budget_limits = [
|
||||
{
|
||||
"reset_at": "2026-08-15T18:00:00+00:00",
|
||||
"max_budget": 2.0,
|
||||
"budget_duration": "1h",
|
||||
},
|
||||
{
|
||||
"reset_at": "2026-08-16T00:00:00+00:00",
|
||||
"max_budget": 20.0,
|
||||
"budget_duration": "1d",
|
||||
},
|
||||
]
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_user_api_key_cache = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
|
||||
)
|
||||
mock_get_current_spend = AsyncMock(return_value=1.25)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
mock_key = MagicMock(spec=LiteLLM_VerificationToken)
|
||||
mock_key.token = test_key_token
|
||||
mock_key.user_id = "user-v2-w"
|
||||
mock_key.team_id = None
|
||||
mock_key.model_dump.return_value = {
|
||||
"token": test_key_token,
|
||||
"budget_limits": [dict(w) for w in budget_limits],
|
||||
"user_id": "user-v2-w",
|
||||
"team_id": None,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_key.dict.return_value = mock_key.model_dump.return_value
|
||||
|
||||
mock_prisma_client.get_data = AsyncMock(return_value=[mock_key])
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-admin-v2-w",
|
||||
)
|
||||
|
||||
result = await info_key_fn_v2(
|
||||
data=KeyRequest(keys=[test_key_token]),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert len(result["info"]) == 1
|
||||
assert result["info"][0]["budget_limits"] == budget_limits
|
||||
assert result["info"][0]["budget_limits_usage"] == {
|
||||
"1h": {"current_spend": 1.25},
|
||||
"1d": {"current_spend": 1.25},
|
||||
}
|
||||
assert mock_get_current_spend.await_count == 2
|
||||
counter_keys = {
|
||||
call.kwargs["counter_key"] for call in mock_get_current_spend.await_args_list
|
||||
}
|
||||
assert counter_keys == {
|
||||
f"spend:key:{test_key_token}:window:1h",
|
||||
f"spend:key:{test_key_token}:window:1d",
|
||||
}
|
||||
assert {
|
||||
call.kwargs["window_duration"] for call in mock_get_current_spend.await_args_list
|
||||
} == {"1h", "1d"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_budget_limits_usage_json_string_input(monkeypatch):
|
||||
"""budget_limits stored as a JSON string is parsed and reported per window."""
|
||||
import json as json_module
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_budget_limits_usage,
|
||||
)
|
||||
|
||||
mock_get_current_spend = AsyncMock(return_value=0.5)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
raw = json_module.dumps(
|
||||
[{"budget_duration": "1h", "max_budget": 2.0, "reset_at": None}]
|
||||
)
|
||||
result = await _build_budget_limits_usage(budget_limits=raw, api_key_hash="hash-1")
|
||||
|
||||
assert result == {"1h": {"current_spend": 0.5}}
|
||||
mock_get_current_spend.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_budget_limits_usage_empty_windows_returns_none(monkeypatch):
|
||||
"""A key with no windows (None, [], or "[]") returns None so the field is left off; no spend lookup runs."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_budget_limits_usage,
|
||||
)
|
||||
|
||||
mock_get_current_spend = AsyncMock(return_value=0.0)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
for stored in (None, [], "[]"):
|
||||
assert await _build_budget_limits_usage(budget_limits=stored, api_key_hash="hash-1") is None
|
||||
mock_get_current_spend.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_budget_limits_usage_window_without_max_budget(monkeypatch):
|
||||
"""A window with only budget_duration still reports current_spend, read without a budget ceiling."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_budget_limits_usage,
|
||||
)
|
||||
|
||||
mock_get_current_spend = AsyncMock(return_value=0.75)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
result = await _build_budget_limits_usage(
|
||||
budget_limits=[{"budget_duration": "2d"}], api_key_hash="hash-no-max"
|
||||
)
|
||||
|
||||
assert result == {"2d": {"current_spend": 0.75}}
|
||||
call_kwargs = mock_get_current_spend.await_args.kwargs
|
||||
assert call_kwargs["counter_key"] == "spend:key:hash-no-max:window:2d"
|
||||
assert call_kwargs["window_duration"] == "2d"
|
||||
assert call_kwargs["max_budget"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_budget_limits_usage_pydantic_windows(monkeypatch):
|
||||
"""BudgetLimitEntry windows (the shape UserAPIKeyAuth carries) are dumped to dicts and reported."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.models.team import BudgetLimitEntry
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_budget_limits_usage,
|
||||
)
|
||||
|
||||
mock_get_current_spend = AsyncMock(return_value=1.0)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
|
||||
)
|
||||
|
||||
result = await _build_budget_limits_usage(
|
||||
budget_limits=[BudgetLimitEntry(budget_duration="7d", max_budget=10.0)],
|
||||
api_key_hash="hash-2",
|
||||
)
|
||||
|
||||
assert result == {"7d": {"current_spend": 1.0}}
|
||||
call_kwargs = mock_get_current_spend.await_args.kwargs
|
||||
assert call_kwargs["counter_key"] == "spend:key:hash-2:window:7d"
|
||||
assert call_kwargs["window_duration"] == "7d"
|
||||
assert call_kwargs["max_budget"] == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_reads_the_configured_budget_model_key(monkeypatch):
|
||||
"""/key/info reads the one counter enforcement reads: the configured budget model.
|
||||
|
|
|
|||
|
|
@ -5462,3 +5462,164 @@ def test_the_marker_check_distinguishes_the_two_route_kinds():
|
|||
builtin = MagicMock(spec=Request)
|
||||
builtin.scope = {"endpoint": llm_passthrough_endpoints.anthropic_proxy_route}
|
||||
assert request_dispatched_to_pass_through_endpoint(builtin) is False
|
||||
|
||||
|
||||
async def _drive_passthrough_request_and_capture_logging(user_api_key_dict: UserAPIKeyAuth) -> tuple[int, object]:
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
def transport_handler(upstream_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
real_handler = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.PassThroughEndpoint,
|
||||
params={"timeout": resolve_pass_through_request_timeout(None)},
|
||||
)
|
||||
cache_dict = litellm.in_memory_llm_clients_cache.cache_dict
|
||||
cache_key = next((key for key, cached in cache_dict.items() if cached is real_handler), None)
|
||||
assert cache_key is not None
|
||||
cache_dict[cache_key] = SimpleNamespace(client=httpx.AsyncClient(transport=httpx.MockTransport(transport_handler)))
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = Headers({})
|
||||
mock_request.query_params = QueryParams({})
|
||||
mock_request.body = AsyncMock(return_value=b'{"model": "gemini-2.0-flash"}')
|
||||
|
||||
captured_data: dict = {}
|
||||
|
||||
async def capture_pre_call_hook(user_api_key_dict, data, call_type):
|
||||
captured_data.update(data)
|
||||
return data
|
||||
|
||||
mock_proxy_logging = MagicMock()
|
||||
mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=capture_pre_call_hook)
|
||||
mock_proxy_logging.post_call_failure_hook = AsyncMock()
|
||||
mock_proxy_logging.post_call_response_headers_hook = AsyncMock(return_value={})
|
||||
mock_proxy_logging.get_proxy_hook = MagicMock(return_value=None)
|
||||
|
||||
try:
|
||||
with patch( # test-quality-ok: proxy_logging_obj is a proxy_server module global read inside pass_through_request; there is no injection seam
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging
|
||||
):
|
||||
response = await pass_through_request(
|
||||
request=mock_request,
|
||||
target="https://upstream.example.test/v1/generate",
|
||||
custom_headers={},
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
finally:
|
||||
cache_dict[cache_key] = real_handler
|
||||
|
||||
return response.status_code, captured_data.get("litellm_logging_obj")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_through_request_wires_team_callbacks():
|
||||
"""LIT-5152 regression: pass_through_request must resolve team-level logging
|
||||
callbacks from key/team metadata and wire them into the Logging object, the
|
||||
same way add_litellm_data_to_request does for normal LLM routes."""
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
team_id="test-team",
|
||||
team_metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success_and_failure",
|
||||
"callback_vars": {
|
||||
"langfuse_public_key": "pk_test",
|
||||
"langfuse_secret_key": "sk_test",
|
||||
"langfuse_host": "https://langfuse.example.test",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(user_api_key_dict)
|
||||
|
||||
assert status_code == 200
|
||||
assert logging_obj is not None
|
||||
assert logging_obj.dynamic_success_callbacks, "team success callbacks not wired into Logging"
|
||||
assert logging_obj.dynamic_failure_callbacks, "team failure callbacks not wired into Logging"
|
||||
assert logging_obj.standard_callback_dynamic_params.get("langfuse_public_key") == "pk_test"
|
||||
assert logging_obj.standard_callback_dynamic_params.get("langfuse_secret_key") == "sk_test"
|
||||
assert logging_obj.standard_callback_dynamic_params.get("langfuse_host") == "https://langfuse.example.test"
|
||||
assert ("langfuse_public_key", "pk_test") in logging_obj._trusted_callback_vars
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_through_request_survives_malformed_team_logging_metadata():
|
||||
"""LIT-5152 fail-open: a malformed team ``logging`` value (here a non-iterable)
|
||||
raises inside callback resolution; the passthrough request must still succeed,
|
||||
just without dynamic callbacks."""
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
team_id="test-team",
|
||||
team_metadata={"logging": 5},
|
||||
)
|
||||
|
||||
status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(user_api_key_dict)
|
||||
|
||||
assert status_code == 200
|
||||
assert logging_obj is not None
|
||||
assert not logging_obj.dynamic_success_callbacks
|
||||
assert not logging_obj.dynamic_failure_callbacks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_through_request_survives_env_reference_in_deprecated_callback_settings():
|
||||
"""LIT-5152 fail-open: the deprecated ``callback_settings`` team metadata skips
|
||||
AddTeamCallback validation, so an ``os.environ/`` callback var would otherwise
|
||||
blow up inside ``Logging.__init__`` and fail the request; the passthrough must
|
||||
instead succeed without dynamic callbacks."""
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
team_id="test-team",
|
||||
team_metadata={
|
||||
"callback_settings": {
|
||||
"success_callback": ["langfuse"],
|
||||
"failure_callback": ["langfuse"],
|
||||
"callback_vars": {
|
||||
"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY",
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY",
|
||||
"langfuse_host": "https://langfuse.example.test",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(user_api_key_dict)
|
||||
|
||||
assert status_code == 200
|
||||
assert logging_obj is not None
|
||||
assert not logging_obj.dynamic_success_callbacks
|
||||
assert not logging_obj.dynamic_failure_callbacks
|
||||
assert not logging_obj.standard_callback_dynamic_params.get("langfuse_public_key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_team_callback_wiring_fails_open_on_operational_error():
|
||||
"""LIT-5152 fail-open: an operational error while resolving callback metadata
|
||||
(e.g. team config lookup hitting a dead secret manager) must not raise; the
|
||||
request proceeds without dynamic callbacks and the error is logged."""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
_resolve_team_callback_wiring,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
class RaisingTeamConfig(ProxyConfig):
|
||||
def load_team_config(self, team_id: str) -> dict:
|
||||
raise RuntimeError("secret manager unavailable")
|
||||
|
||||
wiring = _resolve_team_callback_wiring(
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="test-key", team_id="test-team"),
|
||||
proxy_config=RaisingTeamConfig(),
|
||||
route_description="pass_through_endpoint",
|
||||
)
|
||||
|
||||
assert wiring.success_callbacks is None
|
||||
assert wiring.failure_callbacks is None
|
||||
assert wiring.logging_kwargs is None
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ from __future__ import annotations
|
|||
import io
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_speech(monkeypatch):
|
||||
def patched_speech(monkeypatch, request):
|
||||
upstream_content_type = getattr(request, "param", "audio/mpeg")
|
||||
monkeypatch.setattr(proxy_server, "llm_router", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
|
|
@ -36,15 +39,14 @@ def patched_speech(monkeypatch):
|
|||
|
||||
monkeypatch.setattr(proxy_server, "add_litellm_data_to_request", _add_data)
|
||||
|
||||
class _FakeBinaryResp:
|
||||
async def aiter_bytes(self, chunk_size: int = 8192):
|
||||
async def _gen():
|
||||
yield b"\x00\x01\x02"
|
||||
|
||||
return _gen()
|
||||
|
||||
async def _llm_call():
|
||||
return _FakeBinaryResp()
|
||||
return HttpxBinaryResponseContent(
|
||||
httpx.Response(
|
||||
status_code=200,
|
||||
headers={} if upstream_content_type is None else {"content-type": upstream_content_type},
|
||||
content=b"\x00\x01\x02",
|
||||
)
|
||||
)
|
||||
|
||||
async def _fake_route_request(*args, **kwargs):
|
||||
return _llm_call()
|
||||
|
|
@ -79,6 +81,24 @@ def patched_speech_error(monkeypatch):
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_speech_provider_rejection(monkeypatch, patched_speech_error):
|
||||
import litellm
|
||||
|
||||
async def _raise(*args, **kwargs):
|
||||
raise litellm.BadRequestError(
|
||||
message=(
|
||||
"Gemini TTS only produces raw PCM16 audio, so response_format='mp3' is not supported."
|
||||
" Supported response formats: pcm, wav."
|
||||
),
|
||||
model="gemini-3.1-flash-tts-preview",
|
||||
llm_provider="gemini",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "route_request", _raise)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_transcription(monkeypatch):
|
||||
router = MagicMock()
|
||||
|
|
@ -152,6 +172,35 @@ def test_audio_speech_happy_path(client, auth_as, patched_speech, path):
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("patched_speech", "response_format", "expected_content_type"),
|
||||
[
|
||||
("audio/wav", "wav", "audio/wav"),
|
||||
("audio/flac", "flac", "audio/flac"),
|
||||
("audio/pcm", "pcm", "audio/pcm"),
|
||||
("audio/wav", "mp3", "audio/wav"),
|
||||
("application/json", "flac", "audio/flac"),
|
||||
(None, "wav", "audio/wav"),
|
||||
(None, None, "audio/mpeg"),
|
||||
],
|
||||
indirect=["patched_speech"],
|
||||
)
|
||||
def test_audio_speech_content_type_matches_audio_format(
|
||||
client, auth_as, patched_speech, response_format, expected_content_type
|
||||
):
|
||||
"""Regression for LIT-6482: /v1/audio/speech mislabeled wav/flac/pcm as audio/mpeg."""
|
||||
payload = {
|
||||
"model": "tts-1",
|
||||
"input": "Hi",
|
||||
"voice": "alloy",
|
||||
**({} if response_format is None else {"response_format": response_format}),
|
||||
}
|
||||
with auth_as():
|
||||
response = client.post("/v1/audio/speech", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("content-type", "").split(";")[0] == expected_content_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/audio/speech", "/audio/speech"])
|
||||
def test_audio_speech_error(client, auth_as, patched_speech_error, path):
|
||||
"""Pins ``POST /v1/audio/speech`` and ``POST /audio/speech`` (error)."""
|
||||
|
|
@ -162,6 +211,18 @@ def test_audio_speech_error(client, auth_as, patched_speech_error, path):
|
|||
assert len(response.content) > 0
|
||||
|
||||
|
||||
def test_audio_speech_bad_request_maps_to_400(client, auth_as, patched_speech_provider_rejection):
|
||||
"""Regression for LIT-6501: a BadRequestError from the speech path surfaced as a generic 500."""
|
||||
payload = {"model": "gemini-tts", "input": "Hi", "voice": "Kore", "response_format": "mp3"}
|
||||
with auth_as():
|
||||
response = client.post("/v1/audio/speech", json=payload)
|
||||
assert response.status_code == 400
|
||||
error = response.json()["error"]
|
||||
assert "response_format='mp3'" in error["message"]
|
||||
assert "pcm" in error["message"]
|
||||
assert "wav" in error["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/audio/transcriptions", "/audio/transcriptions"])
|
||||
def test_audio_transcription_happy_path(client, auth_as, patched_transcription, path):
|
||||
"""Pins ``POST /v1/audio/transcriptions`` / ``POST /audio/transcriptions`` (happy)."""
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ def _make_mock_tts_response():
|
|||
inner = MagicMock()
|
||||
inner.aiter_bytes = _aiter_bytes
|
||||
inner._hidden_params = {}
|
||||
inner.response = httpx.Response(status_code=200, headers={"content-type": "audio/mpeg"})
|
||||
|
||||
async def _resolver():
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -10417,3 +10417,323 @@ class TestContextWindowEscalation:
|
|||
|
||||
assert oversized["model_name"] == "big-model"
|
||||
assert small["model_name"] == "small-model"
|
||||
|
||||
|
||||
IMG_PART = {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}}
|
||||
PLAN_BODY = {
|
||||
"messages": [{"role": "system", "content": [{"type": "text", "text": "Plan mode is active. Do not execute."}]}]
|
||||
}
|
||||
|
||||
|
||||
class TestModalityRouting:
|
||||
"""modality_routing: the response gate replaces a routed model that cannot take images."""
|
||||
|
||||
IMAGE_MESSAGE = [{"role": "user", "content": [{"type": "text", "text": "What color is this?"}, IMG_PART]}]
|
||||
BASE_TIERS = {"SIMPLE": "text-cheap", "MEDIUM": "vision-mid", "COMPLEX": "vision-big"}
|
||||
BASE_VISION = {"text-cheap": False, "vision-mid": True, "vision-big": True, "vision-default": True}
|
||||
|
||||
@staticmethod
|
||||
def _router(mock_router_instance, config, vision_by_model):
|
||||
"""vision_by_model: model name -> True/False (deployment model_info) or None (undeclared)."""
|
||||
|
||||
def get_model_list(model_name=None):
|
||||
if model_name not in vision_by_model:
|
||||
return []
|
||||
declared = vision_by_model[model_name]
|
||||
return [
|
||||
{
|
||||
"model_name": model_name,
|
||||
"litellm_params": {"model": f"openai/unmapped-{model_name}"},
|
||||
"model_info": {} if declared is None else {"supports_vision": declared},
|
||||
}
|
||||
]
|
||||
|
||||
mock_router_instance.get_model_list = get_model_list
|
||||
return ComplexityRouter(
|
||||
model_name="modality-test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config_extra, vision, send_image, expected_model, expect_marker",
|
||||
[
|
||||
({}, {"text-cheap": False}, True, "text-cheap", False),
|
||||
({"modality_routing": True}, {"text-cheap": False}, False, "text-cheap", False),
|
||||
({"modality_routing": True}, {"text-cheap": None}, True, "text-cheap", False),
|
||||
],
|
||||
ids=["flag_off", "no_image", "undeclared_model_stays_routable"],
|
||||
)
|
||||
async def test_gate_leaves_ungated_requests_untouched(
|
||||
self, mock_router_instance, config_extra, vision, send_image, expected_model, expect_marker
|
||||
):
|
||||
router = self._router(mock_router_instance, {"tiers": dict(self.BASE_TIERS), **config_extra}, vision)
|
||||
request = self.IMAGE_MESSAGE if send_image else [{"role": "user", "content": "What color is the sky?"}]
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=request)
|
||||
assert result.model == expected_model
|
||||
assert result.routing_decision["cause"] == "heuristic_scorer"
|
||||
assert ("modality:image" in (result.routing_decision.get("signals") or ())) is expect_marker
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"part",
|
||||
[
|
||||
IMG_PART,
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,aGk="},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGk="}},
|
||||
{"type": "tool_result", "tool_use_id": "tu_1", "content": [dict(IMG_PART, type="image")]},
|
||||
],
|
||||
ids=["image_url", "input_image", "anthropic_image", "tool_result_nested"],
|
||||
)
|
||||
async def test_every_image_dialect_escalates(self, mock_router_instance, part):
|
||||
router = self._router(
|
||||
mock_router_instance, {"tiers": dict(self.BASE_TIERS), "modality_routing": True}, dict(self.BASE_VISION)
|
||||
)
|
||||
message = [{"role": "user", "content": [{"type": "text", "text": "What color is this?"}, part]}]
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=message)
|
||||
assert result.model == "vision-mid"
|
||||
assert result.routing_decision["cause"] == "modality_escalation"
|
||||
assert "modality_escalated_from:SIMPLE" in result.routing_decision["signals"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path, expected_model, expected_cause",
|
||||
[
|
||||
("classifier_escalates", "vision-mid", "modality_escalation"),
|
||||
("same_tier_repick_keeps_cause", "vision-cheap", "heuristic_scorer"),
|
||||
("keyword_tier_escalates", "vision-mid", "modality_escalation"),
|
||||
("no_ask_capable_default_kept", "vision-default", "default_fallback"),
|
||||
("no_ask_text_default_displaced", "vision-mid", "modality_escalation"),
|
||||
("custom_tiers_walk", "premium-model", "modality_escalation"),
|
||||
("pin_kept_bypasses", "text-cheap", "session_affinity_pin"),
|
||||
("pin_replacement_gated", "vision-big", "modality_escalation"),
|
||||
("adaptive_pick_rewritten", "vision-mid", "modality_escalation"),
|
||||
],
|
||||
)
|
||||
async def test_placements_across_decision_paths(self, mock_router_instance, path, expected_model, expected_cause):
|
||||
config = {"tiers": dict(self.BASE_TIERS), "modality_routing": True}
|
||||
vision = dict(self.BASE_VISION)
|
||||
request_kwargs = {}
|
||||
messages = self.IMAGE_MESSAGE
|
||||
if path == "same_tier_repick_keeps_cause":
|
||||
config["tiers"]["SIMPLE"] = ["text-cheap", "vision-cheap"]
|
||||
vision["vision-cheap"] = True
|
||||
with patch( # test-quality-ok: the mixed-pool repick is unreachable deterministically without pinning the first random pick
|
||||
"litellm.router_strategy.complexity_router.complexity_router.random.choice",
|
||||
side_effect=lambda pool: sorted(pool)[0],
|
||||
):
|
||||
router = self._router(mock_router_instance, config, vision)
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=messages)
|
||||
assert result.model == expected_model
|
||||
assert result.routing_decision["cause"] == expected_cause
|
||||
assert result.routing_decision["signals"][-1] == "modality:image"
|
||||
return
|
||||
if path == "keyword_tier_escalates":
|
||||
config["keyword_tier_rules"] = [{"keywords": ["quick lookup"], "tier": "SIMPLE"}]
|
||||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "quick lookup: what is this?"}, IMG_PART]}
|
||||
]
|
||||
elif path == "no_ask_capable_default_kept":
|
||||
config["default_model"] = "vision-default"
|
||||
messages = [{"role": "user", "content": [IMG_PART]}]
|
||||
elif path == "no_ask_text_default_displaced":
|
||||
config["default_model"] = "text-default"
|
||||
vision["text-default"] = False
|
||||
messages = [{"role": "user", "content": [IMG_PART]}]
|
||||
elif path == "custom_tiers_walk":
|
||||
config = {
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "gpt-4o-mini"},
|
||||
"fallback_tier": "cheap",
|
||||
"tier_definitions": [
|
||||
{"name": "cheap", "description": "trivial asks"},
|
||||
{"name": "premium", "description": "hard asks"},
|
||||
],
|
||||
"tiers": {"cheap": "cheap-model", "premium": "premium-model"},
|
||||
"keyword_tier_rules": [{"keywords": ["quick lookup"], "tier": "cheap"}],
|
||||
"modality_routing": True,
|
||||
}
|
||||
vision = {"cheap-model": False, "premium-model": True}
|
||||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "quick lookup: what is this?"}, IMG_PART]}
|
||||
]
|
||||
elif path in ("pin_kept_bypasses", "pin_replacement_gated"):
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value={"model": "text-cheap", "tier": "SIMPLE"})
|
||||
mock_router_instance.cache = cache
|
||||
config["session_affinity"] = True
|
||||
request_kwargs = {"metadata": {"session_id": "s1"}}
|
||||
if path == "pin_replacement_gated":
|
||||
config["tiers"]["MEDIUM"] = "text-mid"
|
||||
vision["text-mid"] = False
|
||||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "LITELLM ESCALATE describe this"}, IMG_PART]}
|
||||
]
|
||||
elif path == "adaptive_pick_rewritten":
|
||||
config["adaptive"] = True
|
||||
mock_router_instance.model_list = []
|
||||
mock_router_instance.model_name_to_deployment_indices = {}
|
||||
router = self._router(mock_router_instance, config, vision)
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs=request_kwargs, messages=messages)
|
||||
assert result.model == expected_model
|
||||
assert result.routing_decision["cause"] == expected_cause
|
||||
if path == "adaptive_pick_rewritten":
|
||||
assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == expected_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_floored_decision_never_falls_to_default_model(self, mock_router_instance):
|
||||
"""An upward-only walk cannot undercut the floor; default_model must not either."""
|
||||
config = {
|
||||
"tiers": {"SIMPLE": "vision-cheap", "MEDIUM": "text-mid"},
|
||||
"default_model": "vision-default",
|
||||
"plan_mode_min_tier": "MEDIUM",
|
||||
"modality_routing": True,
|
||||
}
|
||||
vision = {"vision-cheap": True, "text-mid": False, "vision-default": True}
|
||||
router = self._router(mock_router_instance, config, vision)
|
||||
with pytest.raises(litellm.BadRequestError, match="no model"):
|
||||
await router.async_pre_routing_hook(
|
||||
model="m",
|
||||
request_kwargs={"proxy_server_request": {"body": PLAN_BODY}},
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "plan this"}, IMG_PART]}],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_at_floor_plan_turn_never_falls_to_default_model(self, mock_router_instance):
|
||||
"""A sentinel turn whose classified tier already satisfies the floor keeps its ordinary
|
||||
cause, so the record carries no floor marker; the default arm must still refuse it."""
|
||||
config = {
|
||||
"tiers": {"SIMPLE": "text-a", "MEDIUM": "text-b"},
|
||||
"default_model": "vision-default",
|
||||
"plan_mode_min_tier": "SIMPLE",
|
||||
"modality_routing": True,
|
||||
}
|
||||
vision = {"text-a": False, "text-b": False, "vision-default": True}
|
||||
router = self._router(mock_router_instance, config, vision)
|
||||
with pytest.raises(litellm.BadRequestError, match="no model"):
|
||||
await router.async_pre_routing_hook(
|
||||
model="m",
|
||||
request_kwargs={"proxy_server_request": {"body": PLAN_BODY}},
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "plan this"}, IMG_PART]}],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"default_model, default_vision, expect_error",
|
||||
[(None, None, True), ("text-default", False, True), ("vision-default", True, False)],
|
||||
ids=["no_default", "text_only_default", "vision_default_serves"],
|
||||
)
|
||||
async def test_no_capable_tier_above_uses_default_or_rejects(
|
||||
self, mock_router_instance, default_model, default_vision, expect_error
|
||||
):
|
||||
config = {"tiers": {"SIMPLE": "text-cheap", "COMPLEX": "text-big"}, "modality_routing": True}
|
||||
vision = {"text-cheap": False, "text-big": False}
|
||||
if default_model is not None:
|
||||
config["default_model"] = default_model
|
||||
vision[default_model] = default_vision
|
||||
router = self._router(mock_router_instance, config, vision)
|
||||
if expect_error:
|
||||
with pytest.raises(litellm.BadRequestError, match="no model"):
|
||||
await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.IMAGE_MESSAGE)
|
||||
return
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.IMAGE_MESSAGE)
|
||||
assert result.model == "vision-default"
|
||||
assert result.routing_decision["cause"] == "modality_escalation"
|
||||
assert "modality_escalated_from:SIMPLE" in result.routing_decision["signals"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_deployment_group_is_treated_text_only(self, mock_router_instance):
|
||||
def get_model_list(model_name=None):
|
||||
declared = {"mixed-group": [True, False], "vision-big": [True]}.get(model_name)
|
||||
if declared is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"model_name": model_name,
|
||||
"litellm_params": {"model": f"openai/unmapped-{model_name}-{i}"},
|
||||
"model_info": {"supports_vision": accepts},
|
||||
}
|
||||
for i, accepts in enumerate(declared)
|
||||
]
|
||||
|
||||
mock_router_instance.get_model_list = get_model_list
|
||||
router = ComplexityRouter(
|
||||
model_name="modality-test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": "mixed-group", "COMPLEX": "vision-big"},
|
||||
"modality_routing": True,
|
||||
},
|
||||
)
|
||||
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.IMAGE_MESSAGE)
|
||||
assert result.model == "vision-big"
|
||||
assert result.routing_decision["cause"] == "modality_escalation"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuation_turn_screenshot_escalates_past_the_held_model(self, mock_router_instance):
|
||||
"""classification_mode user_turn replays the held model on continuation turns; a
|
||||
continuation carrying a screenshot must still be re-placed when that model is text-only."""
|
||||
mock_router_instance.cache = DualCache()
|
||||
config = {
|
||||
"tiers": dict(self.BASE_TIERS),
|
||||
"classification_mode": "user_turn",
|
||||
"modality_routing": True,
|
||||
}
|
||||
router = self._router(mock_router_instance, config, dict(self.BASE_VISION))
|
||||
first = await router.async_pre_routing_hook(
|
||||
model="m",
|
||||
request_kwargs={"metadata": {"session_id": "cont-1"}},
|
||||
messages=[{"role": "user", "content": "hi there"}],
|
||||
)
|
||||
assert first.model == "text-cheap"
|
||||
continuation = [
|
||||
{"role": "user", "content": "hi there"},
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": "tu_1", "name": "screenshot", "input": {}}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_1",
|
||||
"content": [{"type": "image", "source": {"type": "base64", "data": "aGk="}}],
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
second = await router.async_pre_routing_hook(
|
||||
model="m", request_kwargs={"metadata": {"session_id": "cont-1"}}, messages=continuation
|
||||
)
|
||||
assert second.model == "vision-mid"
|
||||
assert second.routing_decision["cause"] == "modality_escalation"
|
||||
assert "modality_escalated_from:SIMPLE" in second.routing_decision["signals"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_carries_the_context_escalation_record(self, mock_router_instance):
|
||||
"""A context-window escalation and a modality re-place are separate facts on one
|
||||
record; rewriting for the image must not drop the sibling gate's fields."""
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
|
||||
router = self._router(
|
||||
mock_router_instance,
|
||||
{"tiers": dict(self.BASE_TIERS), "modality_routing": True},
|
||||
dict(self.BASE_VISION),
|
||||
)
|
||||
decision = router._build_routing_decision(
|
||||
routed_model="text-cheap",
|
||||
cause="heuristic_scorer",
|
||||
tier=ComplexityTier.SIMPLE,
|
||||
context_escalation_original_tier=ComplexityTier.SIMPLE,
|
||||
)
|
||||
response = PreRoutingHookResponse(model="text-cheap", messages=None, routing_decision=decision)
|
||||
rewritten = await router._gate_response_modality(response, None, self.IMAGE_MESSAGE, {})
|
||||
assert rewritten.model == "vision-mid"
|
||||
assert rewritten.routing_decision["cause"] == "modality_escalation"
|
||||
assert rewritten.routing_decision["context_escalated"] is True
|
||||
assert rewritten.routing_decision["context_escalation_original_tier"] == "SIMPLE"
|
||||
|
||||
def test_modality_escalation_is_never_pinnable(self):
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _decision_is_pinnable
|
||||
|
||||
assert _decision_is_pinnable({"cause": "modality_escalation"}) is False
|
||||
assert _decision_is_pinnable({"cause": "heuristic_scorer"}) is True
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import inspect
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -600,6 +601,72 @@ def test_reconnect_kwargs_in_cluster_kwargs():
|
|||
assert "socket_keepalive" in kwargs
|
||||
|
||||
|
||||
def test_retry_attempts_in_cluster_kwargs():
|
||||
"""cluster_error_retry_attempts must survive the cluster kwarg allow-list so
|
||||
operators can bound worst-case retry latency on a Redis Cluster: it was being
|
||||
silently dropped because the allow-list was built from redis.RedisCluster's
|
||||
decorated __init__ without unwrapping it, so getfullargspec saw an empty
|
||||
(self, *args, **kwargs) wrapper signature."""
|
||||
kwargs = _get_redis_cluster_kwargs()
|
||||
assert "cluster_error_retry_attempts" in kwargs
|
||||
|
||||
|
||||
def test_async_only_kwargs_in_cluster_kwargs_when_async_client_requested():
|
||||
"""decode_responses is on the async cluster client's constructor and not the sync
|
||||
one, on every redis-py the matrix covers. Introspecting the sync class regardless
|
||||
of which client is actually built silently drops it for every async cluster caller."""
|
||||
sync_kwargs = _get_redis_cluster_kwargs()
|
||||
async_kwargs = _get_redis_cluster_kwargs(async_redis.RedisCluster)
|
||||
|
||||
assert "decode_responses" not in sync_kwargs
|
||||
assert "decode_responses" in async_kwargs
|
||||
|
||||
|
||||
@patch( # test-quality-ok: redis-py >= 6 keeps no cluster_error_retry_attempts attribute on the built client, so the constructor call is the only place the value is observable
|
||||
"litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class"
|
||||
)
|
||||
def test_async_cluster_forwards_retry_attempts(mock_get_cluster_class):
|
||||
"""Regression: cluster_error_retry_attempts must reach the constructed async
|
||||
cluster client. Silently dropping it removes an operator's only lever for
|
||||
bounding a stuck node's worst-case retry latency, and the client falls back
|
||||
to redis-py's own default (3 retries) instead."""
|
||||
mock_cluster_cls = mock_get_cluster_class.return_value
|
||||
get_redis_async_client(
|
||||
startup_nodes=[{"host": "cluster-node", "port": 6379}],
|
||||
cluster_error_retry_attempts=2,
|
||||
)
|
||||
|
||||
call_kwargs = mock_cluster_cls.call_args[1]
|
||||
assert call_kwargs["cluster_error_retry_attempts"] == 2
|
||||
|
||||
|
||||
def test_async_cluster_passes_async_only_kwargs():
|
||||
"""Regression: decode_responses is an async-cluster-only constructor arg. When
|
||||
the allow-list came from the sync class it was filtered out and values came
|
||||
back as bytes instead of str."""
|
||||
client = get_redis_async_client(
|
||||
startup_nodes=[{"host": "cluster-node", "port": 6379}],
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
assert client.connection_kwargs["decode_responses"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cluster_client", [redis.RedisCluster, async_redis.RedisCluster], ids=["sync", "async"])
|
||||
def test_cluster_kwargs_exclude_variadic_parameters(cluster_client):
|
||||
"""*args / **kwargs are signature placeholders, not connection settings, and
|
||||
must never land in the allow-list regardless of which cluster client is
|
||||
introspected."""
|
||||
variadic = {
|
||||
name
|
||||
for name, param in inspect.signature(cluster_client).parameters.items()
|
||||
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD)
|
||||
}
|
||||
|
||||
leaked = variadic & set(_get_redis_cluster_kwargs(cluster_client))
|
||||
assert not leaked, f"variadic params leaked into the allow-list: {leaked}"
|
||||
|
||||
|
||||
@patch("litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class")
|
||||
def test_async_cluster_sets_reconnect_defaults(mock_get_cluster_class):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1416,6 +1416,26 @@ def test_get_provider_rerank_config():
|
|||
assert isinstance(config, HostedVLLMRerankConfig)
|
||||
|
||||
|
||||
def test_get_provider_text_to_speech_config_vertex_gemini_skips_cloud_tts():
|
||||
"""Regression for LIT-6501: mapping vertex Gemini TTS params through Google Cloud TTS
|
||||
dropped response_format before the speech_to_completion bridge could honor it."""
|
||||
from litellm.llms.vertex_ai.text_to_speech.transformation import VertexAITextToSpeechConfig
|
||||
from litellm.utils import LlmProviders
|
||||
|
||||
assert (
|
||||
ProviderConfigManager.get_provider_text_to_speech_config(
|
||||
model="gemini-2.5-flash-preview-tts", provider=LlmProviders.VERTEX_AI
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert isinstance(
|
||||
ProviderConfigManager.get_provider_text_to_speech_config(
|
||||
model="en-US-Studio-O", provider=LlmProviders.VERTEX_AI
|
||||
),
|
||||
VertexAITextToSpeechConfig,
|
||||
)
|
||||
|
||||
|
||||
# Models that should be skipped during testing
|
||||
OLD_PROVIDERS = ["aleph_alpha", "palm"]
|
||||
SKIP_MODELS = [
|
||||
|
|
@ -5765,3 +5785,33 @@ class TestHuggingFaceConfigFetch:
|
|||
assert _get_max_position_embeddings("some-org/some-model") == 512
|
||||
request_timeout = hf_config_route.calls.last.request.extensions["timeout"]
|
||||
assert request_timeout["read"] == HF_CONFIG_FETCH_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
class TestIsVisionExplicitlyDisabled:
|
||||
"""github_copilot and chatgpt run an OAuth device flow inside get_llm_provider; the
|
||||
explicit-disable lookup must adopt the declared prefix instead of resolving it, exactly
|
||||
as _supports_factory does, or a capability check on a copilot deployment blocks routing
|
||||
on a device-code prompt."""
|
||||
|
||||
@pytest.mark.parametrize("model", ["github_copilot/gpt-4o", "chatgpt/gpt-5"])
|
||||
def test_never_resolves_an_authenticating_prefix(self, model, monkeypatch):
|
||||
from litellm.utils import is_vision_explicitly_disabled
|
||||
|
||||
lookups: list = []
|
||||
|
||||
def _record(*args, **kwargs):
|
||||
lookups.append((args, kwargs))
|
||||
raise RuntimeError("provider resolution must not run for an authenticating provider")
|
||||
|
||||
monkeypatch.setattr(litellm, "get_llm_provider", _record)
|
||||
|
||||
assert is_vision_explicitly_disabled(model) is False
|
||||
assert lookups == []
|
||||
|
||||
def test_explicit_false_detected_and_absent_reads_enabled(self):
|
||||
from litellm.utils import is_vision_explicitly_disabled
|
||||
|
||||
assert (
|
||||
is_vision_explicitly_disabled("fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731") is True
|
||||
)
|
||||
assert is_vision_explicitly_disabled("anthropic/claude-sonnet-4-5") is False
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ const job = (overrides: Partial<ShadowEvalJob> = {}): ShadowEvalJob => ({
|
|||
job_id: "job-1",
|
||||
status: "running",
|
||||
router_name: "claude-auto",
|
||||
router_names: ["claude-auto"],
|
||||
direction: "forward",
|
||||
baseline_model: null,
|
||||
judge_model: "anthropic/claude-sonnet-5",
|
||||
|
|
@ -436,7 +437,7 @@ describe("ShadowEvalSection", () => {
|
|||
await user.click(within(keyList).getByText("prod-alpha"));
|
||||
await user.click(keyInput);
|
||||
await user.click(within(keyList).getByText("staging-beta"));
|
||||
await user.click(screen.getByPlaceholderText("Select an auto-router"));
|
||||
await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers"));
|
||||
await user.click(await screen.findByText("gpt-auto"));
|
||||
|
||||
expect(screen.getByText("Start shadow eval")).toBeDisabled();
|
||||
|
|
@ -449,7 +450,7 @@ describe("ShadowEvalSection", () => {
|
|||
api_key_ids: ["hash-alpha", "hash-beta"],
|
||||
team_ids: [],
|
||||
user_ids: [],
|
||||
router_name: "gpt-auto",
|
||||
router_names: ["gpt-auto"],
|
||||
direction: "forward",
|
||||
shadow_percentage: 10,
|
||||
duration_days: 7,
|
||||
|
|
@ -469,7 +470,7 @@ describe("ShadowEvalSection", () => {
|
|||
await user.click(screen.getByPlaceholderText("Search teams by alias"));
|
||||
const teamList = await screen.findByTestId("paginated-multi-select-list");
|
||||
await user.click(within(teamList).getByText("engineering"));
|
||||
await user.click(screen.getByPlaceholderText("Select an auto-router"));
|
||||
await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers"));
|
||||
await user.click(await screen.findByText("gpt-auto"));
|
||||
await user.click(screen.getByPlaceholderText("Select a judge model"));
|
||||
await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
|
||||
|
|
@ -479,7 +480,7 @@ describe("ShadowEvalSection", () => {
|
|||
api_key_ids: [],
|
||||
team_ids: ["team-eng"],
|
||||
user_ids: [],
|
||||
router_name: "gpt-auto",
|
||||
router_names: ["gpt-auto"],
|
||||
direction: "forward",
|
||||
shadow_percentage: 10,
|
||||
duration_days: 7,
|
||||
|
|
@ -501,7 +502,7 @@ describe("ShadowEvalSection", () => {
|
|||
await user.click(screen.getByPlaceholderText("Search keys by alias"));
|
||||
const keyList = await screen.findByTestId("paginated-multi-select-list");
|
||||
await user.click(within(keyList).getByText("prod-alpha"));
|
||||
await user.click(screen.getByPlaceholderText("Select an auto-router"));
|
||||
await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers"));
|
||||
await user.click(await screen.findByText("gpt-auto"));
|
||||
await user.click(screen.getByPlaceholderText("Select a judge model"));
|
||||
await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
|
||||
|
|
@ -517,7 +518,7 @@ describe("ShadowEvalSection", () => {
|
|||
api_key_ids: ["hash-alpha"],
|
||||
team_ids: [],
|
||||
user_ids: [],
|
||||
router_name: "gpt-auto",
|
||||
router_names: ["gpt-auto"],
|
||||
direction: "reverse",
|
||||
baseline_model: "prod-claude",
|
||||
shadow_percentage: 10,
|
||||
|
|
@ -528,6 +529,119 @@ describe("ShadowEvalSection", () => {
|
|||
expect(start.mutate).toHaveBeenCalledWith(expectedBody);
|
||||
});
|
||||
|
||||
it("submits every picked auto-router so one job compares them on the same traffic", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { start } = mockHooks({});
|
||||
render(<ShadowEvalSection />);
|
||||
|
||||
await user.click(screen.getByPlaceholderText("Search keys by alias"));
|
||||
const keyList = await screen.findByTestId("paginated-multi-select-list");
|
||||
await user.click(within(keyList).getByText("prod-alpha"));
|
||||
const routerInput = screen.getByPlaceholderText("Select up to 4 auto-routers");
|
||||
await user.click(routerInput);
|
||||
await user.click(await screen.findByText("gpt-auto"));
|
||||
await user.click(routerInput);
|
||||
await user.click(await screen.findByText("claude-auto"));
|
||||
expect(
|
||||
screen.getByText("Every router sees the same sampled requests, judged against the same live responses"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByPlaceholderText("Select a judge model"));
|
||||
await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
|
||||
await user.click(screen.getByText("Start shadow eval"));
|
||||
|
||||
const expectedBody = {
|
||||
api_key_ids: ["hash-alpha"],
|
||||
team_ids: [],
|
||||
user_ids: [],
|
||||
router_names: ["gpt-auto", "claude-auto"],
|
||||
direction: "forward",
|
||||
shadow_percentage: 10,
|
||||
duration_days: 7,
|
||||
max_budget: 10,
|
||||
judge_model: "anthropic/claude-sonnet-5",
|
||||
};
|
||||
expect(start.mutate).toHaveBeenCalledWith(expectedBody);
|
||||
});
|
||||
|
||||
it("blocks starting a reverse job with more than one router and says why", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockHooks({});
|
||||
render(<ShadowEvalSection />);
|
||||
|
||||
await user.click(screen.getByPlaceholderText("Search keys by alias"));
|
||||
const keyList = await screen.findByTestId("paginated-multi-select-list");
|
||||
await user.click(within(keyList).getByText("prod-alpha"));
|
||||
const routerInput = screen.getByPlaceholderText("Select up to 4 auto-routers");
|
||||
await user.click(routerInput);
|
||||
await user.click(await screen.findByText("gpt-auto"));
|
||||
await user.click(routerInput);
|
||||
await user.click(await screen.findByText("claude-auto"));
|
||||
await user.click(screen.getByText("Adoption check: key's traffic vs the router"));
|
||||
await user.click(await screen.findByText("Regression check: router's picks vs a baseline"));
|
||||
await user.click(screen.getByPlaceholderText("Select a judge model"));
|
||||
await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
|
||||
await user.click(screen.getByPlaceholderText("Select a baseline model"));
|
||||
await user.click(screen.getByRole("option", { name: /prod-claude/ }));
|
||||
|
||||
expect(screen.getByText("A regression check compares one router to its baseline")).toBeInTheDocument();
|
||||
expect(screen.getByText("Start shadow eval")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders a per-router comparison table only when the job ran several routers", () => {
|
||||
const routerSlice = (group: string, wins: number) => ({
|
||||
group,
|
||||
turn_count: 20,
|
||||
real_win_rate_pct: 100 - wins - 10,
|
||||
shadow_win_rate_pct: wins,
|
||||
tie_rate_pct: 10,
|
||||
avg_judge_confidence: 0.8,
|
||||
real_spend: 0.4,
|
||||
shadow_spend: 0.2,
|
||||
cache_hit_turns: 0,
|
||||
});
|
||||
const base = job();
|
||||
const multi = job({
|
||||
router_names: ["claude-auto", "gpt-auto"],
|
||||
results: { ...base.results!, by_router: [routerSlice("claude-auto", 40), routerSlice("gpt-auto", 70)] },
|
||||
});
|
||||
mockHooks({ jobs: [multi], detailsById: { "job-1": multi } });
|
||||
render(<ShadowEvalSection />);
|
||||
|
||||
expect(screen.getByText("Router")).toBeInTheDocument();
|
||||
const rows = screen.getAllByRole("row").map((row) => row.textContent ?? "");
|
||||
expect(rows.some((text) => text.includes("claude-auto") && text.includes("40.0%"))).toBe(true);
|
||||
expect(rows.some((text) => text.includes("gpt-auto") && text.includes("70.0%"))).toBe(true);
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, element) =>
|
||||
element?.textContent === "Shadowing 10% of prod-alpha traffic via claude-auto, gpt-auto" &&
|
||||
element.tagName === "P",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a job from an older proxy that predates router_names", () => {
|
||||
const legacy = { ...job(), router_names: undefined } as unknown as ShadowEvalJob;
|
||||
mockHooks({ jobs: [legacy], detailsById: { "job-1": legacy } });
|
||||
render(<ShadowEvalSection />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, element) =>
|
||||
element?.textContent === "Shadowing 10% of prod-alpha traffic via claude-auto" && element.tagName === "P",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the per-router table hidden for a single-router job", () => {
|
||||
const base = job();
|
||||
const single = job({ results: { ...base.results!, by_router: [] } });
|
||||
mockHooks({ jobs: [single], detailsById: { "job-1": single } });
|
||||
render(<ShadowEvalSection />);
|
||||
|
||||
expect(screen.queryByText("Router")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flips the arm labels and headline for a reverse job's results", () => {
|
||||
const j = job({ direction: "reverse", baseline_model: "openai/gpt-4o" });
|
||||
mockHooks({ jobs: [j], detailsById: { "job-1": j } });
|
||||
|
|
|
|||
|
|
@ -2,32 +2,21 @@
|
|||
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useAutoRouters, usePlainModelGroups } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import { userOptionLabel } from "@/components/common_components/UserDropdown";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
import { usd } from "./costOptimizationUtils";
|
||||
import { StartForm } from "./ShadowEvalStartForm";
|
||||
import {
|
||||
useShadowEvalJob,
|
||||
useShadowEvalJobs,
|
||||
useStartShadowEval,
|
||||
useStopShadowEval,
|
||||
type ShadowEvalJob,
|
||||
type ShadowEvalJobTarget,
|
||||
|
|
@ -96,17 +85,19 @@ const targetStatus = (job: ShadowEvalJob, target: ShadowEvalJobTarget): string =
|
|||
return target.stopped_at != null ? "stopped" : "running";
|
||||
};
|
||||
|
||||
const jobRouters = (job: ShadowEvalJob): string => (job.router_names ?? [job.router_name]).join(", ");
|
||||
|
||||
const jobHeadline = (job: ShadowEvalJob): React.ReactNode =>
|
||||
job.direction === "reverse" ? (
|
||||
<>
|
||||
Comparing <span className="font-mono text-xs">{job.router_name}</span> to{" "}
|
||||
Comparing <span className="font-mono text-xs">{jobRouters(job)}</span> to{" "}
|
||||
<span className="font-mono text-xs">{job.baseline_model}</span> on {job.shadow_percentage}% of{" "}
|
||||
<span className="font-mono text-xs">{shadowedTargetsLabel(job)}</span> traffic
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Shadowing {job.shadow_percentage}% of <span className="font-mono text-xs">{shadowedTargetsLabel(job)}</span>{" "}
|
||||
traffic via <span className="font-mono text-xs">{job.router_name}</span>
|
||||
traffic via <span className="font-mono text-xs">{jobRouters(job)}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -352,6 +343,11 @@ const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({
|
|||
<CostComparison direction={job.direction} results={results} />
|
||||
</div>
|
||||
<VerdictBar direction={job.direction} results={results} />
|
||||
{(results.by_router ?? []).length > 1 && (
|
||||
<div className="border-b">
|
||||
<SliceTable groupHeader="Router" direction={job.direction} slices={results.by_router ?? []} />
|
||||
</div>
|
||||
)}
|
||||
{results.by_current_model.length > 0 && (
|
||||
<SliceTable
|
||||
groupHeader={job.direction === "reverse" ? "Router pick" : "Compared against"}
|
||||
|
|
@ -410,328 +406,6 @@ const JobResults: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const;
|
||||
|
||||
interface CostMapEntry {
|
||||
litellm_provider?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
const useChatModelNames = (): string[] => {
|
||||
const { data: costMap } = useModelCostMap();
|
||||
return useMemo(() => {
|
||||
if (!costMap) return [];
|
||||
const chatModels = Object.entries(costMap as Record<string, CostMapEntry>)
|
||||
.filter(([, value]) => value?.mode === "chat" && value?.litellm_provider)
|
||||
.map(([key, value]) => (key.startsWith(`${value.litellm_provider}/`) ? key : `${value.litellm_provider}/${key}`));
|
||||
return [...new Set(chatModels)].toSorted((a, b) => a.localeCompare(b));
|
||||
}, [costMap]);
|
||||
};
|
||||
|
||||
const useJudgeModelOptions = (): SearchSelectOption[] => {
|
||||
const chatModels = useChatModelNames();
|
||||
return useMemo(() => {
|
||||
const pinned: SearchSelectOption[] = RECOMMENDED_JUDGE_MODELS.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
sublabel: "Recommended",
|
||||
}));
|
||||
const pinnedNames = new Set<string>(RECOMMENDED_JUDGE_MODELS);
|
||||
const rest = chatModels.filter((model) => !pinnedNames.has(model)).map((model) => ({ label: model, value: model }));
|
||||
return [...pinned, ...rest];
|
||||
}, [chatModels]);
|
||||
};
|
||||
|
||||
const useBaselineModelOptions = (): SearchSelectOption[] => {
|
||||
const configuredGroups = usePlainModelGroups();
|
||||
const chatModels = useChatModelNames();
|
||||
return useMemo(() => {
|
||||
const configured = [...configuredGroups]
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map((model) => ({ label: model, value: model, sublabel: "Configured on this gateway" }));
|
||||
const rest = chatModels
|
||||
.filter((model) => !configuredGroups.has(model))
|
||||
.map((model) => ({ label: model, value: model }));
|
||||
return [...configured, ...rest];
|
||||
}, [configuredGroups, chatModels]);
|
||||
};
|
||||
|
||||
const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[] = [
|
||||
{ value: "forward", label: "Adoption check: key's traffic vs the router" },
|
||||
{ value: "reverse", label: "Regression check: router's picks vs a baseline" },
|
||||
] as const;
|
||||
|
||||
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, string> = {
|
||||
forward:
|
||||
"Duplicates a sampled slice of the selected targets' traffic (keys, teams, or users) through the auto-router and has an LLM judge compare both answers blind. Each target gets its own spend budget. The router's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
|
||||
reverse:
|
||||
"Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each target gets its own spend budget. The baseline's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
|
||||
};
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "1", label: "1 day" },
|
||||
{ value: "3", label: "3 days" },
|
||||
{ value: "7", label: "7 days" },
|
||||
{ value: "14", label: "14 days" },
|
||||
{ value: "30", label: "30 days" },
|
||||
] as const;
|
||||
|
||||
const Field: React.FC<{ label: string; htmlFor?: string; className?: string; children: React.ReactNode }> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
}) => (
|
||||
<div className={`space-y-1.5 ${className ?? ""}`}>
|
||||
<Label htmlFor={htmlFor} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => void }> = ({ value, onChange }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, {
|
||||
selectedKeyAlias: search || null,
|
||||
});
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.keys)
|
||||
.map((key) => ({
|
||||
label: key.key_alias || key.key_name || key.token,
|
||||
value: key.token,
|
||||
sublabel: key.token,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<PaginatedMultiSelect
|
||||
inputId="shadow-eval-key"
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={() => void fetchNextPage()}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isPending}
|
||||
placeholder="Search keys by alias"
|
||||
emptyText="No matching keys"
|
||||
errorText={isError ? "Keys could not be loaded. Refresh the page to retry." : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UserSelect: React.FC<{ value: string[]; onChange: (ids: string[]) => void }> = ({ value, onChange }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers(
|
||||
50,
|
||||
search || undefined,
|
||||
);
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
Array.from(
|
||||
new Map(
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.users)
|
||||
.map((user) => [user.user_id, { label: userOptionLabel(user), value: user.user_id }] as const),
|
||||
).values(),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<PaginatedMultiSelect
|
||||
inputId="shadow-eval-user"
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={() => void fetchNextPage()}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isPending}
|
||||
placeholder="Search users by email"
|
||||
emptyText="No matching users"
|
||||
errorText={isError ? "Users could not be loaded. Refresh the page to retry." : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StartForm: React.FC = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [apiKeyIds, setApiKeyIds] = useState<string[]>([]);
|
||||
const [teamIds, setTeamIds] = useState<string[]>([]);
|
||||
const [userIds, setUserIds] = useState<string[]>([]);
|
||||
const [routerName, setRouterName] = useState("");
|
||||
const [direction, setDirection] = useState<ShadowEvalDirection>("forward");
|
||||
const [baselineModel, setBaselineModel] = useState("");
|
||||
const [percentage, setPercentage] = useState("10");
|
||||
const [durationDays, setDurationDays] = useState("7");
|
||||
const [judgeModel, setJudgeModel] = useState("");
|
||||
const [maxBudget, setMaxBudget] = useState("10");
|
||||
const { data: autoRouters } = useAutoRouters();
|
||||
const judgeModelOptions = useJudgeModelOptions();
|
||||
const baselineModelOptions = useBaselineModelOptions();
|
||||
const start = useStartShadowEval();
|
||||
|
||||
const routerOptions = useMemo<SearchSelectOption[]>(() => {
|
||||
const names = new Set(
|
||||
(autoRouters ?? []).map((deployment) => deployment.model_name).filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
return [...names].toSorted().map((name) => ({ label: name, value: name }));
|
||||
}, [autoRouters]);
|
||||
|
||||
const parsedPct = Number.parseFloat(percentage);
|
||||
const percentageValid = parsedPct >= 0.1 && parsedPct <= 100;
|
||||
const parsedMaxBudget = Number.parseFloat(maxBudget);
|
||||
const maxBudgetValid = parsedMaxBudget >= 0.01 && parsedMaxBudget <= 10000;
|
||||
const baselinePicked = direction === "forward" || baselineModel !== "";
|
||||
const targetsPicked = apiKeyIds.length + teamIds.length + userIds.length > 0;
|
||||
const filled = targetsPicked && [routerName, judgeModel].every((field) => field !== "") && baselinePicked;
|
||||
const boundsValid = percentageValid && maxBudgetValid;
|
||||
const valid = Boolean(accessToken) && filled && boundsValid;
|
||||
const handleStart = () => {
|
||||
const startBody = {
|
||||
api_key_ids: apiKeyIds,
|
||||
team_ids: teamIds,
|
||||
user_ids: userIds,
|
||||
router_name: routerName,
|
||||
direction,
|
||||
...(direction === "reverse" ? { baseline_model: baselineModel } : {}),
|
||||
shadow_percentage: parsedPct,
|
||||
duration_days: Number.parseInt(durationDays, 10),
|
||||
max_budget: parsedMaxBudget,
|
||||
judge_model: judgeModel,
|
||||
};
|
||||
start.mutate(startBody);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-foreground">Start a shadow eval</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{START_FORM_DESCRIPTION[direction]}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Direction">
|
||||
<Select
|
||||
value={direction}
|
||||
onValueChange={(v: string | null) => setDirection(v === "reverse" ? "reverse" : "forward")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{DIRECTION_OPTIONS.find((o) => o.value === direction)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Keys to shadow" htmlFor="shadow-eval-key">
|
||||
<KeySelect value={apiKeyIds} onChange={setApiKeyIds} />
|
||||
</Field>
|
||||
<Field label="Teams to shadow">
|
||||
<TeamMultiSelect value={teamIds} onChange={setTeamIds} placeholder="Search teams by alias" />
|
||||
</Field>
|
||||
<Field label="Users to shadow" htmlFor="shadow-eval-user">
|
||||
<UserSelect value={userIds} onChange={setUserIds} />
|
||||
</Field>
|
||||
<Field label="Auto-router">
|
||||
<SearchSelect
|
||||
options={routerOptions}
|
||||
value={routerName}
|
||||
onValueChange={setRouterName}
|
||||
placeholder="Select an auto-router"
|
||||
emptyText="No auto-routers configured"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Traffic sampled" htmlFor="shadow-eval-pct">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="shadow-eval-pct"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="w-24"
|
||||
value={percentage}
|
||||
onChange={(e) => setPercentage(e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">% of traffic</span>
|
||||
</div>
|
||||
<div>
|
||||
{percentage.trim() !== "" && !percentageValid && (
|
||||
<p className="text-xs text-destructive">Enter a value from 0.1 to 100</p>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Duration">
|
||||
<Select value={durationDays} onValueChange={(v: string | null) => setDurationDays(v ?? "7")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DURATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Spend budget">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
className="w-24"
|
||||
value={maxBudget}
|
||||
onChange={(e) => setMaxBudget(e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">max shadow + judge spend, per target</span>
|
||||
</div>
|
||||
{maxBudget.trim() !== "" && !maxBudgetValid && (
|
||||
<p className="text-xs text-destructive">Enter a value from 0.01 to 10000</p>
|
||||
)}
|
||||
</Field>
|
||||
{direction === "reverse" && (
|
||||
<Field label="Baseline model">
|
||||
<SearchSelect
|
||||
options={baselineModelOptions}
|
||||
value={baselineModel}
|
||||
onValueChange={setBaselineModel}
|
||||
placeholder="Select a baseline model"
|
||||
emptyText="No chat models available"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Judge model" className="sm:col-span-2">
|
||||
<SearchSelect
|
||||
options={judgeModelOptions}
|
||||
value={judgeModel}
|
||||
onValueChange={setJudgeModel}
|
||||
placeholder="Select a judge model"
|
||||
emptyText="No chat models available"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button disabled={!valid || start.isPending} onClick={handleStart}>
|
||||
{start.isPending ? "Starting..." : "Start shadow eval"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const previousSummary = (job: ShadowEvalJob): string => {
|
||||
const results = job.results;
|
||||
if (results) return pct(routerMatchedOrBeatPct(job.direction, results));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useAutoRouters, usePlainModelGroups } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import { userOptionLabel } from "@/components/common_components/UserDropdown";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import { useStartShadowEval, type ShadowEvalJob } from "./useShadowEval";
|
||||
|
||||
type ShadowEvalDirection = ShadowEvalJob["direction"];
|
||||
|
||||
const MAX_ROUTERS = 4;
|
||||
|
||||
const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const;
|
||||
|
||||
interface CostMapEntry {
|
||||
litellm_provider?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
const useChatModelNames = (): string[] => {
|
||||
const { data: costMap } = useModelCostMap();
|
||||
return useMemo(() => {
|
||||
if (!costMap) return [];
|
||||
const chatModels = Object.entries(costMap as Record<string, CostMapEntry>)
|
||||
.filter(([, value]) => value?.mode === "chat" && value?.litellm_provider)
|
||||
.map(([key, value]) => (key.startsWith(`${value.litellm_provider}/`) ? key : `${value.litellm_provider}/${key}`));
|
||||
return [...new Set(chatModels)].toSorted((a, b) => a.localeCompare(b));
|
||||
}, [costMap]);
|
||||
};
|
||||
|
||||
const useJudgeModelOptions = (): SearchSelectOption[] => {
|
||||
const chatModels = useChatModelNames();
|
||||
return useMemo(() => {
|
||||
const pinned: SearchSelectOption[] = RECOMMENDED_JUDGE_MODELS.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
sublabel: "Recommended",
|
||||
}));
|
||||
const pinnedNames = new Set<string>(RECOMMENDED_JUDGE_MODELS);
|
||||
const rest = chatModels.filter((model) => !pinnedNames.has(model)).map((model) => ({ label: model, value: model }));
|
||||
return [...pinned, ...rest];
|
||||
}, [chatModels]);
|
||||
};
|
||||
|
||||
const useBaselineModelOptions = (): SearchSelectOption[] => {
|
||||
const configuredGroups = usePlainModelGroups();
|
||||
const chatModels = useChatModelNames();
|
||||
return useMemo(() => {
|
||||
const configured = [...configuredGroups]
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map((model) => ({ label: model, value: model, sublabel: "Configured on this gateway" }));
|
||||
const rest = chatModels
|
||||
.filter((model) => !configuredGroups.has(model))
|
||||
.map((model) => ({ label: model, value: model }));
|
||||
return [...configured, ...rest];
|
||||
}, [configuredGroups, chatModels]);
|
||||
};
|
||||
|
||||
const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[] = [
|
||||
{ value: "forward", label: "Adoption check: key's traffic vs the router" },
|
||||
{ value: "reverse", label: "Regression check: router's picks vs a baseline" },
|
||||
] as const;
|
||||
|
||||
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, string> = {
|
||||
forward:
|
||||
"Duplicates a sampled slice of the selected targets' traffic (keys, teams, or users) through the auto-router and has an LLM judge compare both answers blind. Each target gets its own spend budget. The router's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
|
||||
reverse:
|
||||
"Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each target gets its own spend budget. The baseline's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
|
||||
};
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "1", label: "1 day" },
|
||||
{ value: "3", label: "3 days" },
|
||||
{ value: "7", label: "7 days" },
|
||||
{ value: "14", label: "14 days" },
|
||||
{ value: "30", label: "30 days" },
|
||||
] as const;
|
||||
|
||||
const Field: React.FC<{ label: string; htmlFor?: string; className?: string; children: React.ReactNode }> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
}) => (
|
||||
<div className={`space-y-1.5 ${className ?? ""}`}>
|
||||
<Label htmlFor={htmlFor} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => void }> = ({ value, onChange }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, {
|
||||
selectedKeyAlias: search || null,
|
||||
});
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.keys)
|
||||
.map((key) => ({
|
||||
label: key.key_alias || key.key_name || key.token,
|
||||
value: key.token,
|
||||
sublabel: key.token,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<PaginatedMultiSelect
|
||||
inputId="shadow-eval-key"
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={() => void fetchNextPage()}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isPending}
|
||||
placeholder="Search keys by alias"
|
||||
emptyText="No matching keys"
|
||||
errorText={isError ? "Keys could not be loaded. Refresh the page to retry." : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UserSelect: React.FC<{ value: string[]; onChange: (ids: string[]) => void }> = ({ value, onChange }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers(
|
||||
50,
|
||||
search || undefined,
|
||||
);
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
Array.from(
|
||||
new Map(
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.users)
|
||||
.map((user) => [user.user_id, { label: userOptionLabel(user), value: user.user_id }] as const),
|
||||
).values(),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
return (
|
||||
<PaginatedMultiSelect
|
||||
inputId="shadow-eval-user"
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={() => void fetchNextPage()}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isPending}
|
||||
placeholder="Search users by email"
|
||||
emptyText="No matching users"
|
||||
errorText={isError ? "Users could not be loaded. Refresh the page to retry." : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RouterField: React.FC<{
|
||||
options: SearchSelectOption[];
|
||||
routerNames: string[];
|
||||
onChange: (names: string[]) => void;
|
||||
direction: ShadowEvalDirection;
|
||||
}> = ({ options, routerNames, onChange, direction }) => (
|
||||
<Field label="Auto-routers">
|
||||
<MultiSelect
|
||||
options={options}
|
||||
value={routerNames}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select up to 4 auto-routers"
|
||||
emptyText="No auto-routers configured"
|
||||
/>
|
||||
{routerNames.length > MAX_ROUTERS && (
|
||||
<p className="text-xs text-destructive">Pick at most {MAX_ROUTERS} auto-routers</p>
|
||||
)}
|
||||
{direction === "reverse" && routerNames.length > 1 && (
|
||||
<p className="text-xs text-destructive">A regression check compares one router to its baseline</p>
|
||||
)}
|
||||
{direction === "forward" && routerNames.length > 1 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every router sees the same sampled requests, judged against the same live responses
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
interface StartFormValidityInputs {
|
||||
accessToken: string | null | undefined;
|
||||
apiKeyIds: string[];
|
||||
teamIds: string[];
|
||||
userIds: string[];
|
||||
routerNames: string[];
|
||||
direction: ShadowEvalDirection;
|
||||
baselineModel: string;
|
||||
judgeModel: string;
|
||||
percentage: string;
|
||||
maxBudget: string;
|
||||
}
|
||||
|
||||
const startFormValidity = (inputs: StartFormValidityInputs) => {
|
||||
const parsedPct = Number.parseFloat(inputs.percentage);
|
||||
const percentageValid = parsedPct >= 0.1 && parsedPct <= 100;
|
||||
const parsedMaxBudget = Number.parseFloat(inputs.maxBudget);
|
||||
const maxBudgetValid = parsedMaxBudget >= 0.01 && parsedMaxBudget <= 10000;
|
||||
const baselinePicked = inputs.direction === "forward" || inputs.baselineModel !== "";
|
||||
const targetsPicked = inputs.apiKeyIds.length + inputs.teamIds.length + inputs.userIds.length > 0;
|
||||
const routerCountValid = inputs.routerNames.length >= 1 && inputs.routerNames.length <= MAX_ROUTERS;
|
||||
const routersMatchDirection = inputs.direction === "forward" || inputs.routerNames.length === 1;
|
||||
const routersValid = routerCountValid && routersMatchDirection;
|
||||
const modelsPicked = routersValid && inputs.judgeModel !== "" && baselinePicked;
|
||||
const filled = targetsPicked && modelsPicked;
|
||||
const boundsValid = percentageValid && maxBudgetValid;
|
||||
const valid = Boolean(inputs.accessToken) && filled && boundsValid;
|
||||
return { parsedPct, parsedMaxBudget, percentageValid, maxBudgetValid, valid };
|
||||
};
|
||||
|
||||
interface StartBodyInputs {
|
||||
apiKeyIds: string[];
|
||||
teamIds: string[];
|
||||
userIds: string[];
|
||||
routerNames: string[];
|
||||
direction: ShadowEvalDirection;
|
||||
baselineModel: string;
|
||||
shadowPercentage: number;
|
||||
durationDays: number;
|
||||
maxBudget: number;
|
||||
judgeModel: string;
|
||||
}
|
||||
|
||||
const buildStartBody = (inputs: StartBodyInputs) => ({
|
||||
api_key_ids: inputs.apiKeyIds,
|
||||
team_ids: inputs.teamIds,
|
||||
user_ids: inputs.userIds,
|
||||
router_names: inputs.routerNames,
|
||||
direction: inputs.direction,
|
||||
...(inputs.direction === "reverse" ? { baseline_model: inputs.baselineModel } : {}),
|
||||
shadow_percentage: inputs.shadowPercentage,
|
||||
duration_days: inputs.durationDays,
|
||||
max_budget: inputs.maxBudget,
|
||||
judge_model: inputs.judgeModel,
|
||||
});
|
||||
|
||||
export const StartForm: React.FC = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [apiKeyIds, setApiKeyIds] = useState<string[]>([]);
|
||||
const [teamIds, setTeamIds] = useState<string[]>([]);
|
||||
const [userIds, setUserIds] = useState<string[]>([]);
|
||||
const [routerNames, setRouterNames] = useState<string[]>([]);
|
||||
const [direction, setDirection] = useState<ShadowEvalDirection>("forward");
|
||||
const [baselineModel, setBaselineModel] = useState("");
|
||||
const [percentage, setPercentage] = useState("10");
|
||||
const [durationDays, setDurationDays] = useState("7");
|
||||
const [judgeModel, setJudgeModel] = useState("");
|
||||
const [maxBudget, setMaxBudget] = useState("10");
|
||||
const { data: autoRouters } = useAutoRouters();
|
||||
const judgeModelOptions = useJudgeModelOptions();
|
||||
const baselineModelOptions = useBaselineModelOptions();
|
||||
const start = useStartShadowEval();
|
||||
|
||||
const routerOptions = useMemo<SearchSelectOption[]>(() => {
|
||||
const names = new Set(
|
||||
(autoRouters ?? []).map((deployment) => deployment.model_name).filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
return [...names].toSorted().map((name) => ({ label: name, value: name }));
|
||||
}, [autoRouters]);
|
||||
|
||||
const validityInputs: StartFormValidityInputs = {
|
||||
accessToken,
|
||||
apiKeyIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
routerNames,
|
||||
direction,
|
||||
baselineModel,
|
||||
judgeModel,
|
||||
percentage,
|
||||
maxBudget,
|
||||
};
|
||||
const { parsedPct, parsedMaxBudget, percentageValid, maxBudgetValid, valid } = startFormValidity(validityInputs);
|
||||
const handleStart = () => {
|
||||
const bodyInputs: StartBodyInputs = {
|
||||
apiKeyIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
routerNames,
|
||||
direction,
|
||||
baselineModel,
|
||||
shadowPercentage: parsedPct,
|
||||
durationDays: Number.parseInt(durationDays, 10),
|
||||
maxBudget: parsedMaxBudget,
|
||||
judgeModel,
|
||||
};
|
||||
start.mutate(buildStartBody(bodyInputs));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-foreground">Start a shadow eval</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{START_FORM_DESCRIPTION[direction]}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Direction">
|
||||
<Select
|
||||
value={direction}
|
||||
onValueChange={(v: string | null) => setDirection(v === "reverse" ? "reverse" : "forward")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{DIRECTION_OPTIONS.find((o) => o.value === direction)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Keys to shadow" htmlFor="shadow-eval-key">
|
||||
<KeySelect value={apiKeyIds} onChange={setApiKeyIds} />
|
||||
</Field>
|
||||
<Field label="Teams to shadow">
|
||||
<TeamMultiSelect value={teamIds} onChange={setTeamIds} placeholder="Search teams by alias" />
|
||||
</Field>
|
||||
<Field label="Users to shadow" htmlFor="shadow-eval-user">
|
||||
<UserSelect value={userIds} onChange={setUserIds} />
|
||||
</Field>
|
||||
<RouterField
|
||||
options={routerOptions}
|
||||
routerNames={routerNames}
|
||||
onChange={setRouterNames}
|
||||
direction={direction}
|
||||
/>
|
||||
<Field label="Traffic sampled" htmlFor="shadow-eval-pct">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="shadow-eval-pct"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="w-24"
|
||||
value={percentage}
|
||||
onChange={(e) => setPercentage(e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">% of traffic</span>
|
||||
</div>
|
||||
<div>
|
||||
{percentage.trim() !== "" && !percentageValid && (
|
||||
<p className="text-xs text-destructive">Enter a value from 0.1 to 100</p>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Duration">
|
||||
<Select value={durationDays} onValueChange={(v: string | null) => setDurationDays(v ?? "7")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DURATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Spend budget">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
className="w-24"
|
||||
value={maxBudget}
|
||||
onChange={(e) => setMaxBudget(e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">max shadow + judge spend, per target</span>
|
||||
</div>
|
||||
{maxBudget.trim() !== "" && !maxBudgetValid && (
|
||||
<p className="text-xs text-destructive">Enter a value from 0.01 to 10000</p>
|
||||
)}
|
||||
</Field>
|
||||
{direction === "reverse" && (
|
||||
<Field label="Baseline model">
|
||||
<SearchSelect
|
||||
options={baselineModelOptions}
|
||||
value={baselineModel}
|
||||
onValueChange={setBaselineModel}
|
||||
placeholder="Select a baseline model"
|
||||
emptyText="No chat models available"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Judge model" className="sm:col-span-2">
|
||||
<SearchSelect
|
||||
options={judgeModelOptions}
|
||||
value={judgeModel}
|
||||
onValueChange={setJudgeModel}
|
||||
placeholder="Select a judge model"
|
||||
emptyText="No chat models available"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button disabled={!valid || start.isPending} onClick={handleStart}>
|
||||
{start.isPending ? "Starting..." : "Start shadow eval"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
},
|
||||
"classifier_type": "heuristic",
|
||||
"escalation_keywords": ["LITELLM ESCALATE"],
|
||||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
},
|
||||
"classifier_type": "heuristic",
|
||||
"escalation_keywords": ["LITELLM ESCALATE"],
|
||||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
},
|
||||
"classifier_context_window_size": 0,
|
||||
"escalation_keywords": ["LITELLM ESCALATE"],
|
||||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
|
|
@ -72,6 +75,7 @@
|
|||
},
|
||||
"classifier_type": "heuristic",
|
||||
"escalation_keywords": ["LITELLM ESCALATE"],
|
||||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ import { RestrictedSection, restrictedBy } from "./TierRestrictions";
|
|||
import HeuristicScoringConfig from "./HeuristicScoringConfig";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
ClassificationFrequency,
|
||||
ClassifierFallback,
|
||||
ClassifierType,
|
||||
ComplexityRouterConfigValue,
|
||||
classificationFrequency,
|
||||
withClassificationFrequency,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
MIN_QUOTED_CONTEXT_TURN_CHARS,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
|
|
@ -111,7 +114,7 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
|
|||
<strong className="block mb-2 font-semibold">How Classification Works</strong>
|
||||
<span className="text-[13px] text-muted-foreground">{scoringExplanation(value)}</span>
|
||||
{scorerRuns && ranges && (
|
||||
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
|
||||
<ul className="mt-2 pl-5 text-[13px] text-muted-foreground">
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score < {ranges.simpleMedium}
|
||||
</li>
|
||||
|
|
@ -211,6 +214,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null);
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const sessionFrequencyRestriction = restrictedBy(value, "sessionAffinity");
|
||||
const classifierModelMissing =
|
||||
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
|
|
@ -305,6 +309,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
onChange({ ...value, classifier_fallback: fallback });
|
||||
};
|
||||
|
||||
const handleClassificationFrequencyChange = (frequency: ClassificationFrequency) => {
|
||||
onChange(withClassificationFrequency(value, frequency));
|
||||
};
|
||||
|
||||
const handleClassifierContextWindowSizeChange = (windowSize: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
|
|
@ -367,6 +375,49 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="block font-semibold">How often to classify</strong>
|
||||
<RadioGroup
|
||||
value={classificationFrequency(value)}
|
||||
onValueChange={(frequency: unknown) =>
|
||||
handleClassificationFrequencyChange(frequency as ClassificationFrequency)
|
||||
}
|
||||
>
|
||||
<div className="inline-flex flex-col gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="every_request" className="mt-0.5" />
|
||||
<span>
|
||||
<span>Every request</span>{" "}
|
||||
<span className="text-muted-foreground">: score every turn, tool-result continuations included</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="user_turn" className="mt-0.5" />
|
||||
<span>
|
||||
<span>Every new user message</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
: score each new human ask, then hold that tier for the tool calls that follow it
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="session" className="mt-0.5" disabled={Boolean(sessionFrequencyRestriction)} />
|
||||
<span>
|
||||
<span>Once per session</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{sessionFrequencyRestriction?.reason ??
|
||||
": score the first turn only, then hold that tier and its deployment for the whole session"}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Holding the tier keeps an agent on one model for a whole tool loop and cuts scoring cost. A turn the router
|
||||
cannot match to a held decision, such as one with no session id or an expired one, is scored again
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{usesLlmClassifier(classifierType) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@ describe("ComplexityRouterConfig", () => {
|
|||
expect(screen.getByText(/Score > 0.60/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the score threshold list color to the theme instead of an inline style", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
const list = screen.getByText(/Score < 0.15/).closest<HTMLUListElement>("ul");
|
||||
expect(list).toBeInTheDocument();
|
||||
expect(list).toHaveClass("text-muted-foreground");
|
||||
expect(list?.style.color).toBe("");
|
||||
});
|
||||
|
||||
it("should default to heuristic and hide classifier model/timeout fields", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
|
||||
expect(screen.getByText("Advanced: Classification Method")).toBeInTheDocument();
|
||||
|
|
@ -589,6 +598,82 @@ describe("ComplexityRouterConfig classifier fallback", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig classification frequency", () => {
|
||||
const llmValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
};
|
||||
|
||||
it("defaults to every request, matching both backend field defaults", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Every request/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /Every new user message/ })).not.toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /Once per session/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("writes both wire fields when the frequency moves to every new user message", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Every new user message/ }));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...llmValue,
|
||||
classification_mode: "user_turn",
|
||||
session_affinity: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes session affinity, not a classification mode, when the frequency moves to once per session", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Once per session/ }));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...llmValue,
|
||||
classification_mode: "every_request",
|
||||
session_affinity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a hand-authored config that sets both fields as once per session, matching the backend", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={{ ...llmValue, classification_mode: "user_turn", session_affinity: true }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Once per session/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /Every new user message/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("records a switch back to every request", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={{ ...llmValue, classification_mode: "user_turn" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Every new user message/ })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Every request/ }));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ classification_mode: "every_request" }));
|
||||
});
|
||||
|
||||
it("offers the frequency on a heuristic router, where holding the tier still pins the model", () => {
|
||||
// The backend pin is gated on the two fields alone, so a heuristic router that switches models
|
||||
// mid tool loop is fixed by this control too.
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Every new user message/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig classifier rubric", () => {
|
||||
const llmValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
|
|
@ -752,12 +837,12 @@ describe("ComplexityRouterConfig tier labels", () => {
|
|||
});
|
||||
|
||||
describe("ComplexityRouterConfig affinity panel", () => {
|
||||
it("holds both affinity switches with their backend defaults", () => {
|
||||
it("holds the deployment switch at its backend default, session pinning having moved to the frequency choice", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Affinity"));
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
|
||||
expect(screen.queryByRole("switch", { name: "Pin a session to its first model" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes deployment_affinity through onChange without touching other keys", () => {
|
||||
|
|
@ -1282,10 +1367,18 @@ describe("ComplexityRouterConfig tier editing", () => {
|
|||
expect(screen.getByLabelText("Fallback tier")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables session pinning and says why, rather than letting a stripped value look saved", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Affinity"));
|
||||
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
|
||||
it("disables the once-per-session frequency and says why, rather than letting a stripped value look saved", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{ ...customValue, session_affinity: true }}
|
||||
onEditingTiersChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
const sessionOption = screen.getByRole("radio", { name: /Once per session/ });
|
||||
expect(sessionOption).toHaveAttribute("aria-disabled", "true");
|
||||
expect(sessionOption).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import React from "react";
|
|||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import ContextWindowEscalationConfig from "./ContextWindowEscalationConfig";
|
||||
import { Restricted, restrictedBy } from "./TierRestrictions";
|
||||
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
|
||||
import {
|
||||
|
|
@ -55,6 +56,16 @@ export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120;
|
|||
export const DEFAULT_SESSION_AFFINITY = false;
|
||||
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
|
||||
|
||||
export type ClassificationMode = "every_request" | "user_turn";
|
||||
|
||||
export const DEFAULT_CLASSIFICATION_MODE: ClassificationMode = "every_request";
|
||||
|
||||
/**
|
||||
* One operator-facing choice over the two wire fields that share the router's tier-pin machinery:
|
||||
* session affinity pins every turn, user_turn pins every turn except a new human ask.
|
||||
*/
|
||||
export type ClassificationFrequency = ClassificationMode | "session";
|
||||
|
||||
export type ComplexityTiers = {
|
||||
SIMPLE: string[];
|
||||
MEDIUM: string[];
|
||||
|
|
@ -383,6 +394,7 @@ export interface ComplexityRouterConfigValue {
|
|||
classification_prompt?: string;
|
||||
/** Highest tier the scorer may decide alone under heuristic_first. Required by that type, rejected by the others. */
|
||||
heuristic_first_max_tier?: string;
|
||||
classification_mode?: ClassificationMode;
|
||||
session_affinity?: boolean;
|
||||
deployment_affinity?: boolean;
|
||||
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
|
||||
|
|
@ -392,6 +404,13 @@ export interface ComplexityRouterConfigValue {
|
|||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
/**
|
||||
* Context-window escalation gate. Undefined means untouched, which keeps both keys out of the
|
||||
* payload so the router tracks the backend defaults (enabled, 0.95 buffer); an explicit false
|
||||
* is a real opt-out and must survive the edit round-trip.
|
||||
*/
|
||||
enable_context_window_escalation?: boolean;
|
||||
context_window_escalation_buffer?: number;
|
||||
/**
|
||||
* Heuristic scorer knobs. Undefined means the operator never touched them, which keeps the key out of the
|
||||
* payload so the router tracks the backend defaults rather than freezing today's numbers.
|
||||
|
|
@ -412,6 +431,21 @@ export interface ComplexityRouterConfigValue {
|
|||
tier_model_params?: TierModelParamsByTier;
|
||||
}
|
||||
|
||||
/** Session affinity wins where a hand-authored config sets both, matching the backend's own `or`. */
|
||||
export const classificationFrequency = (value: ComplexityRouterConfigValue): ClassificationFrequency => {
|
||||
if (!value.custom_tier_set && (value.session_affinity ?? DEFAULT_SESSION_AFFINITY)) return "session";
|
||||
return value.classification_mode === "user_turn" ? "user_turn" : "every_request";
|
||||
};
|
||||
|
||||
export const withClassificationFrequency = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
frequency: ClassificationFrequency,
|
||||
): ComplexityRouterConfigValue => ({
|
||||
...value,
|
||||
classification_mode: frequency === "user_turn" ? "user_turn" : "every_request",
|
||||
session_affinity: frequency === "session",
|
||||
});
|
||||
|
||||
interface ComplexityRouterConfigProps {
|
||||
modelInfo: ModelGroup[];
|
||||
value: ComplexityRouterConfigValue;
|
||||
|
|
@ -490,23 +524,10 @@ const AffinityControls: React.FC<{
|
|||
/>
|
||||
<strong className="font-semibold">Pin a session to one deployment per model group</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to
|
||||
load-balance every turn.
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.custom_tier_set ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
disabled={Boolean(value.custom_tier_set)}
|
||||
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
|
||||
aria-label="Pin a session to its first model"
|
||||
/>
|
||||
<strong className="font-semibold">Pin a session to its first model</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{restrictedBy(value, "sessionAffinity")?.reason ??
|
||||
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -827,6 +848,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "context-window",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
|
||||
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "response",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const ContextWindowEscalationConfig: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const enabled = value.enable_context_window_escalation ?? true;
|
||||
// A number input renders Number("0.") as "0", so a decimal cannot be typed without a local draft.
|
||||
const [bufferDraft, setBufferDraft] = React.useState<string | null>(null);
|
||||
const commitBuffer = (raw: string) => {
|
||||
setBufferDraft(null);
|
||||
if (raw.trim() === "") {
|
||||
onChange({ ...value, context_window_escalation_buffer: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
onChange({ ...value, context_window_escalation_buffer: Math.min(1, Math.max(0.01, parsed)) });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => onChange({ ...value, enable_context_window_escalation: next })}
|
||||
aria-label="Escalate oversized prompts to a tier that fits"
|
||||
/>
|
||||
<strong className="font-semibold">Escalate oversized prompts to a tier that fits</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
When a prompt provably cannot fit the decided tier's context windows, route it to the lowest tier whose
|
||||
window holds it instead of letting the provider reject it. Off means requests dispatch on complexity alone.
|
||||
</span>
|
||||
{enabled && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label className="block text-sm font-medium mb-1" htmlFor="context-window-escalation-buffer">
|
||||
Window fit buffer
|
||||
</label>
|
||||
<Input
|
||||
id="context-window-escalation-buffer"
|
||||
inputMode="decimal"
|
||||
value={bufferDraft ?? value.context_window_escalation_buffer ?? ""}
|
||||
placeholder="0.95"
|
||||
onChange={(event) => setBufferDraft(event.target.value)}
|
||||
onBlur={(event) => commitBuffer(event.target.value)}
|
||||
/>
|
||||
<span className="block text-xs mt-1 text-muted-foreground">
|
||||
Fraction of a model's window the counted prompt must fit within, above 0 up to 1. Empty tracks the
|
||||
backend default of 0.95.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextWindowEscalationConfig;
|
||||
|
|
@ -362,8 +362,8 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Affinity"));
|
||||
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
|
||||
await user.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(await screen.findByRole("radio", { name: /Once per session/ })).not.toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
|
|
@ -373,6 +373,71 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("carries a context-window escalation opt-out through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-window-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const toggle = await screen.findByRole("switch", { name: "Escalate oversized prompts to a tier that fits" });
|
||||
expect(toggle).toBeChecked();
|
||||
await user.click(toggle);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
enable_context_window_escalation: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the context-window buffer to 1 and keeps an untouched buffer out of the payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-buffer-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const buffer = await screen.findByLabelText("Window fit buffer");
|
||||
fireEvent.change(buffer, { target: { value: "1.5" } });
|
||||
fireEvent.blur(buffer, { target: { value: "1.5" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
const config = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config;
|
||||
expect(config).toMatchObject({ context_window_escalation_buffer: 1 });
|
||||
expect(config).not.toHaveProperty("enable_context_window_escalation");
|
||||
});
|
||||
|
||||
it("clearing the buffer removes it from the payload so the router tracks the backend default", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "ctx-clear-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Context Window Escalation"));
|
||||
const buffer = await screen.findByLabelText("Window fit buffer");
|
||||
fireEvent.change(buffer, { target: { value: "0.8" } });
|
||||
fireEvent.blur(buffer, { target: { value: "0.8" } });
|
||||
fireEvent.change(buffer, { target: { value: "" } });
|
||||
fireEvent.blur(buffer, { target: { value: "" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).not.toHaveProperty(
|
||||
"context_window_escalation_buffer",
|
||||
);
|
||||
});
|
||||
|
||||
// The scalar floor is the one scorer knob with no group dict behind it, so its wiring into the create
|
||||
// payload is only proven end to end. 0 is the case a truthy check would silently drop.
|
||||
it("carries a reasoning override floor of 0 through to the create payload", async () => {
|
||||
|
|
@ -403,8 +468,8 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Affinity"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
|
||||
await user.click(screen.getByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Once per session/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
|
|
@ -414,6 +479,44 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("carries every new user message through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "user-turn-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
classification_mode: "user_turn",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes every_request into the create payload when the default frequency stays selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "default-timing-router");
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(await screen.findByRole("radio", { name: /Every request/ })).toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(
|
||||
vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config.classification_mode,
|
||||
).toBe("every_request");
|
||||
});
|
||||
|
||||
it("defaults a new router to deployment affinity on, matching the backend field default", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
classificationPrompt: complexityRouterConfig.classification_prompt,
|
||||
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
|
||||
classificationMode: complexityRouterConfig.classification_mode,
|
||||
tierLabels: complexityRouterConfig.tier_labels,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
|
|
@ -367,6 +368,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
tokenThresholds: complexityRouterConfig.token_thresholds,
|
||||
dimensionWeights: complexityRouterConfig.dimension_weights,
|
||||
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
|
||||
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
|
||||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe("buildComplexityRouterConfig", () => {
|
|||
const expected = {
|
||||
tiers,
|
||||
classifier_type: "heuristic",
|
||||
classification_mode: "every_request",
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
escalation_keywords: ["LITELLM ESCALATE"],
|
||||
|
|
@ -59,6 +60,16 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(config).toEqual(expected);
|
||||
});
|
||||
|
||||
it("carries an explicit context-window escalation opt-out and buffer, false included", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
enableContextWindowEscalation: false,
|
||||
contextWindowEscalationBuffer: 0.9,
|
||||
});
|
||||
expect(config.enable_context_window_escalation).toBe(false);
|
||||
expect(config.context_window_escalation_buffer).toBe(0.9);
|
||||
});
|
||||
|
||||
it("trims escalation keywords and drops blank entries", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
|
|
@ -780,6 +791,20 @@ describe("heuristic_first", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("classification_mode", () => {
|
||||
it("emits user_turn", () => {
|
||||
const config = buildComplexityRouterConfig({ ...baseParams, classificationMode: "user_turn" });
|
||||
expect(config.classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("writes every_request explicitly, so a saved router never depends on the backend default", () => {
|
||||
expect(
|
||||
buildComplexityRouterConfig({ ...baseParams, classificationMode: "every_request" }).classification_mode,
|
||||
).toBe("every_request");
|
||||
expect(buildComplexityRouterConfig(baseParams).classification_mode).toBe("every_request");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComplexityRouterConfig with an edited tier set", () => {
|
||||
const customTierSet = {
|
||||
tiers: [
|
||||
|
|
@ -892,6 +917,10 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
|
|||
expect(payload.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
|
||||
});
|
||||
|
||||
it("keeps classification_mode, which the backend accepts beside tier_definitions", () => {
|
||||
expect(build({ classificationMode: "user_turn" }).classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("carries the plan-mode floor as the row's name, not the row id the form holds", () => {
|
||||
expect(build({ planModeMinTier: "sec" }).plan_mode_min_tier).toBe("SECURITY_REVIEW");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ import {
|
|||
import {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
ClassificationMode,
|
||||
ClassifierFallback,
|
||||
ClassifierLLMConfig,
|
||||
ClassifierType,
|
||||
ComplexityTierLabels,
|
||||
DEFAULT_CLASSIFICATION_MODE,
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
DimensionWeights,
|
||||
|
|
@ -105,6 +107,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
classifierFallback: ClassifierFallback | undefined;
|
||||
classificationPrompt: string | undefined;
|
||||
heuristicFirstMaxTier: string | undefined;
|
||||
classificationMode: ClassificationMode | undefined;
|
||||
sessionAffinity: boolean;
|
||||
deploymentAffinity: boolean;
|
||||
customTechnicalKeywords: string[];
|
||||
|
|
@ -123,6 +126,8 @@ export interface BuildComplexityRouterConfigParams {
|
|||
dimensionWeights?: DimensionWeights;
|
||||
reasoningOverrideMinScore?: number;
|
||||
tierModelParams?: TierModelParamsByTier;
|
||||
enableContextWindowEscalation?: boolean;
|
||||
contextWindowEscalationBuffer?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,6 +162,7 @@ export interface ComplexityRouterConfigPayload {
|
|||
classifier_fallback?: ClassifierFallback;
|
||||
classification_prompt?: string;
|
||||
heuristic_first_max_tier?: string;
|
||||
classification_mode: ClassificationMode;
|
||||
session_affinity: boolean;
|
||||
deployment_affinity: boolean;
|
||||
custom_technical_keywords?: string[];
|
||||
|
|
@ -174,6 +180,8 @@ export interface ComplexityRouterConfigPayload {
|
|||
token_thresholds?: TokenThresholds;
|
||||
dimension_weights?: DimensionWeights;
|
||||
reasoning_override_min_score?: number;
|
||||
enable_context_window_escalation?: boolean;
|
||||
context_window_escalation_buffer?: number;
|
||||
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
|
||||
}
|
||||
|
||||
|
|
@ -389,6 +397,7 @@ export const buildComplexityRouterConfig = ({
|
|||
classifierFallback,
|
||||
classificationPrompt,
|
||||
heuristicFirstMaxTier,
|
||||
classificationMode,
|
||||
sessionAffinity,
|
||||
deploymentAffinity,
|
||||
customTechnicalKeywords,
|
||||
|
|
@ -407,6 +416,8 @@ export const buildComplexityRouterConfig = ({
|
|||
dimensionWeights,
|
||||
reasoningOverrideMinScore,
|
||||
tierModelParams,
|
||||
enableContextWindowEscalation,
|
||||
contextWindowEscalationBuffer,
|
||||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const serializedTierModelConfigs = customTierSet
|
||||
? serializeTierModelConfigs(
|
||||
|
|
@ -446,6 +457,7 @@ export const buildComplexityRouterConfig = ({
|
|||
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
|
||||
classifier_type: classifierType,
|
||||
...classifierWireFields(effectiveType, classifierInputs),
|
||||
classification_mode: classificationMode ?? DEFAULT_CLASSIFICATION_MODE,
|
||||
session_affinity: sessionAffinity,
|
||||
deployment_affinity: deploymentAffinity,
|
||||
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
|
||||
|
|
@ -463,6 +475,12 @@ export const buildComplexityRouterConfig = ({
|
|||
adaptive_eligible: adaptiveEligible,
|
||||
}),
|
||||
...(returnRawModelName && { return_raw_model_name: true }),
|
||||
...(enableContextWindowEscalation !== undefined && {
|
||||
enable_context_window_escalation: enableContextWindowEscalation,
|
||||
}),
|
||||
...(contextWindowEscalationBuffer !== undefined && {
|
||||
context_window_escalation_buffer: contextWindowEscalationBuffer,
|
||||
}),
|
||||
...scorerKnobs,
|
||||
};
|
||||
if (!customTierSet) return payload;
|
||||
|
|
|
|||
|
|
@ -257,6 +257,33 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig classification mode", () => {
|
||||
it("round-trips a stored user_turn through hydrate then save", () => {
|
||||
const stored = { ...STORED, classification_mode: "user_turn" };
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
|
||||
expect(hydrated.classification_mode).toBe("user_turn");
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("round-trips an explicitly stored every_request, so an untouched save leaves it as written", () => {
|
||||
const stored = { ...STORED, classification_mode: "every_request" };
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
|
||||
expect(hydrated.classification_mode).toBe("every_request");
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).classification_mode).toBe("every_request");
|
||||
});
|
||||
|
||||
it("rewrites a stored user_turn to every_request once the operator picks the default back", () => {
|
||||
const stored = { ...STORED, classification_mode: "user_turn" };
|
||||
const result = buildUpdatedComplexityRouterConfig(stored, {
|
||||
...FORM_VALUE,
|
||||
classification_mode: "every_request",
|
||||
});
|
||||
expect(result.classification_mode).toBe("every_request");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig deployment affinity", () => {
|
||||
it("writes deployment_affinity=false when the toggle is off", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, deployment_affinity: false });
|
||||
|
|
@ -476,6 +503,7 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
classifier_context_budget_chars: 4000,
|
||||
classifier_context_include_assistant_turns: true,
|
||||
classifier_fallback: "default_model",
|
||||
classification_mode: "user_turn",
|
||||
session_affinity: true,
|
||||
deployment_affinity: false,
|
||||
adaptive: true,
|
||||
|
|
@ -487,6 +515,8 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
token_thresholds: { simple: 20, complex: 500 },
|
||||
dimension_weights: { tokenCount: 0.1 },
|
||||
reasoning_override_min_score: 0.3,
|
||||
enable_context_window_escalation: false,
|
||||
context_window_escalation_buffer: 0.9,
|
||||
};
|
||||
|
||||
// tier_definitions, fallback_tier and classification_prompt cannot sit beside heuristic_first, which
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const expectedClassifiedTierConfig = {
|
|||
semantic_keyword_matching: true,
|
||||
embedding_model: "voyage-4-large",
|
||||
match_threshold: 0.65,
|
||||
classification_mode: "every_request",
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
adaptive: true,
|
||||
|
|
@ -68,6 +69,7 @@ const expectedAdaptiveDisabledConfig = {
|
|||
semantic_keyword_matching: true,
|
||||
embedding_model: "voyage-4-large",
|
||||
match_threshold: 0.65,
|
||||
classification_mode: "every_request",
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ describe("EditAutoRouterModal assistant turns", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal session affinity", () => {
|
||||
describe("EditAutoRouterModal classification frequency", () => {
|
||||
beforeEach(() => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
|
@ -381,15 +381,15 @@ describe("EditAutoRouterModal session affinity", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// A stored config with no session_affinity key now runs with affinity OFF, because the backend
|
||||
// field defaults to False. The toggle has to render what the router actually does, and an
|
||||
// untouched save must not flip it.
|
||||
it("shows a stored config with no session_affinity key as off", async () => {
|
||||
// A stored config with neither key now runs with affinity OFF, because both backend fields
|
||||
// default that way. The picker has to render what the router actually does, and an untouched
|
||||
// save must not flip it.
|
||||
it("shows a stored config with neither key as every request", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig(STORED_CONFIG);
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Affinity"));
|
||||
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
expect(await screen.findByRole("radio", { name: /Every request/ })).toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
|
|
@ -397,12 +397,12 @@ describe("EditAutoRouterModal session affinity", () => {
|
|||
expect(savedConfig().session_affinity).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a stored session_affinity=true as on and preserves it through an untouched save", async () => {
|
||||
it("shows a stored session_affinity=true as once per session and preserves it through an untouched save", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Affinity"));
|
||||
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).toBeChecked();
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
expect(await screen.findByRole("radio", { name: /Once per session/ })).toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
|
|
@ -410,12 +410,12 @@ describe("EditAutoRouterModal session affinity", () => {
|
|||
expect(savedConfig().session_affinity).toBe(true);
|
||||
});
|
||||
|
||||
it("persists turning session affinity on", async () => {
|
||||
it("persists picking once per session", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig(STORED_CONFIG);
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Affinity"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Once per session/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
|
|
@ -423,18 +423,71 @@ describe("EditAutoRouterModal session affinity", () => {
|
|||
expect(savedConfig().session_affinity).toBe(true);
|
||||
});
|
||||
|
||||
it("persists turning session affinity back off", async () => {
|
||||
it("persists picking every request back over a stored session pin", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Affinity"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Every request/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().session_affinity).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a stored session pin when the operator moves to every new user message", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().session_affinity).toBe(false);
|
||||
expect(savedConfig().classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("shows a stored user_turn as selected and preserves it through an untouched save", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, classification_mode: "user_turn" });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
expect(await screen.findByRole("radio", { name: /Every new user message/ })).toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("persists switching a stored config to every new user message", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig(STORED_CONFIG);
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().classification_mode).toBe("user_turn");
|
||||
});
|
||||
|
||||
it("rewrites the stored mode to every_request when the operator picks it back", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, classification_mode: "user_turn" });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await user.click(await screen.findByRole("radio", { name: /Every request/ }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().classification_mode).toBe("every_request");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal deployment affinity", () => {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface StoredComplexityRouterConfig {
|
|||
classifier_context_budget_chars?: unknown;
|
||||
classifier_context_include_assistant_turns?: unknown;
|
||||
classifier_fallback?: unknown;
|
||||
classification_mode?: unknown;
|
||||
tier_boundaries?: unknown;
|
||||
token_thresholds?: unknown;
|
||||
dimension_weights?: unknown;
|
||||
|
|
@ -107,6 +108,8 @@ export interface StoredComplexityRouterConfig {
|
|||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
enable_context_window_escalation?: unknown;
|
||||
context_window_escalation_buffer?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,6 +166,10 @@ export const hydrateComplexityRouterConfig = (
|
|||
typeof parsedConfig.heuristic_first_max_tier === "string" && parsedConfig.heuristic_first_max_tier.trim() !== ""
|
||||
? parsedConfig.heuristic_first_max_tier
|
||||
: undefined,
|
||||
classification_mode:
|
||||
parsedConfig.classification_mode === "user_turn" || parsedConfig.classification_mode === "every_request"
|
||||
? parsedConfig.classification_mode
|
||||
: undefined,
|
||||
tier_boundaries: hydrateTierBoundaries(parsedConfig.tier_boundaries),
|
||||
token_thresholds: hydrateTokenThresholds(parsedConfig.token_thresholds),
|
||||
dimension_weights: hydrateDimensionWeights(parsedConfig.dimension_weights),
|
||||
|
|
@ -178,6 +185,14 @@ export const hydrateComplexityRouterConfig = (
|
|||
tier_distance_penalty: parsedConfig.tier_distance_penalty,
|
||||
adaptive_eligible: parsedConfig.adaptive_eligible || "all",
|
||||
return_raw_model_name: parsedConfig.return_raw_model_name || false,
|
||||
enable_context_window_escalation:
|
||||
typeof parsedConfig.enable_context_window_escalation === "boolean"
|
||||
? parsedConfig.enable_context_window_escalation
|
||||
: undefined,
|
||||
context_window_escalation_buffer:
|
||||
typeof parsedConfig.context_window_escalation_buffer === "number"
|
||||
? parsedConfig.context_window_escalation_buffer
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -197,6 +212,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"classifier_fallback",
|
||||
"classification_prompt",
|
||||
"heuristic_first_max_tier",
|
||||
"classification_mode",
|
||||
"session_affinity",
|
||||
"deployment_affinity",
|
||||
"adaptive",
|
||||
|
|
@ -208,6 +224,8 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"token_thresholds",
|
||||
"dimension_weights",
|
||||
"reasoning_override_min_score",
|
||||
"enable_context_window_escalation",
|
||||
"context_window_escalation_buffer",
|
||||
]);
|
||||
|
||||
// Managed only when the caller passes the corresponding state. A caller that does not render
|
||||
|
|
@ -282,6 +300,7 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
planModeMinTier: value.plan_mode_min_tier,
|
||||
classificationPrompt: value.classification_prompt,
|
||||
heuristicFirstMaxTier: value.heuristic_first_max_tier,
|
||||
classificationMode: value.classification_mode,
|
||||
tierLabels: value.tier_labels,
|
||||
classifierType: value.classifier_type,
|
||||
classifierLlmConfig: value.classifier_llm_config,
|
||||
|
|
@ -307,6 +326,8 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
dimensionWeights: value.dimension_weights,
|
||||
reasoningOverrideMinScore: value.reasoning_override_min_score,
|
||||
tierModelParams: value.tier_model_params,
|
||||
enableContextWindowEscalation: value.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: value.context_window_escalation_buffer,
|
||||
};
|
||||
const built = buildComplexityRouterConfig(builderParams);
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,12 @@ describe("RoutingDecisionCard", () => {
|
|||
expect(screen.queryByText("housekeeping")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a modality escalation instead of showing the raw cause token", () => {
|
||||
render(<RoutingDecisionCard decision={{ ...heuristic, cause: "modality_escalation" }} />);
|
||||
expect(screen.getByText("Escalated for image input")).toBeInTheDocument();
|
||||
expect(screen.queryByText("modality_escalation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the escalation keyword", () => {
|
||||
render(
|
||||
<RoutingDecisionCard decision={{ ...heuristic, escalated: true, escalation_keyword: "LITELLM ESCALATE" }} />,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
|
|||
session_affinity_pin: "Pinned to session",
|
||||
session_affinity_escalation: "Escalated from session pin",
|
||||
user_turn_continuation: "Continuation turn, classifier skipped",
|
||||
modality_escalation: "Escalated for image input",
|
||||
quality_tier: "Quality tier mapping",
|
||||
bandit: "Adaptive bandit",
|
||||
default_fallback: "Default model, no route matched",
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -273,6 +274,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -287,6 +289,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -320,6 +323,7 @@ describe("autorouter_presets", () => {
|
|||
const simpleTierConfig = (presetModel: string) => ({
|
||||
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
});
|
||||
|
|
@ -563,6 +567,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
match_threshold: 0,
|
||||
|
|
@ -573,11 +578,46 @@ describe("autorouter_presets", () => {
|
|||
expect(prefill.escalationKeywords).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries a preset's context-window escalation opt-out and buffer through the prefill", () => {
|
||||
const prefill = buildPresetPrefill(
|
||||
{
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
classification_mode: "every_request",
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
enable_context_window_escalation: false,
|
||||
context_window_escalation_buffer: 0.9,
|
||||
},
|
||||
groupsOnly(["gpt-5-nano"]),
|
||||
);
|
||||
expect(prefill.complexityRouterConfig.enable_context_window_escalation).toBe(false);
|
||||
expect(prefill.complexityRouterConfig.context_window_escalation_buffer).toBe(0.9);
|
||||
});
|
||||
|
||||
it("carries a preset's classification_mode and defaults it when the preset omits one", () => {
|
||||
const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
|
||||
const base = {
|
||||
tiers,
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
const availability = groupsOnly(["gpt-5-nano"]);
|
||||
expect(
|
||||
buildPresetPrefill({ ...base, classification_mode: "user_turn" }, availability).complexityRouterConfig
|
||||
.classification_mode,
|
||||
).toBe("user_turn");
|
||||
expect(buildPresetPrefill(base, availability).complexityRouterConfig.classification_mode).toBe("every_request");
|
||||
});
|
||||
|
||||
it("falls back to the defaults when a preset omits match_threshold and escalation_keywords", () => {
|
||||
const prefill = buildPresetPrefill(
|
||||
{
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
},
|
||||
|
|
@ -594,6 +634,7 @@ describe("autorouter_presets", () => {
|
|||
const base = {
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -609,6 +650,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: ["claude-sonnet-4-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -623,6 +665,7 @@ describe("autorouter_presets", () => {
|
|||
REASONING: [{ model_name: "o3", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -642,6 +685,7 @@ describe("autorouter_presets", () => {
|
|||
REASONING: [{ model_name: "claude-sonnet-4-5", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
@ -664,6 +708,9 @@ describe("autorouter_presets", () => {
|
|||
],
|
||||
},
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
|
||||
// temperature survives from the spelling that would otherwise have been overwritten;
|
||||
|
|
@ -677,6 +724,7 @@ describe("autorouter_presets", () => {
|
|||
const config = {
|
||||
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
classification_mode: "every_request" as const,
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ComplexityRouterConfigValue,
|
||||
ClassifierType,
|
||||
ClassifierLLMConfig,
|
||||
DEFAULT_CLASSIFICATION_MODE,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
usesLlmClassifier,
|
||||
|
|
@ -288,6 +289,7 @@ export const buildPresetPrefill = (
|
|||
classifier_context_budget_chars: config.classifier_context_budget_chars,
|
||||
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
|
||||
classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
|
||||
classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE,
|
||||
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
adaptive: config.adaptive,
|
||||
|
|
@ -295,6 +297,8 @@ export const buildPresetPrefill = (
|
|||
tier_distance_penalty: config.tier_distance_penalty,
|
||||
adaptive_eligible: config.adaptive_eligible,
|
||||
return_raw_model_name: config.return_raw_model_name,
|
||||
enable_context_window_escalation: config.enable_context_window_escalation,
|
||||
context_window_escalation_buffer: config.context_window_escalation_buffer,
|
||||
},
|
||||
customTechnicalKeywords: config.custom_technical_keywords ?? [],
|
||||
keywordTierRules: hydrateKeywordTierRules(config.keyword_tier_rules ?? []),
|
||||
|
|
|
|||
47
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
47
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -7710,6 +7710,10 @@ export interface paths {
|
|||
* - model_max_budget: dict - Per-model budgets, e.g. {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
|
||||
* - model_max_budget_usage: dict | None - Current-window spend per model, present only when
|
||||
* the key has per-model budgets
|
||||
* - budget_limits: list | None - Concurrent budget windows, exactly as stored
|
||||
* - budget_limits_usage: dict | None - Current-window spend per budget window, e.g.
|
||||
* {"1h": {"current_spend": 0.0009}}, present only when the key has budget windows
|
||||
* (read from the same cross-pod spend counter the budget enforcement uses)
|
||||
* - models: list - Model_name's the key is allowed to call
|
||||
* - tpm_limit / rpm_limit: int | None - Tokens and requests per minute limits
|
||||
* - metadata: dict - Metadata for the key, e.g. {"team": "core-infra"}
|
||||
|
|
@ -34463,6 +34467,12 @@ export interface components {
|
|||
* @default 0.5
|
||||
*/
|
||||
match_threshold: number;
|
||||
/**
|
||||
* Modality Routing
|
||||
* @description Route image-bearing requests only to models that can accept image input. The classifier reads text alone, so an image request whose text classifies cheap otherwise lands on a text-only model and fails with a provider 400. When enabled, a routed model explicitly declared supports_vision false (deployment model_info or the model cost map; unmapped names stay routable) is replaced by the nearest HIGHER tier holding a capable model, then default_model, else a clear 400. A kept session-affinity pin still wins even when an image arrives.
|
||||
* @default false
|
||||
*/
|
||||
modality_routing: boolean;
|
||||
/**
|
||||
* Plan Mode Min Tier
|
||||
* @description When set, requests carrying a coding-agent plan-mode sentinel (Claude Code plan mode, VS Code Copilot Plan mode, Copilot CLI's exit_plan_mode tool) are routed to at least this tier: the classified tier still wins when it is higher, and the floor also overrides a session-affinity pin to a lower tier for exactly the turns carrying the sentinel, without rewriting the pin -- the first turn after plan mode exits routes as if plan mode had never happened. Names a built-in tier, or with tier_definitions set, one of the defined tier names (list order is ascending severity, same as keyword_tier_rules). Unset disables detection entirely. The sentinels ride in client-injected prompt text, so a caller who pastes one can spend up to this tier's models -- never down, and never outside the configured pools.
|
||||
|
|
@ -35321,8 +35331,17 @@ export interface components {
|
|||
last_error?: string | null;
|
||||
/** @description Stratified verdicts; detail endpoint only */
|
||||
results?: components["schemas"]["ShadowEvalResult"] | null;
|
||||
/** Router Name */
|
||||
router_name: string;
|
||||
/**
|
||||
* Router Name
|
||||
* @description The first router, kept for callers that predate router_names; derived so the
|
||||
* two fields can never disagree.
|
||||
*/
|
||||
readonly router_name: string;
|
||||
/**
|
||||
* Router Names
|
||||
* @description Every auto-router this job runs as a shadow arm. Multi-router jobs sample one slice of traffic and judge every arm against the same real responses
|
||||
*/
|
||||
router_names: string[];
|
||||
/** Shadow Percentage */
|
||||
shadow_percentage: number;
|
||||
/**
|
||||
|
|
@ -35410,6 +35429,12 @@ export interface components {
|
|||
* @description Sliced by the model that served the real arm: the keys' incumbent models in forward mode, and in reverse the models the router itself picked
|
||||
*/
|
||||
by_current_model: components["schemas"]["ShadowEvalSlice"][];
|
||||
/**
|
||||
* By Router
|
||||
* @description One slice per router arm, grouped on the router name. Every arm of a multi-router job is judged against the same real responses over the same sampled requests, so these slices compare routers head-to-head: like-for-like win rates and spends on identical traffic. Verdicts from before arm stamping existed count toward the job's own router
|
||||
* @default []
|
||||
*/
|
||||
by_router: components["schemas"]["ShadowEvalSlice"][];
|
||||
/** By Tier */
|
||||
by_tier: components["schemas"]["ShadowEvalSlice"][];
|
||||
/**
|
||||
|
|
@ -35423,13 +35448,13 @@ export interface components {
|
|||
overall_tie_rate_pct: number;
|
||||
/**
|
||||
* Sampled Real Spend
|
||||
* @description USD the real arm billed across all judged turns, cache-served turns excluded
|
||||
* @description USD the real arm billed across all judged turns, cache-served turns excluded. A judged turn is one (request, router arm) verdict, so a multi-router job counts the real response once per arm it was judged against; per-router comparisons read by_router
|
||||
* @default 0
|
||||
*/
|
||||
sampled_real_spend: number;
|
||||
/**
|
||||
* Sampled Shadow Spend
|
||||
* @description USD the shadow arm billed across the same turns, judge excluded, like for like
|
||||
* @description USD the shadow arms billed across the same turns, judge excluded, like for like
|
||||
* @default 0
|
||||
*/
|
||||
sampled_shadow_spend: number;
|
||||
|
|
@ -35620,7 +35645,7 @@ export interface components {
|
|||
* Cause
|
||||
* @enum {string}
|
||||
*/
|
||||
cause?: "heuristic_scorer" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
cause?: "heuristic_scorer" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
/** Classifier Cost */
|
||||
classifier_cost?: number;
|
||||
/** Classifier Model */
|
||||
|
|
@ -35723,15 +35748,21 @@ export interface components {
|
|||
judge_model: string;
|
||||
/**
|
||||
* Max Budget
|
||||
* @description Per-target USD budget for the eval's own overhead, the shadow-arm and judge calls, priced with the same figures the spend pipeline bills. EACH scoped target samples until its recorded eval spend reaches this, so a job over N targets spends at most about N times max_budget; in-flight samples can overshoot the cap by one sampling cache window
|
||||
* @description Per-target USD budget for the eval's own overhead, the shadow-arm and judge calls, priced with the same figures the spend pipeline bills. EACH scoped target samples until its recorded eval spend reaches this, so a job over N targets spends at most about N times max_budget; in-flight samples can overshoot the cap by one sampling cache window. Every router arm draws from the same per-target budget, so a multi-router job reaches it proportionally sooner
|
||||
* @default 10
|
||||
*/
|
||||
max_budget: number;
|
||||
/**
|
||||
* Router Name
|
||||
* @description The auto-router under evaluation, in either direction
|
||||
* @description The auto-router under evaluation, in either direction: the single-router spelling of router_names. Provide exactly one of the two fields
|
||||
*/
|
||||
router_name: string;
|
||||
router_name?: string | null;
|
||||
/**
|
||||
* Router Names
|
||||
* @description The auto-routers under evaluation, at most 4. Every sampled request runs through every router listed and each arm is judged independently against the same real response, so routers compare head-to-head on identical traffic. More than one router requires direction 'forward'. After validation this field always carries the full deduplicated set, whichever spelling the caller used
|
||||
* @default []
|
||||
*/
|
||||
router_names: string[];
|
||||
/**
|
||||
* Shadow Percentage
|
||||
* @description Percentage of each target's requests to duplicate through the router
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue