mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/mutmut-test-coverage-gaps-b4dd83
This commit is contained in:
commit
0bba7f8869
65 changed files with 2897 additions and 1000 deletions
|
|
@ -2421,45 +2421,6 @@ jobs:
|
|||
- wait_for_service:
|
||||
url: http://localhost:4000
|
||||
timeout: "300"
|
||||
# Add Ruby installation and testing before the existing Node.js and Python tests
|
||||
- run:
|
||||
name: Install Ruby and Bundler
|
||||
command: |
|
||||
# Clone RVM at pinned tag and verify the commit SHA matches the
|
||||
# published tag before running its install script.
|
||||
RVM_VERSION="1.29.12"
|
||||
RVM_EXPECTED_SHA="6bfc9213c9d6914fe756f524eb034a403d51db81"
|
||||
git clone --depth 1 --branch "$RVM_VERSION" https://github.com/rvm/rvm.git /tmp/rvm
|
||||
RVM_ACTUAL_SHA="$(git -C /tmp/rvm rev-parse HEAD)"
|
||||
if [ "$RVM_ACTUAL_SHA" != "$RVM_EXPECTED_SHA" ]; then
|
||||
echo "RVM tag $RVM_VERSION resolved to $RVM_ACTUAL_SHA; expected $RVM_EXPECTED_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Import RVM signing keys (used by `rvm install` to verify Ruby tarballs)
|
||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||
|
||||
# Install RVM from the verified checkout. The install script
|
||||
# sources `scripts/functions/installer` using paths relative to
|
||||
# its own working directory, so it must be run from /tmp/rvm.
|
||||
(cd /tmp/rvm && ./install --path "$HOME/.rvm")
|
||||
source "$HOME/.rvm/scripts/rvm"
|
||||
|
||||
# Install Ruby 3.2.2 (RVM verifies the tarball PGP signature)
|
||||
rvm install 3.2.2
|
||||
rvm use 3.2.2 --default
|
||||
|
||||
# Install latest Bundler
|
||||
gem install bundler
|
||||
|
||||
- run:
|
||||
name: Run Ruby tests
|
||||
command: |
|
||||
source $HOME/.rvm/scripts/rvm
|
||||
cd tests/pass_through_tests/ruby_passthrough_tests
|
||||
bundle install
|
||||
bundle exec rspec
|
||||
no_output_timeout: 30m
|
||||
# Install Node.js directly from nodejs.org with SHA256 verification,
|
||||
# instead of piping NodeSource's setup_24.x apt-repo installer into
|
||||
# sudo bash (which runs a mutable upstream script unattended).
|
||||
|
|
|
|||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -141,6 +141,7 @@ jobs:
|
|||
test-path: >-
|
||||
tests/test_litellm/proxy/analytics_endpoints
|
||||
tests/test_litellm/proxy/management_endpoints
|
||||
tests/test_litellm/proxy/list_api
|
||||
tests/test_litellm/proxy/memory
|
||||
tests/test_litellm/proxy/guardrails
|
||||
tests/test_litellm/proxy/management_helpers
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import base64
|
||||
import io
|
||||
import struct
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, cast
|
||||
|
||||
import tiktoken
|
||||
|
|
@ -25,14 +25,21 @@ from litellm.litellm_core_utils.default_encoding import encoding as default_enco
|
|||
from litellm.litellm_core_utils.url_utils import safe_get
|
||||
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthropicContentParamSource,
|
||||
AnthropicContentParamSourceFileId,
|
||||
AnthropicContentParamSourceUrl,
|
||||
AnthropicMessagesDocumentParam,
|
||||
AnthropicMessagesImageParam,
|
||||
AnthropicMessagesTextParam,
|
||||
AnthropicMessagesToolResultParam,
|
||||
AnthropicMessagesToolUseParam,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionDocumentObject,
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
ChatCompletionToolParam,
|
||||
OpenAIMessageContent,
|
||||
OpenAIMessageContentListBlock,
|
||||
)
|
||||
from litellm.types.utils import Message, SelectTokenizerResponse
|
||||
|
||||
|
|
@ -346,7 +353,7 @@ def token_counter(
|
|||
model="",
|
||||
custom_tokenizer: dict | SelectTokenizerResponse | None = None,
|
||||
text: str | list[str] | None = None,
|
||||
messages: list[AllMessageValues | Message] | None = None,
|
||||
messages: Sequence[AllMessageValues | Message] | None = None,
|
||||
count_response_tokens: bool | None = False,
|
||||
tools: list[ChatCompletionToolParam] | None = None,
|
||||
tool_choice: ChatCompletionNamedToolChoiceParam | None = None,
|
||||
|
|
@ -646,6 +653,46 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type:
|
|||
return expected_cls
|
||||
|
||||
|
||||
def _anthropic_image_source_data(
|
||||
source: AnthropicContentParamSource | AnthropicContentParamSourceUrl | AnthropicContentParamSourceFileId,
|
||||
) -> str:
|
||||
if source["type"] == "base64":
|
||||
data: Final = source.get("data")
|
||||
if not data:
|
||||
return ""
|
||||
media_type: Final = source.get("media_type") or "image/png"
|
||||
return f"data:{media_type};base64,{data}"
|
||||
if source["type"] == "url":
|
||||
return source.get("url") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _count_document_tokens(
|
||||
document: ChatCompletionDocumentObject | AnthropicMessagesDocumentParam,
|
||||
count_function: TokenCounterFunction,
|
||||
use_default_image_token_count: bool,
|
||||
default_token_count: int | None,
|
||||
) -> int:
|
||||
source: Final = document["source"]
|
||||
metadata_tokens: Final = sum(
|
||||
count_function(text) for text in (document.get("title"), document.get("context")) if text
|
||||
)
|
||||
if source["type"] == "text":
|
||||
return metadata_tokens + count_function(source["data"])
|
||||
if source["type"] == "content":
|
||||
content: Final = source["content"]
|
||||
if isinstance(content, str):
|
||||
return metadata_tokens + count_function(content)
|
||||
return metadata_tokens + _count_content_list(
|
||||
count_function, content, use_default_image_token_count, default_token_count
|
||||
)
|
||||
return metadata_tokens + calculate_img_tokens(
|
||||
data=_anthropic_image_source_data(source),
|
||||
mode="auto",
|
||||
use_default_image_token_count=use_default_image_token_count,
|
||||
)
|
||||
|
||||
|
||||
def _count_anthropic_content(
|
||||
content: Mapping[str, Any],
|
||||
count_function: TokenCounterFunction,
|
||||
|
|
@ -697,13 +744,17 @@ def _count_anthropic_content(
|
|||
|
||||
def _count_content_list(
|
||||
count_function: TokenCounterFunction,
|
||||
content_list: OpenAIMessageContent,
|
||||
content_list: str
|
||||
| Iterable[
|
||||
OpenAIMessageContentListBlock
|
||||
| AnthropicMessagesTextParam
|
||||
| AnthropicMessagesImageParam
|
||||
| AnthropicMessagesDocumentParam
|
||||
],
|
||||
use_default_image_token_count: bool,
|
||||
default_token_count: int | None,
|
||||
) -> int:
|
||||
"""
|
||||
Recursively count tokens from a list of content blocks.
|
||||
"""
|
||||
"""Recursively count tokens from a list of content blocks."""
|
||||
try:
|
||||
num_tokens = 0
|
||||
for c in content_list:
|
||||
|
|
@ -714,6 +765,19 @@ def _count_content_list(
|
|||
elif c["type"] == "image_url":
|
||||
image_url = c.get("image_url")
|
||||
num_tokens += _count_image_tokens(image_url, use_default_image_token_count)
|
||||
elif c["type"] == "image":
|
||||
num_tokens += calculate_img_tokens(
|
||||
data=_anthropic_image_source_data(c["source"]),
|
||||
mode="auto",
|
||||
use_default_image_token_count=use_default_image_token_count,
|
||||
)
|
||||
elif c["type"] == "document":
|
||||
num_tokens += _count_document_tokens(
|
||||
c,
|
||||
count_function,
|
||||
use_default_image_token_count,
|
||||
default_token_count,
|
||||
)
|
||||
elif c["type"] in ("tool_use", "tool_result"):
|
||||
num_tokens += _count_anthropic_content(
|
||||
c,
|
||||
|
|
@ -742,7 +806,8 @@ def _count_content_list(
|
|||
content_type = c.get("type", type(c).__name__) if isinstance(c, dict) else type(c).__name__
|
||||
raise ValueError(
|
||||
f"Invalid content item type: {content_type}. "
|
||||
f"Expected str or dict with 'type' field (text, image_url, tool_use, tool_result, thinking, tool_reference)."
|
||||
f"Expected str or dict with 'type' field "
|
||||
f"(text, image_url, image, document, tool_use, tool_result, thinking, tool_reference)."
|
||||
)
|
||||
return num_tokens
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -566,6 +566,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
model_info_routes = [
|
||||
"/model/info",
|
||||
"/v1/model/info",
|
||||
"/model_group/info",
|
||||
]
|
||||
|
||||
llm_api_routes = (
|
||||
|
|
@ -729,6 +730,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/litellm/.well-known/litellm-ui-config",
|
||||
"/.well-known/litellm-ui-config",
|
||||
"/public/model_hub",
|
||||
"/public/v1/model_hub",
|
||||
"/public/model_hub/info",
|
||||
"/public/agent_hub",
|
||||
"/public/mcp_hub",
|
||||
|
|
|
|||
1
litellm/proxy/list_api/__init__.py
Normal file
1
litellm/proxy/list_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Surface-neutral machinery for LiteLLM's own paginated list endpoints."""
|
||||
104
litellm/proxy/list_api/common.py
Normal file
104
litellm/proxy/list_api/common.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Contract machinery shared by every LiteLLM-defined list route, on any surface."""
|
||||
|
||||
from typing import Final
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.dependencies.utils import get_flat_params
|
||||
from fastapi.params import ParamTypes
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ListLinks,
|
||||
PageLinks,
|
||||
ProblemDetail,
|
||||
)
|
||||
|
||||
PROBLEM_CONTENT_TYPE: Final = "application/problem+json"
|
||||
# A URN, not an https URL: RFC 9457 only asks that `type` identify the problem
|
||||
# type, and an https URI promises documentation at that address. Switch to an
|
||||
# https base only when pages actually exist to serve.
|
||||
PROBLEM_TYPE_BASE: Final = "urn:litellm:error:"
|
||||
|
||||
|
||||
class ManagementProblem(Exception):
|
||||
"""Raised to return an RFC 9457 problem instead of the proxy's OpenAI error shape."""
|
||||
|
||||
def __init__(self, problem: ProblemDetail) -> None:
|
||||
self.problem = problem
|
||||
super().__init__(problem.detail)
|
||||
|
||||
|
||||
def problem_response(problem: ProblemDetail) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=problem.status,
|
||||
content=problem.model_dump(exclude_none=True),
|
||||
media_type=PROBLEM_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
|
||||
def _declared_query_params(request: Request) -> frozenset[str]:
|
||||
route: Final = request.scope.get("route")
|
||||
dependant: Final = getattr(route, "dependant", None)
|
||||
if dependant is None:
|
||||
return frozenset()
|
||||
# fastapi>=0.140.7 removed get_flat_dependant(); get_flat_params() returns the
|
||||
# flattened (deduped) param list. Filter to query params to match the old behavior.
|
||||
return frozenset(
|
||||
field.alias
|
||||
for field in get_flat_params(dependant)
|
||||
if getattr(field.field_info, "in_", None) == ParamTypes.query
|
||||
)
|
||||
|
||||
|
||||
def escape_like(value: str) -> str:
|
||||
"""Escape LIKE/ILIKE metacharacters. Ids routinely contain `_`, which is a wildcard unescaped."""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def unknown_query_param_problem(unknown: tuple[str, ...], allowed: tuple[str, ...]) -> ProblemDetail:
|
||||
return ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter",
|
||||
title="Unknown query parameter",
|
||||
status=400,
|
||||
detail=f"Unrecognized query parameter(s): {', '.join(unknown)}.",
|
||||
allowed=sorted(allowed),
|
||||
)
|
||||
|
||||
|
||||
async def reject_unknown_query_params(request: Request) -> None:
|
||||
"""Reject any query param the route did not declare.
|
||||
|
||||
A silently ignored filter over-returns data, which is worse than a rejected
|
||||
request; a fresh surface is the only chance to be strict about it.
|
||||
"""
|
||||
declared: Final = _declared_query_params(request)
|
||||
unknown: Final[tuple[str, ...]] = tuple(sorted(name for name in request.query_params if name not in declared))
|
||||
if not unknown:
|
||||
return
|
||||
raise ManagementProblem(unknown_query_param_problem(unknown=unknown, allowed=tuple(sorted(declared))))
|
||||
|
||||
|
||||
def _page_url(request: Request, page: int) -> str:
|
||||
others: Final = tuple((key, value) for key, value in request.query_params.multi_items() if key != "page")
|
||||
return f"{request.url.path}?{urlencode((*others, ('page', page)))}"
|
||||
|
||||
|
||||
def build_page_links(request: Request, page: int, has_more: bool) -> PageLinks:
|
||||
return PageLinks(
|
||||
self_link=_page_url(request, page),
|
||||
prev=_page_url(request, page - 1) if page > 1 else None,
|
||||
next=_page_url(request, page + 1) if has_more else None,
|
||||
)
|
||||
|
||||
|
||||
def build_list_links(request: Request, page: int, total_pages: int) -> ListLinks:
|
||||
"""Page-mode links. `last` clamps to page 1 on an empty result set so every link still resolves."""
|
||||
last: Final = max(total_pages, 1)
|
||||
return ListLinks(
|
||||
self_link=_page_url(request, page),
|
||||
first=_page_url(request, 1),
|
||||
prev=_page_url(request, page - 1) if page > 1 else None,
|
||||
next=_page_url(request, page + 1) if page < last else None,
|
||||
last=_page_url(request, last),
|
||||
)
|
||||
143
litellm/proxy/list_api/in_memory.py
Normal file
143
litellm/proxy/list_api/in_memory.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""An in-memory `ListExecutor`, for list resources whose rows are computed rather than queried.
|
||||
|
||||
Answers the same `QueryPlan` a SQL executor would render through `where_sql` / `order_by_sql`,
|
||||
so a filter or a sort means the same thing on either. `enrich_page` runs on the page slice and
|
||||
never on the whole match set.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Final, Generic, TypeAlias, TypeVar
|
||||
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
AnyOf,
|
||||
Compare,
|
||||
ComparisonOp,
|
||||
FilterValue,
|
||||
IsNull,
|
||||
Predicate,
|
||||
QueryPlan,
|
||||
SortKey,
|
||||
Within,
|
||||
)
|
||||
|
||||
TRow: Final = TypeVar("TRow")
|
||||
|
||||
Cell: TypeAlias = str | int | float | datetime | None
|
||||
# A tuple-valued cell is a row's repeated field (a model group's providers, say). A predicate
|
||||
# holds against it when it holds against any one element, the way an SQL join would answer.
|
||||
Cells: TypeAlias = Mapping[str, Cell | tuple[Cell, ...]]
|
||||
|
||||
|
||||
def _sign(cell: Cell, value: FilterValue) -> int | None:
|
||||
"""None when the two values are not orderable against each other."""
|
||||
if isinstance(cell, str) and isinstance(value, str):
|
||||
return (cell > value) - (cell < value)
|
||||
if isinstance(cell, datetime) and isinstance(value, datetime):
|
||||
return (cell > value) - (cell < value)
|
||||
if isinstance(cell, (int, float)) and isinstance(value, (int, float)):
|
||||
return (cell > value) - (cell < value)
|
||||
return None
|
||||
|
||||
|
||||
def _matches(cell: Cell, op: ComparisonOp, value: FilterValue) -> bool:
|
||||
"""SQL's three-valued logic: a NULL cell satisfies no comparison, only `is_null`."""
|
||||
if cell is None:
|
||||
return False
|
||||
sign: Final = _sign(cell, value)
|
||||
match op:
|
||||
case "eq":
|
||||
return cell == value
|
||||
case "not":
|
||||
return cell != value
|
||||
case "contains":
|
||||
return str(value).casefold() in str(cell).casefold()
|
||||
case "gt":
|
||||
return sign is not None and sign > 0
|
||||
case "gte":
|
||||
return sign is not None and sign >= 0
|
||||
case "lt":
|
||||
return sign is not None and sign < 0
|
||||
case "lte":
|
||||
return sign is not None and sign <= 0
|
||||
case _:
|
||||
assert_never(op)
|
||||
|
||||
|
||||
def _any_cell(cells: Cells, name: str, matches: Callable[[Cell], bool]) -> bool:
|
||||
cell: Final = cells.get(name)
|
||||
if isinstance(cell, tuple):
|
||||
return any(matches(item) for item in cell)
|
||||
return matches(cell)
|
||||
|
||||
|
||||
def _leaf_holds(predicate: Compare | Within | IsNull, cells: Cells) -> bool:
|
||||
match predicate:
|
||||
case Compare(field=name, op=op, value=value):
|
||||
return _any_cell(cells, name, lambda cell: _matches(cell, op, value))
|
||||
case Within(field=name, values=values):
|
||||
return _any_cell(cells, name, lambda cell: cell is not None and cell in values)
|
||||
case IsNull(field=name, negated=negated):
|
||||
return _any_cell(cells, name, lambda cell: (cell is None) != negated)
|
||||
case _:
|
||||
assert_never(predicate)
|
||||
|
||||
|
||||
def _holds(predicate: Predicate, cells: Cells) -> bool:
|
||||
if isinstance(predicate, AnyOf):
|
||||
return any(_leaf_holds(clause, cells) for clause in predicate.clauses)
|
||||
return _leaf_holds(predicate, cells)
|
||||
|
||||
|
||||
def _sort_key(cells: Cells, key: SortKey) -> tuple[bool, Cell | tuple[Cell, ...]]:
|
||||
"""NULLS LAST in both directions, matching `order_by_sql`.
|
||||
|
||||
The placeholder standing in for a null is only ever compared against another null's,
|
||||
because the rank ahead of it already separates nulls from the rest.
|
||||
"""
|
||||
cell: Final = cells.get(key.field)
|
||||
return (cell is None) != key.descending, 0 if cell is None else cell
|
||||
|
||||
|
||||
def _ordered(
|
||||
matched: Sequence[tuple[Cells, TRow]],
|
||||
order: tuple[SortKey, ...],
|
||||
) -> Sequence[tuple[Cells, TRow]]:
|
||||
"""Least significant key first: Python's sort is stable, so the most significant pass wins."""
|
||||
return reduce(
|
||||
lambda rows, key: sorted(rows, key=lambda pair: _sort_key(pair[0], key), reverse=key.descending),
|
||||
reversed(order),
|
||||
matched,
|
||||
)
|
||||
|
||||
|
||||
async def _unchanged(rows: Sequence[TRow]) -> Sequence[TRow]:
|
||||
return rows
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InMemoryListExecutor(Generic[TRow]):
|
||||
"""`cells` projects a row down to the values the spec's filters, search and sort read, so a
|
||||
plan can be applied without this module knowing the row type."""
|
||||
|
||||
rows: Sequence[TRow]
|
||||
cells: Callable[[TRow], Cells]
|
||||
enrich_page: Callable[[Sequence[TRow]], Awaitable[Sequence[TRow]]] = _unchanged
|
||||
|
||||
def _matching(self, where: tuple[Predicate, ...]) -> Sequence[tuple[Cells, TRow]]:
|
||||
return tuple(
|
||||
(cells, row)
|
||||
for cells, row in ((self.cells(row), row) for row in self.rows)
|
||||
if all(_holds(predicate, cells) for predicate in where)
|
||||
)
|
||||
|
||||
async def count(self, where: tuple[Predicate, ...]) -> int:
|
||||
return len(self._matching(where))
|
||||
|
||||
async def find_many(self, plan: QueryPlan) -> Sequence[TRow]:
|
||||
page: Final = _ordered(self._matching(plan.where), plan.order)[plan.skip : plan.skip + plan.take]
|
||||
return await self.enrich_page(tuple(row for _, row in page))
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Generic list handling for `/management/v1` collection routes.
|
||||
"""Generic list handling for LiteLLM-defined collection routes.
|
||||
|
||||
A resource declares a `ListSpec`; `build_query_plan` turns query parameters into a
|
||||
`QueryPlan` or an RFC 9457 problem without touching a database, and `handle_list`
|
||||
|
|
@ -24,7 +24,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
build_list_links,
|
||||
|
|
@ -85,9 +85,13 @@ class IsNull:
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AnyOf:
|
||||
"""Disjunction of its clauses. `?q=` is the only producer today."""
|
||||
"""Disjunction of its clauses. `?q=` is the only producer.
|
||||
|
||||
clauses: tuple["Predicate", ...]
|
||||
Holding leaves rather than predicates keeps the disjunction one level deep by type, so
|
||||
neither the SQL renderer nor an in-memory executor has to walk a tree to evaluate it.
|
||||
"""
|
||||
|
||||
clauses: tuple[Compare, ...]
|
||||
|
||||
|
||||
Predicate = Compare | Within | IsNull | AnyOf
|
||||
|
|
@ -369,6 +373,19 @@ def _parse_sort(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> tuple[
|
|||
f"Cannot sort {spec.resource} by: {', '.join(repr(field) for field in rejected)}.",
|
||||
tuple(spec.sortable),
|
||||
)
|
||||
# A repeated field cannot change the ordering, but an executor that sorts once per key
|
||||
# does the work anyway. Rejecting repeats bounds that to the size of `sortable`, which
|
||||
# matters because an unauthenticated caller can otherwise name one field a thousand times.
|
||||
fields: Final = tuple(key.field for key in keys)
|
||||
repeated: Final = tuple(sorted(frozenset(field for field in fields if fields.count(field) > 1)))
|
||||
if repeated:
|
||||
return _problem(
|
||||
"duplicate-sort-field",
|
||||
"Duplicate sort field",
|
||||
400,
|
||||
f"Sort field(s) named more than once: {', '.join(repeated)}. Each may appear once.",
|
||||
tuple(spec.sortable),
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
|
|
@ -16,12 +16,11 @@ from litellm.proxy._types import (
|
|||
user_api_key_has_admin_view,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
FilterSpec,
|
||||
ListSpec,
|
||||
Predicate,
|
||||
|
|
@ -34,6 +33,7 @@ from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
|||
order_by_sql,
|
||||
where_sql,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ListResponse,
|
||||
|
|
|
|||
|
|
@ -1,105 +1,8 @@
|
|||
"""Contract machinery shared by every `/management/v1` route."""
|
||||
"""Constants specific to the `/management/v1` control-plane surface.
|
||||
|
||||
The contract machinery every list route shares lives in `litellm.proxy.list_api`.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.dependencies.utils import get_flat_params
|
||||
from fastapi.params import ParamTypes
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ListLinks,
|
||||
PageLinks,
|
||||
ProblemDetail,
|
||||
)
|
||||
|
||||
MANAGEMENT_V1_PREFIX: Final = "/management/v1"
|
||||
PROBLEM_CONTENT_TYPE: Final = "application/problem+json"
|
||||
# A URN, not an https URL: RFC 9457 only asks that `type` identify the problem
|
||||
# type, and an https URI promises documentation at that address. Switch to an
|
||||
# https base only when pages actually exist to serve.
|
||||
PROBLEM_TYPE_BASE: Final = "urn:litellm:error:"
|
||||
|
||||
|
||||
class ManagementProblem(Exception):
|
||||
"""Raised to return an RFC 9457 problem instead of the proxy's OpenAI error shape."""
|
||||
|
||||
def __init__(self, problem: ProblemDetail) -> None:
|
||||
self.problem = problem
|
||||
super().__init__(problem.detail)
|
||||
|
||||
|
||||
def problem_response(problem: ProblemDetail) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=problem.status,
|
||||
content=problem.model_dump(exclude_none=True),
|
||||
media_type=PROBLEM_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
|
||||
def _declared_query_params(request: Request) -> frozenset[str]:
|
||||
route: Final = request.scope.get("route")
|
||||
dependant: Final = getattr(route, "dependant", None)
|
||||
if dependant is None:
|
||||
return frozenset()
|
||||
# fastapi>=0.140.7 removed get_flat_dependant(); get_flat_params() returns the
|
||||
# flattened (deduped) param list. Filter to query params to match the old behavior.
|
||||
return frozenset(
|
||||
field.alias
|
||||
for field in get_flat_params(dependant)
|
||||
if getattr(field.field_info, "in_", None) == ParamTypes.query
|
||||
)
|
||||
|
||||
|
||||
def escape_like(value: str) -> str:
|
||||
"""Escape LIKE/ILIKE metacharacters. Ids routinely contain `_`, which is a wildcard unescaped."""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def unknown_query_param_problem(unknown: tuple[str, ...], allowed: tuple[str, ...]) -> ProblemDetail:
|
||||
return ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter",
|
||||
title="Unknown query parameter",
|
||||
status=400,
|
||||
detail=f"Unrecognized query parameter(s): {', '.join(unknown)}.",
|
||||
allowed=sorted(allowed),
|
||||
)
|
||||
|
||||
|
||||
async def reject_unknown_query_params(request: Request) -> None:
|
||||
"""Reject any query param the route did not declare.
|
||||
|
||||
A silently ignored filter over-returns data, which is worse than a rejected
|
||||
request; a fresh surface is the only chance to be strict about it.
|
||||
"""
|
||||
declared: Final = _declared_query_params(request)
|
||||
unknown: Final[tuple[str, ...]] = tuple(sorted(name for name in request.query_params if name not in declared))
|
||||
if not unknown:
|
||||
return
|
||||
raise ManagementProblem(unknown_query_param_problem(unknown=unknown, allowed=tuple(sorted(declared))))
|
||||
|
||||
|
||||
def _page_url(request: Request, page: int) -> str:
|
||||
others: Final = tuple((key, value) for key, value in request.query_params.multi_items() if key != "page")
|
||||
return f"{request.url.path}?{urlencode((*others, ('page', page)))}"
|
||||
|
||||
|
||||
def build_page_links(request: Request, page: int, has_more: bool) -> PageLinks:
|
||||
return PageLinks(
|
||||
self_link=_page_url(request, page),
|
||||
prev=_page_url(request, page - 1) if page > 1 else None,
|
||||
next=_page_url(request, page + 1) if has_more else None,
|
||||
)
|
||||
|
||||
|
||||
def build_list_links(request: Request, page: int, total_pages: int) -> ListLinks:
|
||||
"""Page-mode links. `last` clamps to page 1 on an empty result set so every link still resolves."""
|
||||
last: Final = max(total_pages, 1)
|
||||
return ListLinks(
|
||||
self_link=_page_url(request, page),
|
||||
first=_page_url(request, 1),
|
||||
prev=_page_url(request, page - 1) if page > 1 else None,
|
||||
next=_page_url(request, page + 1) if page < last else None,
|
||||
last=_page_url(request, last),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
build_page_links,
|
||||
escape_like,
|
||||
reject_unknown_query_params,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
FacetListResponse,
|
||||
|
|
|
|||
|
|
@ -432,6 +432,11 @@ from litellm.proxy.hooks.prompt_injection_detection import (
|
|||
)
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger
|
||||
from litellm.proxy.image_endpoints.endpoints import router as image_router
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
|
||||
from litellm.proxy.logging_endpoints.callback_logs_endpoints import (
|
||||
rust_control_plane_router,
|
||||
|
|
@ -488,12 +493,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
|||
from litellm.proxy.management_endpoints.management_v1 import (
|
||||
router as management_v1_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
router as model_access_group_management_router,
|
||||
)
|
||||
|
|
@ -600,6 +600,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
|||
router as pass_through_router,
|
||||
)
|
||||
from litellm.proxy.public_endpoints import router as public_endpoints_router
|
||||
from litellm.proxy.public_endpoints.public_v1 import router as public_v1_router
|
||||
from litellm.proxy.rag_endpoints.endpoints import router as rag_router
|
||||
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
|
||||
from litellm.proxy.response_api_endpoints.endpoints import router as response_router
|
||||
|
|
@ -672,7 +673,12 @@ from litellm.types.llms.anthropic import (
|
|||
AnthropicResponseContentBlockText,
|
||||
AnthropicResponseUsageBlock,
|
||||
)
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionSystemMessage,
|
||||
ChatCompletionToolParam,
|
||||
HttpxBinaryResponseContent,
|
||||
)
|
||||
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelGroupInfoProxy,
|
||||
|
|
@ -12126,6 +12132,13 @@ async def _try_provider_token_count(
|
|||
return result
|
||||
|
||||
|
||||
def _system_message(system: object) -> ChatCompletionSystemMessage | None:
|
||||
if not isinstance(system, (str, list)) or not system:
|
||||
return None
|
||||
message: Final[ChatCompletionSystemMessage] = {"role": "system", "content": system}
|
||||
return message
|
||||
|
||||
|
||||
@router.post(
|
||||
"/utils/token_counter",
|
||||
tags=["llm utils"],
|
||||
|
|
@ -12224,10 +12237,21 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False)
|
|||
_tokenizer_used: Final = litellm.utils._select_tokenizer(model=model_to_use, custom_tokenizer=custom_tokenizer)
|
||||
|
||||
tokenizer_used: Final = str(_tokenizer_used["type"])
|
||||
system_message: Final = _system_message(system)
|
||||
typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes
|
||||
Sequence[AllMessageValues] | None, messages
|
||||
)
|
||||
counted_messages: Final = (
|
||||
typed_messages if typed_messages is None or system_message is None else (system_message, *typed_messages)
|
||||
)
|
||||
counted_tools: Final = cast( # cast-ok: raw OpenAI or Anthropic tool dicts, both of which token_counter formats
|
||||
list[ChatCompletionToolParam] | None, tools if counted_messages is not None else None
|
||||
)
|
||||
total_tokens: Final = await asyncify(litellm.token_counter)(
|
||||
model=model_to_use,
|
||||
text=prompt,
|
||||
messages=messages,
|
||||
messages=counted_messages,
|
||||
tools=counted_tools,
|
||||
custom_tokenizer=_tokenizer_used,
|
||||
)
|
||||
return TokenCountResponse(
|
||||
|
|
@ -17661,6 +17685,7 @@ async def get_routes():
|
|||
app.include_router(router)
|
||||
app.include_router(response_router)
|
||||
app.include_router(public_endpoints_router)
|
||||
app.include_router(public_v1_router)
|
||||
app.include_router(rerank_router)
|
||||
app.include_router(ocr_router)
|
||||
app.include_router(rag_router)
|
||||
|
|
|
|||
14
litellm/proxy/public_endpoints/public_v1/__init__.py
Normal file
14
litellm/proxy/public_endpoints/public_v1/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""The `/public/v1` unauthenticated public surface."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from litellm.proxy.public_endpoints.public_v1.model_hub import router as model_hub_router
|
||||
|
||||
PUBLIC_V1_PREFIX: Final = "/public/v1"
|
||||
|
||||
router: Final = APIRouter(prefix=PUBLIC_V1_PREFIX)
|
||||
router.include_router(model_hub_router)
|
||||
|
||||
__all__ = ("PUBLIC_V1_PREFIX", "router")
|
||||
242
litellm/proxy/public_endpoints/public_v1/model_hub.py
Normal file
242
litellm/proxy/public_endpoints/public_v1/model_hub.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""`GET /public/v1/model_hub`."""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem
|
||||
from litellm.proxy.list_api.in_memory import Cells, InMemoryListExecutor
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
FilterSpec,
|
||||
ListSpec,
|
||||
Scope,
|
||||
ScopeAll,
|
||||
SortKey,
|
||||
handle_list,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
ListResponse,
|
||||
ProblemDetail,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelGroupInfoProxy,
|
||||
)
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HealthSnapshot:
|
||||
"""The health fields a model hub row carries, as the latest health check recorded them."""
|
||||
|
||||
status: str | None
|
||||
response_time_ms: float | None
|
||||
checked_at: str | None
|
||||
|
||||
|
||||
class HealthSnapshotLookup(Protocol):
|
||||
"""The health half of the list, injected so the page slice decides how much of it runs."""
|
||||
|
||||
async def latest_for(self, model_groups: Sequence[str]) -> Mapping[str, HealthSnapshot]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PrismaHealthSnapshotLookup:
|
||||
prisma_client: PrismaClient
|
||||
|
||||
async def latest_for(self, model_groups: Sequence[str]) -> Mapping[str, HealthSnapshot]:
|
||||
checks: Final = await self.prisma_client.get_latest_health_checks_for_models(model_groups)
|
||||
return MappingProxyType(
|
||||
{
|
||||
check.model_name: HealthSnapshot(
|
||||
status=check.status,
|
||||
response_time_ms=check.response_time_ms,
|
||||
checked_at=check.checked_at.isoformat() if check.checked_at else None,
|
||||
)
|
||||
for check in checks
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _HealthFields(TypedDict):
|
||||
health_status: ReadOnly[str | None]
|
||||
health_response_time: ReadOnly[float | None]
|
||||
health_checked_at: ReadOnly[str | None]
|
||||
|
||||
|
||||
def _with_health(row: ModelGroupInfoProxy, health: HealthSnapshot | None) -> ModelGroupInfoProxy:
|
||||
if health is None:
|
||||
return row
|
||||
update: Final[_HealthFields] = {
|
||||
"health_status": health.status,
|
||||
"health_response_time": health.response_time_ms,
|
||||
"health_checked_at": health.checked_at,
|
||||
}
|
||||
return row.model_copy(update=update)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HealthEnricher:
|
||||
"""Resolves health for exactly the rows handed to it, which is the page and never the match set."""
|
||||
|
||||
lookup: HealthSnapshotLookup
|
||||
|
||||
async def __call__(self, rows: Sequence[ModelGroupInfoProxy]) -> Sequence[ModelGroupInfoProxy]:
|
||||
health: Final = await self.lookup.latest_for(tuple(row.model_group for row in rows))
|
||||
return tuple(_with_health(row, health.get(row.model_group)) for row in rows)
|
||||
|
||||
|
||||
def _cells(row: ModelGroupInfoProxy) -> Cells:
|
||||
return MappingProxyType(
|
||||
{
|
||||
"model_group": row.model_group,
|
||||
"mode": row.mode,
|
||||
"providers": tuple(row.providers),
|
||||
"max_input_tokens": row.max_input_tokens,
|
||||
"max_output_tokens": row.max_output_tokens,
|
||||
"input_cost_per_token": row.input_cost_per_token,
|
||||
"output_cost_per_token": row.output_cost_per_token,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _serialize(row: ModelGroupInfoProxy) -> ModelGroupInfoProxy:
|
||||
"""The row shape is the wire shape: the rows served are the router's own model group records."""
|
||||
return row
|
||||
|
||||
|
||||
def _scope(_caller: UserAPIKeyAuth) -> Scope:
|
||||
"""Unconditional, and `/public/v1` is the one surface where that is allowed.
|
||||
|
||||
Every row here is already a model group the operator published, so a public browse
|
||||
caller seeing all of them is the answer, not a gap in the scoping.
|
||||
"""
|
||||
return ScopeAll()
|
||||
|
||||
|
||||
MODEL_HUB_FILTERS: Final[Mapping[str, FilterSpec]] = MappingProxyType(
|
||||
{
|
||||
"mode": FilterSpec(type=str, ops=frozenset(("eq", "in"))),
|
||||
"providers": FilterSpec(type=str, ops=frozenset(("contains",))),
|
||||
}
|
||||
)
|
||||
|
||||
MODEL_HUB_LIST_SPEC: Final[ListSpec[ModelGroupInfoProxy, ModelGroupInfoProxy]] = ListSpec(
|
||||
resource="model groups",
|
||||
sortable=frozenset(
|
||||
(
|
||||
"model_group",
|
||||
"mode",
|
||||
"max_input_tokens",
|
||||
"max_output_tokens",
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
)
|
||||
),
|
||||
searchable=frozenset(("model_group",)),
|
||||
filters=MODEL_HUB_FILTERS,
|
||||
default_sort=(SortKey(field="model_group", descending=False),),
|
||||
default_page_size=50,
|
||||
max_page_size=100,
|
||||
scope=_scope,
|
||||
serialize=_serialize,
|
||||
tiebreaker="model_group",
|
||||
)
|
||||
|
||||
|
||||
def _executor(
|
||||
rows: Sequence[ModelGroupInfoProxy],
|
||||
prisma_client: PrismaClient | None,
|
||||
) -> InMemoryListExecutor[ModelGroupInfoProxy]:
|
||||
if prisma_client is None:
|
||||
return InMemoryListExecutor(rows=rows, cells=_cells)
|
||||
return InMemoryListExecutor(
|
||||
rows=rows,
|
||||
cells=_cells,
|
||||
enrich_page=HealthEnricher(lookup=PrismaHealthSnapshotLookup(prisma_client=prisma_client)),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/model_hub",
|
||||
tags=["public", "model management"], # mutable-ok: fastapi types tags as list[str | Enum]
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
response_model=ListResponse[ModelGroupInfoProxy],
|
||||
)
|
||||
async def public_model_hub_list(
|
||||
request: Request,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> ListResponse[ModelGroupInfoProxy]:
|
||||
"""
|
||||
The public model groups this proxy publishes, paged, sortable, searchable and
|
||||
filterable, for the public Model Hub page. No authentication.
|
||||
|
||||
A rejected request answers with the parameters, sort fields and filter operators
|
||||
it would have accepted, so the accepted set stays discoverable from the endpoint
|
||||
itself rather than from a copy of the spec kept here.
|
||||
|
||||
Example curl:
|
||||
```
|
||||
curl --location --globoff \
|
||||
'http://0.0.0.0:4000/public/v1/model_hub?sort=-input_cost_per_token&filter[mode][in]=chat&page_size=25'
|
||||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import (
|
||||
_get_model_group_info, # pyright: ignore[reportPrivateUsage] # /public/model_hub imports it the same way
|
||||
llm_router,
|
||||
prisma_client,
|
||||
)
|
||||
|
||||
if llm_router is None:
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}no-llm-router",
|
||||
title="No models configured",
|
||||
status=400,
|
||||
detail=CommonProxyErrors.no_llm_router.value,
|
||||
)
|
||||
)
|
||||
|
||||
rows: Final[Sequence[ModelGroupInfoProxy]] = (
|
||||
()
|
||||
if litellm.public_model_groups is None
|
||||
else tuple(
|
||||
_get_model_group_info(
|
||||
llm_router=llm_router,
|
||||
all_models_str=litellm.public_model_groups,
|
||||
model_group=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return await handle_list(
|
||||
spec=MODEL_HUB_LIST_SPEC,
|
||||
executor=_executor(rows, prisma_client),
|
||||
request=request,
|
||||
caller=user_api_key_dict,
|
||||
)
|
||||
|
||||
except ManagementProblem:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 # a router error answers as a problem document, not the OpenAI error shape
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.public_endpoints.public_v1.model_hub.public_model_hub_list(): Exception occured - %s", e
|
||||
)
|
||||
raise ManagementProblem(
|
||||
ProblemDetail(
|
||||
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
|
||||
title="Internal server error",
|
||||
status=500,
|
||||
detail="Failed to list public model groups.",
|
||||
)
|
||||
)
|
||||
|
|
@ -5833,6 +5833,29 @@ class PrismaClient:
|
|||
verbose_proxy_logger.error("Error getting all latest health checks: %s", e)
|
||||
return []
|
||||
|
||||
async def get_latest_health_checks_for_models(
|
||||
self, model_names: "Sequence[str]"
|
||||
) -> "Sequence[prisma_models.LiteLLM_HealthCheckTable]":
|
||||
"""
|
||||
Get the latest health check for each of the named models.
|
||||
|
||||
Same DISTINCT ON as ``get_all_latest_health_checks``, bounded to the models asked
|
||||
about, so a paged caller reads health for its page instead of for the whole table.
|
||||
"""
|
||||
if not model_names:
|
||||
return ()
|
||||
latest_first: Final = (("model_id", "asc"), ("model_name", "asc"), ("checked_at", "desc"))
|
||||
order: Final = [{field: direction} for field, direction in latest_first] # mutable-ok: prisma order is a list
|
||||
try:
|
||||
return await HealthCheckRepository(self).table.find_many(
|
||||
where={"model_name": {"in": list(model_names)}}, # mutable-ok: prisma filters are dicts and lists
|
||||
distinct=["model_id", "model_name"], # mutable-ok: prisma distinct takes a list
|
||||
order=order,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # health decorates a list; a driver error must not fail the page
|
||||
verbose_proxy_logger.error("Error getting latest health checks for models: %s", e)
|
||||
return ()
|
||||
|
||||
|
||||
### HELPER FUNCTIONS ###
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Any, Final, Literal, TypeAlias
|
||||
|
||||
|
|
@ -254,6 +254,17 @@ class AnthropicContentParamSourceFileId(TypedDict):
|
|||
file_id: str
|
||||
|
||||
|
||||
class AnthropicContentParamSourceText(TypedDict):
|
||||
type: ReadOnly[Literal["text"]]
|
||||
media_type: ReadOnly[Literal["text/plain"]]
|
||||
data: ReadOnly[str]
|
||||
|
||||
|
||||
class AnthropicContentParamSourceContent(TypedDict):
|
||||
type: ReadOnly[Literal["content"]]
|
||||
content: ReadOnly[str | Sequence["AnthropicMessagesTextParam | AnthropicMessagesImageParam"]]
|
||||
|
||||
|
||||
class AnthropicMessagesContainerUploadParam(TypedDict, total=False):
|
||||
type: Required[Literal["container_upload"]]
|
||||
file_id: str
|
||||
|
|
@ -305,7 +316,13 @@ AnthropicCitation = AnthropicCitationPageLocation | AnthropicCitationCharLocatio
|
|||
|
||||
class AnthropicMessagesDocumentParam(TypedDict, total=False):
|
||||
type: Required[Literal["document"]]
|
||||
source: Required[AnthropicContentParamSource | AnthropicContentParamSourceFileId | AnthropicContentParamSourceUrl]
|
||||
source: Required[
|
||||
AnthropicContentParamSource
|
||||
| AnthropicContentParamSourceFileId
|
||||
| AnthropicContentParamSourceUrl
|
||||
| AnthropicContentParamSourceText
|
||||
| AnthropicContentParamSourceContent
|
||||
]
|
||||
cache_control: dict | ChatCompletionCachedContent | None
|
||||
title: str
|
||||
context: str
|
||||
|
|
|
|||
|
|
@ -2302,7 +2302,7 @@ def token_counter(
|
|||
model="",
|
||||
custom_tokenizer: dict | SelectTokenizerResponse | None = None,
|
||||
text: str | list[str] | None = None,
|
||||
messages: list | None = None,
|
||||
messages: Sequence | None = None,
|
||||
count_response_tokens: bool | None = False,
|
||||
tools: list[ChatCompletionToolParam] | None = None,
|
||||
tool_choice: ChatCompletionNamedToolChoiceParam | None = None,
|
||||
|
|
@ -7741,7 +7741,7 @@ def convert_to_dict(message: BaseModel | dict) -> dict:
|
|||
raise TypeError(f"Invalid message type: {type(message)}. Expected dict or Pydantic model.")
|
||||
|
||||
|
||||
def convert_list_message_to_dict(messages: list):
|
||||
def convert_list_message_to_dict(messages: Sequence):
|
||||
new_messages: Final = []
|
||||
for message in messages:
|
||||
convert_msg_to_dict = cast(AllMessageValues, convert_to_dict(message))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"TQ001": {
|
||||
"limit": 744
|
||||
"limit": 736
|
||||
},
|
||||
"TQ002": {
|
||||
"limit": 742
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"limit": 469
|
||||
},
|
||||
"TQ005": {
|
||||
"limit": 2405
|
||||
"limit": 2399
|
||||
},
|
||||
"TQ006": {
|
||||
"limit": 34
|
||||
|
|
|
|||
|
|
@ -46,9 +46,6 @@ class TestFilesBatchesContract:
|
|||
case other:
|
||||
pytest.fail(f"upload without purpose expected 4xx, got {other!r}")
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="stage red: product gap, /v1/batches 500s (acreate_batch TypeError) on missing input_file_id instead of 400"
|
||||
)
|
||||
@pytest.mark.covers("llm.batches.openai.input_validation.nonstream.works")
|
||||
def test_create_batch_missing_input_file_id_returns_error(
|
||||
self, proxy: ProxyClient, resources: ResourceManager
|
||||
|
|
|
|||
|
|
@ -49,16 +49,6 @@ def _seed_completion(proxy: ProxyClient, *, key: str, marker: str) -> None:
|
|||
|
||||
|
||||
class TestDatadogMcpRoundTrip:
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"LIT-5052: this test sends a `telemetry` argument that Datadog's "
|
||||
"search_datadog_logs tool now rejects, so every tool call fails validation with "
|
||||
"'unexpected additional properties [\"telemetry\"]' before the round-trip "
|
||||
"assertion is reached. `telemetry` was never a documented Datadog parameter; the "
|
||||
"test relied on the server ignoring unknown properties. Unskip once the argument "
|
||||
"is dropped."
|
||||
)
|
||||
)
|
||||
@pytest.mark.covers("mcp.list_tools.api_key.succeeds", "mcp.call_tool.api_key.succeeds")
|
||||
def test_search_logs_finds_seeded_completion(
|
||||
self,
|
||||
|
|
@ -98,9 +88,6 @@ class TestDatadogMcpRoundTrip:
|
|||
"from": DD_SEARCH_FROM,
|
||||
"to": "now",
|
||||
"max_tokens": 5000,
|
||||
"telemetry": {
|
||||
"intent": "e2e assert seeded litellm completion log is searchable via MCP"
|
||||
},
|
||||
},
|
||||
)
|
||||
assert call.is_error is not True, f"search_datadog_logs errored: {call}"
|
||||
|
|
|
|||
|
|
@ -78,16 +78,6 @@ def _search_on_synced_pod(
|
|||
|
||||
|
||||
class TestMcpToolCallGuardrail:
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"LIT-5052: the control call sends a `telemetry` argument that Datadog's "
|
||||
"search_datadog_logs tool now rejects, so the clean-argument half of this test "
|
||||
"errors with 'unexpected additional properties [\"telemetry\"]' and the guardrail "
|
||||
"block it exists to prove is never exercised. `telemetry` was never a documented "
|
||||
"Datadog parameter; the test relied on the server ignoring unknown properties. "
|
||||
"Unskip once the argument is dropped."
|
||||
)
|
||||
)
|
||||
@pytest.mark.covers(
|
||||
"guardrail.litellm_content_filter.pre_mcp_call.blocks",
|
||||
exercised_on=["mcp_operations"],
|
||||
|
|
@ -118,7 +108,6 @@ class TestMcpToolCallGuardrail:
|
|||
"from": DD_SEARCH_FROM,
|
||||
"to": "now",
|
||||
"max_tokens": 500,
|
||||
"telemetry": {"intent": "e2e mcp guardrail check"},
|
||||
}
|
||||
return client.call_tool(key, server_id=server_id, name=tool_name, arguments=arguments)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,16 +51,6 @@ class TestMcpKeyWithoutAccessIsDenied:
|
|||
f"boundary: {denied_tools}"
|
||||
)
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"LIT-5052: the control call proving a granted key CAN invoke the tool sends a "
|
||||
"`telemetry` argument that Datadog's search_datadog_logs tool now rejects, so it "
|
||||
"errors with 'unexpected additional properties [\"telemetry\"]' and the denial "
|
||||
"assertion is never reached. `telemetry` was never a documented Datadog "
|
||||
"parameter; the test relied on the server ignoring unknown properties. Unskip "
|
||||
"once the argument is dropped."
|
||||
)
|
||||
)
|
||||
@pytest.mark.covers("mcp.call_tool.api_key.denied_without_permission")
|
||||
def test_call_tool_denied_without_permission(
|
||||
self,
|
||||
|
|
@ -80,7 +70,6 @@ class TestMcpKeyWithoutAccessIsDenied:
|
|||
"from": DD_SEARCH_FROM,
|
||||
"to": "now",
|
||||
"max_tokens": 1000,
|
||||
"telemetry": {"intent": "e2e control call proving granted key can invoke Datadog MCP"},
|
||||
}
|
||||
permitted_call = client.await_call_tool(
|
||||
permitted_key, server_id=server_id, name=tool_name, arguments=search_args
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
|
|
@ -17,43 +17,49 @@ async function openTemplateSelect(page: PlaywrightPage) {
|
|||
return trigger;
|
||||
}
|
||||
|
||||
function pollPixelsBelowTrigger(trigger: Locator, popup: Locator) {
|
||||
return expect.poll(async () => {
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const popupBox = await popup.boundingBox();
|
||||
if (!triggerBox || !popupBox) return null;
|
||||
return popupBox.y - (triggerBox.y + triggerBox.height);
|
||||
});
|
||||
}
|
||||
|
||||
function pollPopupOverlapsTrigger(trigger: Locator, popup: Locator) {
|
||||
return expect.poll(async () => {
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const popupBox = await popup.boundingBox();
|
||||
if (!triggerBox || !popupBox) return null;
|
||||
return popupBox.y < triggerBox.y + triggerBox.height && popupBox.y + popupBox.height > triggerBox.y;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Auto Router template select anchoring", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("opens the options below the trigger rather than over it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
const popupBox = await popup.boundingBox();
|
||||
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(popupBox).not.toBeNull();
|
||||
|
||||
// Item-aligned mode reports "none" and puts the active item over the trigger.
|
||||
await expect(popup).toHaveAttribute("data-side", "bottom");
|
||||
expect(popupBox!.y).toBeGreaterThanOrEqual(triggerBox!.y + triggerBox!.height);
|
||||
await pollPixelsBelowTrigger(trigger, popup).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("flips above the trigger instead of covering it when there is no room below", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 560 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
const popupBox = await popup.boundingBox();
|
||||
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(popupBox).not.toBeNull();
|
||||
|
||||
const overlaps =
|
||||
popupBox!.y < triggerBox!.y + triggerBox!.height && popupBox!.y + popupBox!.height > triggerBox!.y;
|
||||
expect(overlaps).toBe(false);
|
||||
await pollPopupOverlapsTrigger(trigger, popup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,32 +19,32 @@ from litellm import (
|
|||
# litellm.set_verbose=True
|
||||
|
||||
|
||||
TOLERATED_UPSTREAM_FAILURES = (Timeout, litellm.InternalServerError)
|
||||
|
||||
|
||||
def test_batch_completions():
|
||||
messages = [[{"role": "user", "content": "write a short poem"}] for _ in range(3)]
|
||||
model = "gpt-3.5-turbo"
|
||||
litellm.set_verbose = True
|
||||
try:
|
||||
result = batch_completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
request_timeout=1,
|
||||
)
|
||||
print(result)
|
||||
print(len(result))
|
||||
assert len(result) == 3
|
||||
|
||||
for response in result:
|
||||
assert response.choices[0].message.content is not None
|
||||
except Timeout as e:
|
||||
print(f"IN TIMEOUT")
|
||||
pass
|
||||
except litellm.InternalServerError as e:
|
||||
print(f"IN INTERNAL SERVER ERROR")
|
||||
pass
|
||||
except Exception as e:
|
||||
pytest.fail(f"An error occurred: {e}")
|
||||
result = batch_completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
request_timeout=1,
|
||||
)
|
||||
print(result)
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
for response in result:
|
||||
if isinstance(response, TOLERATED_UPSTREAM_FAILURES):
|
||||
continue
|
||||
assert not isinstance(
|
||||
response, Exception
|
||||
), f"batch_completion returned {type(response).__name__}: {response}"
|
||||
assert response.choices[0].message.content is not None
|
||||
|
||||
|
||||
# test_batch_completions()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ verbose_logger.setLevel(logging.DEBUG)
|
|||
litellm.set_verbose = True
|
||||
import time
|
||||
|
||||
INTERVAL_TOO_LONG_TO_FIRE_DURING_THIS_TEST = 3600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opik_logging_http_request():
|
||||
|
|
@ -23,70 +25,60 @@ async def test_opik_logging_http_request():
|
|||
- Test that HTTP requests are made to Opik
|
||||
- Traces and spans are batched correctly
|
||||
"""
|
||||
try:
|
||||
from litellm.integrations.opik.opik import OpikLogger
|
||||
from litellm.integrations.opik.opik import OpikLogger
|
||||
|
||||
os.environ["OPIK_URL_OVERRIDE"] = "https://fake.comet.com/opik/api"
|
||||
os.environ["OPIK_API_KEY"] = "anything"
|
||||
os.environ["OPIK_WORKSPACE"] = "anything"
|
||||
os.environ["OPIK_URL_OVERRIDE"] = "https://fake.comet.com/opik/api"
|
||||
os.environ["OPIK_API_KEY"] = "anything"
|
||||
os.environ["OPIK_WORKSPACE"] = "anything"
|
||||
|
||||
# Initialize OpikLogger
|
||||
test_opik_logger = OpikLogger()
|
||||
test_opik_logger = OpikLogger()
|
||||
test_opik_logger.flush_interval = INTERVAL_TOO_LONG_TO_FIRE_DURING_THIS_TEST
|
||||
test_opik_logger.batch_size = 12
|
||||
|
||||
litellm.callbacks = [test_opik_logger]
|
||||
test_opik_logger.batch_size = 12
|
||||
litellm.set_verbose = True
|
||||
litellm.callbacks = [test_opik_logger]
|
||||
|
||||
# Create a mock for the async_client's post method
|
||||
mock_post = AsyncMock()
|
||||
mock_post.return_value.status_code = 202
|
||||
mock_post.return_value.text = "Accepted"
|
||||
test_opik_logger.async_httpx_client.post = mock_post
|
||||
mock_post = AsyncMock(return_value=Mock(status_code=202, text="Accepted"))
|
||||
test_opik_logger.async_httpx_client.post = mock_post
|
||||
|
||||
# Make multiple calls to ensure we don't hit the batch size
|
||||
for _ in range(5):
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
mock_response="This is a mock response",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
def opik_batch_calls():
|
||||
return [
|
||||
call
|
||||
for call in mock_post.call_args_list
|
||||
if "/traces/batch" in str(call) or "/spans/batch" in str(call)
|
||||
]
|
||||
|
||||
# Check batching of events and that the queue contains 5 trace events and 5 span events
|
||||
assert (
|
||||
mock_post.called == False
|
||||
), "HTTP request was made but events should have been batched"
|
||||
assert len(test_opik_logger.log_queue) == 10
|
||||
for _ in range(5):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
mock_response="This is a mock response",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now make calls to exceed the batch size
|
||||
for _ in range(3):
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
mock_response="This is a mock response",
|
||||
)
|
||||
assert opik_batch_calls() == [], "events below batch_size must stay queued"
|
||||
assert len(test_opik_logger.log_queue) == 10
|
||||
|
||||
# Wait a short time for any asynchronous operations to complete
|
||||
await asyncio.sleep(1)
|
||||
for _ in range(3):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
max_tokens=10,
|
||||
temperature=0.2,
|
||||
mock_response="This is a mock response",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check that the queue was flushed after exceeding batch size
|
||||
assert len(test_opik_logger.log_queue) < test_opik_logger.batch_size
|
||||
assert opik_batch_calls(), "crossing batch_size must flush the queue"
|
||||
events_left_over_after_the_size_triggered_flush = len(test_opik_logger.log_queue)
|
||||
assert 0 < events_left_over_after_the_size_triggered_flush < test_opik_logger.batch_size
|
||||
|
||||
# Check that the data has been sent when it goes above the flush interval
|
||||
await asyncio.sleep(test_opik_logger.flush_interval)
|
||||
assert len(test_opik_logger.log_queue) == 0
|
||||
calls_before_periodic_flush = len(opik_batch_calls())
|
||||
await test_opik_logger.flush_queue()
|
||||
|
||||
# Clean up
|
||||
for cb in litellm.callbacks:
|
||||
if isinstance(cb, OpikLogger):
|
||||
await cb.async_httpx_client.client.aclose()
|
||||
|
||||
except Exception as e:
|
||||
pytest.fail(f"Error occurred: {e}")
|
||||
assert len(opik_batch_calls()) > calls_before_periodic_flush
|
||||
assert len(test_opik_logger.log_queue) == 0
|
||||
|
||||
|
||||
def test_sync_opik_logging_http_request():
|
||||
|
|
|
|||
|
|
@ -94,11 +94,13 @@ def test_langfuse_handler_accepts_secret_key_alias(monkeypatch):
|
|||
langfuse_public_key=None,
|
||||
langfuse_secret=None,
|
||||
langfuse_host=None,
|
||||
langfuse_environment=None,
|
||||
allow_env_credentials=True,
|
||||
):
|
||||
captured["langfuse_public_key"] = langfuse_public_key
|
||||
captured["langfuse_secret"] = langfuse_secret
|
||||
captured["langfuse_host"] = langfuse_host
|
||||
captured["langfuse_environment"] = langfuse_environment
|
||||
captured["allow_env_credentials"] = allow_env_credentials
|
||||
|
||||
class FakeDynamicLoggingCache:
|
||||
|
|
@ -117,6 +119,7 @@ def test_langfuse_handler_accepts_secret_key_alias(monkeypatch):
|
|||
"langfuse_public_key": "dynamic-public",
|
||||
"langfuse_secret_key": "dynamic-secret",
|
||||
"langfuse_host": "https://langfuse.example",
|
||||
"langfuse_environment": "dynamic-environment",
|
||||
},
|
||||
in_memory_dynamic_logger_cache=FakeDynamicLoggingCache(),
|
||||
)
|
||||
|
|
@ -124,6 +127,7 @@ def test_langfuse_handler_accepts_secret_key_alias(monkeypatch):
|
|||
assert captured["langfuse_public_key"] == "dynamic-public"
|
||||
assert captured["langfuse_secret"] == "dynamic-secret"
|
||||
assert captured["langfuse_host"] == "https://langfuse.example"
|
||||
assert captured["langfuse_environment"] == "dynamic-environment"
|
||||
assert captured["allow_env_credentials"] is False
|
||||
assert captured["cached_service_name"] == "langfuse"
|
||||
assert captured["cached_logging_obj"] is logger
|
||||
|
|
|
|||
|
|
@ -123,16 +123,12 @@ def test_get_langfuse_logger_for_request_with_dynamic_params(
|
|||
assert result.secret_key == "test_secret"
|
||||
assert result.langfuse_host == "https://test.langfuse.com"
|
||||
|
||||
# Check if the logger is cached
|
||||
cached_logger = dynamic_logging_cache.get_cache(
|
||||
credentials={
|
||||
"langfuse_public_key": "test_public_key",
|
||||
"langfuse_secret": "test_secret",
|
||||
"langfuse_host": "https://test.langfuse.com",
|
||||
},
|
||||
service_name="langfuse",
|
||||
logger_for_identical_repeat_request = LangFuseHandler.get_langfuse_logger_for_request(
|
||||
standard_callback_dynamic_params=standard_params,
|
||||
in_memory_dynamic_logger_cache=dynamic_logging_cache,
|
||||
globalLangfuseLogger=globalLangfuseLogger,
|
||||
)
|
||||
assert cached_logger is result
|
||||
assert logger_for_identical_repeat_request is result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("globalLangfuseLogger", [None, global_langfuse_logger])
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
source 'https://rubygems.org'
|
||||
|
||||
gem 'rspec'
|
||||
gem 'ruby-openai'
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
base64 (0.2.0)
|
||||
diff-lcs (1.6.0)
|
||||
event_stream_parser (1.0.0)
|
||||
faraday (2.8.1)
|
||||
base64
|
||||
faraday-net_http (>= 2.0, < 3.1)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.0.2)
|
||||
multipart-post (2.4.1)
|
||||
rspec (3.13.0)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-support (3.13.2)
|
||||
ruby-openai (7.4.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby2_keywords (0.0.5)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
rspec
|
||||
ruby-openai
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.5
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
require 'openai'
|
||||
require 'rspec'
|
||||
|
||||
RSpec.describe 'OpenAI Assistants Passthrough' do
|
||||
let(:client) do
|
||||
OpenAI::Client.new(
|
||||
access_token: "sk-1234",
|
||||
uri_base: "http://0.0.0.0:4000/openai",
|
||||
request_timeout: 600
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
it 'performs basic assistant operations' do
|
||||
assistant = client.assistants.create(
|
||||
parameters: {
|
||||
name: "Math Tutor",
|
||||
instructions: "You are a personal math tutor. Write and run code to answer math questions.",
|
||||
tools: [{ type: "code_interpreter" }],
|
||||
model: "gpt-4o"
|
||||
}
|
||||
)
|
||||
expect(assistant).to include('id')
|
||||
expect(assistant['name']).to eq("Math Tutor")
|
||||
|
||||
assistants_list = client.assistants.list
|
||||
expect(assistants_list['data']).to be_an(Array)
|
||||
expect(assistants_list['data']).to include(include('id' => assistant['id']))
|
||||
|
||||
retrieved_assistant = client.assistants.retrieve(id: assistant['id'])
|
||||
expect(retrieved_assistant).to eq(assistant)
|
||||
|
||||
deleted_assistant = client.assistants.delete(id: assistant['id'])
|
||||
expect(deleted_assistant['deleted']).to be true
|
||||
expect(deleted_assistant['id']).to eq(assistant['id'])
|
||||
end
|
||||
|
||||
it 'performs streaming assistant operations' do
|
||||
puts "\n=== Starting Streaming Assistant Test ==="
|
||||
|
||||
assistant = client.assistants.create(
|
||||
parameters: {
|
||||
name: "Math Tutor",
|
||||
instructions: "You are a personal math tutor. Write and run code to answer math questions.",
|
||||
tools: [{ type: "code_interpreter" }],
|
||||
model: "gpt-4o"
|
||||
}
|
||||
)
|
||||
puts "Created assistant: #{assistant['id']}"
|
||||
expect(assistant).to include('id')
|
||||
|
||||
thread = client.threads.create
|
||||
puts "Created thread: #{thread['id']}"
|
||||
expect(thread).to include('id')
|
||||
|
||||
message = client.messages.create(
|
||||
thread_id: thread['id'],
|
||||
parameters: {
|
||||
role: "user",
|
||||
content: "I need to solve the equation `3x + 11 = 14`. Can you help me?"
|
||||
}
|
||||
)
|
||||
puts "Created message: #{message['id']}"
|
||||
puts "User question: #{message['content']}"
|
||||
expect(message).to include('id')
|
||||
expect(message['role']).to eq('user')
|
||||
|
||||
puts "\nStarting streaming response:"
|
||||
puts "------------------------"
|
||||
run = client.runs.create(
|
||||
thread_id: thread['id'],
|
||||
parameters: {
|
||||
assistant_id: assistant['id'],
|
||||
max_prompt_tokens: 256,
|
||||
max_completion_tokens: 16,
|
||||
stream: proc do |chunk, _bytesize|
|
||||
puts "Received chunk: #{chunk.inspect}" # Debug: Print raw chunk
|
||||
if chunk["object"] == "thread.message.delta"
|
||||
content = chunk.dig("delta", "content")
|
||||
puts "Content: #{content.inspect}" # Debug: Print content structure
|
||||
if content && content[0] && content[0]["text"]
|
||||
print content[0]["text"]["value"]
|
||||
$stdout.flush # Ensure output is printed immediately
|
||||
end
|
||||
end
|
||||
end
|
||||
}
|
||||
)
|
||||
puts "\n------------------------"
|
||||
puts "Run completed: #{run['id']}"
|
||||
expect(run).not_to be_nil
|
||||
ensure
|
||||
client.assistants.delete(id: assistant['id']) if assistant && assistant['id']
|
||||
client.threads.delete(id: thread['id']) if thread && thread['id']
|
||||
end
|
||||
end
|
||||
|
|
@ -1,141 +1,22 @@
|
|||
import pytest
|
||||
import openai
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import tempfile
|
||||
from typing_extensions import override
|
||||
from openai import AssistantEventHandler
|
||||
|
||||
|
||||
client = openai.OpenAI(base_url="http://0.0.0.0:4000/openai", api_key="sk-1234")
|
||||
|
||||
|
||||
def test_pass_through_file_operations():
|
||||
# Create a temporary file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w+", suffix=".txt", delete=False
|
||||
) as temp_file:
|
||||
temp_file.write("This is a test file for the OpenAI Assistants API.")
|
||||
temp_file.flush()
|
||||
|
||||
# create a file
|
||||
file = client.files.create(
|
||||
file=open(temp_file.name, "rb"),
|
||||
purpose="assistants",
|
||||
)
|
||||
print("file created", file)
|
||||
|
||||
# delete the file
|
||||
delete_file = client.files.delete(file.id)
|
||||
print("file deleted", delete_file)
|
||||
|
||||
|
||||
def test_openai_assistants_e2e_operations():
|
||||
assistant = client.beta.assistants.create(
|
||||
name="Math Tutor",
|
||||
instructions="You are a personal math tutor. Write and run code to answer math questions.",
|
||||
tools=[{"type": "code_interpreter"}],
|
||||
model="gpt-4o",
|
||||
)
|
||||
print("assistant created", assistant)
|
||||
|
||||
get_assistant = client.beta.assistants.retrieve(assistant.id)
|
||||
print(get_assistant)
|
||||
|
||||
delete_assistant = client.beta.assistants.delete(assistant.id)
|
||||
print(delete_assistant)
|
||||
|
||||
|
||||
class EventHandler(AssistantEventHandler):
|
||||
@override
|
||||
def on_text_created(self, text) -> None:
|
||||
print(f"\nassistant > ", end="", flush=True)
|
||||
|
||||
@override
|
||||
def on_text_delta(self, delta, snapshot):
|
||||
print(delta.value, end="", flush=True)
|
||||
|
||||
def on_tool_call_created(self, tool_call):
|
||||
print(f"\nassistant > {tool_call.type}\n", flush=True)
|
||||
|
||||
def on_tool_call_delta(self, delta, snapshot):
|
||||
if delta.type == "code_interpreter":
|
||||
if delta.code_interpreter.input:
|
||||
print(delta.code_interpreter.input, end="", flush=True)
|
||||
if delta.code_interpreter.outputs:
|
||||
print(f"\n\noutput >", flush=True)
|
||||
for output in delta.code_interpreter.outputs:
|
||||
if output.type == "logs":
|
||||
print(f"\n{output.logs}", flush=True)
|
||||
|
||||
|
||||
def test_openai_assistants_e2e_operations_stream():
|
||||
|
||||
assistant = client.beta.assistants.create(
|
||||
name="Math Tutor",
|
||||
instructions="You are a personal math tutor. Write and run code to answer math questions.",
|
||||
tools=[{"type": "code_interpreter"}],
|
||||
model="gpt-4o",
|
||||
)
|
||||
print("assistant created", assistant)
|
||||
|
||||
thread = client.beta.threads.create()
|
||||
print("thread created", thread)
|
||||
|
||||
message = client.beta.threads.messages.create(
|
||||
thread_id=thread.id,
|
||||
role="user",
|
||||
content="I need to solve the equation `3x + 11 = 14`. Can you help me?",
|
||||
)
|
||||
print("message created", message)
|
||||
|
||||
# Then, we use the `stream` SDK helper
|
||||
# with the `EventHandler` class to create the Run
|
||||
# and stream the response.
|
||||
|
||||
with client.beta.threads.runs.stream(
|
||||
thread_id=thread.id,
|
||||
assistant_id=assistant.id,
|
||||
instructions="Please address the user as Jane Doe. The user has a premium account.",
|
||||
event_handler=EventHandler(),
|
||||
) as stream:
|
||||
stream.until_done()
|
||||
|
||||
|
||||
def test_azure_openai_assistants_e2e_operations_stream():
|
||||
from openai import AzureOpenAI
|
||||
|
||||
client = AzureOpenAI(
|
||||
base_url="http://0.0.0.0:4000/azure-config-passthrough/openai",
|
||||
api_key="sk-1234",
|
||||
api_version="2025-01-01-preview",
|
||||
)
|
||||
assistant = client.beta.assistants.create(
|
||||
name="Math Tutor",
|
||||
instructions="You are a personal math tutor. Write and run code to answer math questions.",
|
||||
tools=[{"type": "code_interpreter"}],
|
||||
model="gpt-4o",
|
||||
)
|
||||
print("assistant created", assistant)
|
||||
|
||||
thread = client.beta.threads.create()
|
||||
print("thread created", thread)
|
||||
|
||||
message = client.beta.threads.messages.create(
|
||||
thread_id=thread.id,
|
||||
role="user",
|
||||
content="I need to solve the equation `3x + 11 = 14`. Can you help me?",
|
||||
)
|
||||
print("message created", message)
|
||||
|
||||
# Then, we use the `stream` SDK helper
|
||||
# with the `EventHandler` class to create the Run
|
||||
# and stream the response.
|
||||
|
||||
with client.beta.threads.runs.stream(
|
||||
thread_id=thread.id,
|
||||
assistant_id=assistant.id,
|
||||
instructions="Please address the user as Jane Doe. The user has a premium account.",
|
||||
event_handler=EventHandler(),
|
||||
) as stream:
|
||||
stream.until_done()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch, MagicMock
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
import pytest
|
||||
|
|
@ -31,17 +30,62 @@ class TestCustomLogger(CustomLogger):
|
|||
self.logged_kwargs = kwargs
|
||||
|
||||
|
||||
UPSTREAM_RESPONSE_BODY = {
|
||||
"id": "modr-abc123",
|
||||
"model": "omni-moderation-latest",
|
||||
"results": [
|
||||
{
|
||||
"flagged": False,
|
||||
"categories": {"violence": False},
|
||||
"category_scores": {"violence": 1.2e-06},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream():
|
||||
received: dict = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("content-length", 0) or 0))
|
||||
received["path"] = self.path
|
||||
received["body"] = json.loads(body or b"{}")
|
||||
payload = json.dumps(UPSTREAM_RESPONSE_BODY).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", received
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistants_passthrough_logging():
|
||||
async def test_passthrough_logging_payload_for_a_route_no_provider_handler_claims(
|
||||
upstream,
|
||||
):
|
||||
base_url, upstream_received = upstream
|
||||
|
||||
test_custom_logger = TestCustomLogger()
|
||||
litellm._async_success_callback = [test_custom_logger]
|
||||
|
||||
TARGET_URL = "https://api.openai.com/v1/assistants"
|
||||
TARGET_URL = f"{base_url}/v1/moderations"
|
||||
REQUEST_BODY = {
|
||||
"instructions": "You are a personal math tutor. When asked a question, write and run Python code to answer the question.",
|
||||
"name": "Math Tutor",
|
||||
"tools": [{"type": "code_interpreter"}],
|
||||
"model": "gpt-4.1-mini",
|
||||
"model": "omni-moderation-latest",
|
||||
"input": "I want to bake a cake for my friend's birthday.",
|
||||
}
|
||||
TARGET_METHOD = "POST"
|
||||
|
||||
|
|
@ -50,23 +94,18 @@ async def test_assistants_passthrough_logging():
|
|||
scope={
|
||||
"type": "http",
|
||||
"method": TARGET_METHOD,
|
||||
"path": "/v1/assistants",
|
||||
"path": "/v1/moderations",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(
|
||||
b"authorization",
|
||||
f"Bearer {os.getenv('OPENAI_API_KEY')}".encode(),
|
||||
),
|
||||
(b"openai-beta", b"assistants=v2"),
|
||||
(b"authorization", b"Bearer sk-test-passthrough"),
|
||||
],
|
||||
},
|
||||
),
|
||||
target=TARGET_URL,
|
||||
custom_headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
|
||||
"OpenAI-Beta": "assistants=v2",
|
||||
"Authorization": "Bearer sk-test-passthrough",
|
||||
},
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test",
|
||||
|
|
@ -83,6 +122,10 @@ async def test_assistants_passthrough_logging():
|
|||
print("result status code", result.status_code)
|
||||
print("result content", result.body)
|
||||
|
||||
assert upstream_received.get("path") == "/v1/moderations"
|
||||
assert upstream_received.get("body") == REQUEST_BODY
|
||||
assert result.status_code == 200
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert test_custom_logger.logged_kwargs is not None
|
||||
|
|
@ -92,79 +135,8 @@ async def test_assistants_passthrough_logging():
|
|||
assert passthrough_logging_payload is not None
|
||||
assert passthrough_logging_payload["url"] == TARGET_URL
|
||||
assert passthrough_logging_payload["request_body"] == REQUEST_BODY
|
||||
|
||||
# assert that the response body content matches the response body content
|
||||
client_facing_response_body = json.loads(result.body)
|
||||
assert passthrough_logging_payload["response_body"] == client_facing_response_body
|
||||
|
||||
# assert that the request method is correct
|
||||
assert passthrough_logging_payload["request_method"] == TARGET_METHOD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_threads_passthrough_logging():
|
||||
test_custom_logger = TestCustomLogger()
|
||||
litellm._async_success_callback = [test_custom_logger]
|
||||
|
||||
TARGET_URL = "https://api.openai.com/v1/threads"
|
||||
REQUEST_BODY = {}
|
||||
TARGET_METHOD = "POST"
|
||||
|
||||
result = await pass_through_request(
|
||||
request=Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"method": TARGET_METHOD,
|
||||
"path": "/v1/threads",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(
|
||||
b"authorization",
|
||||
f"Bearer {os.getenv('OPENAI_API_KEY')}".encode(),
|
||||
),
|
||||
(b"openai-beta", b"assistants=v2"),
|
||||
],
|
||||
},
|
||||
),
|
||||
target=TARGET_URL,
|
||||
custom_headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
|
||||
"OpenAI-Beta": "assistants=v2",
|
||||
},
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test",
|
||||
user_id="test",
|
||||
team_id="test",
|
||||
end_user_id="test",
|
||||
),
|
||||
custom_body=REQUEST_BODY,
|
||||
forward_headers=False,
|
||||
merge_query_params=False,
|
||||
)
|
||||
|
||||
print("got result", result)
|
||||
print("result status code", result.status_code)
|
||||
print("result content", result.body)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert test_custom_logger.logged_kwargs is not None
|
||||
passthrough_logging_payload = test_custom_logger.logged_kwargs[
|
||||
"passthrough_logging_payload"
|
||||
]
|
||||
assert passthrough_logging_payload is not None
|
||||
|
||||
# Fix for TypedDict access errors
|
||||
assert passthrough_logging_payload.get("url") == TARGET_URL
|
||||
assert passthrough_logging_payload.get("request_body") == REQUEST_BODY
|
||||
|
||||
# Fix for json.loads error with potential memoryview
|
||||
response_body = result.body
|
||||
client_facing_response_body = json.loads(response_body)
|
||||
|
||||
assert (
|
||||
passthrough_logging_payload.get("response_body") == client_facing_response_body
|
||||
)
|
||||
assert passthrough_logging_payload.get("request_method") == TARGET_METHOD
|
||||
client_facing_response_body = json.loads(result.body)
|
||||
assert client_facing_response_body == UPSTREAM_RESPONSE_BODY
|
||||
assert passthrough_logging_payload["response_body"] == client_facing_response_body
|
||||
|
|
|
|||
|
|
@ -23,9 +23,10 @@ from litellm.proxy.management_helpers.access_group_team_sync import (
|
|||
sync_team_access_group_membership,
|
||||
)
|
||||
|
||||
TEAM = "ags-team-a"
|
||||
OTHER_TEAM = "ags-team-b"
|
||||
GROUPS = ("ags-group-1", "ags-group-2", "ags-group-3")
|
||||
_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER", "master")
|
||||
TEAM = f"ags-team-a-{_XDIST_WORKER}"
|
||||
OTHER_TEAM = f"ags-team-b-{_XDIST_WORKER}"
|
||||
GROUPS = tuple(f"ags-group-{n}-{_XDIST_WORKER}" for n in (1, 2, 3))
|
||||
_DELETE_SEEDED = 'DELETE FROM "LiteLLM_AccessGroupTable" WHERE access_group_id = ANY($1::TEXT[])'
|
||||
_DELETE_TEAMS = 'DELETE FROM "LiteLLM_TeamTable" WHERE team_id = ANY($1::TEXT[])'
|
||||
|
||||
|
|
|
|||
|
|
@ -1160,3 +1160,220 @@ def test_count_content_list_rejects_unknown_type():
|
|||
message = str(exc_info.value)
|
||||
assert "Invalid content item type: totally_unknown_block" in message
|
||||
assert "tool_reference" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
{"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="},
|
||||
{"type": "url", "url": "https://example.com/image.png"},
|
||||
{"type": "file", "file_id": "file-abc123"},
|
||||
],
|
||||
ids=["base64", "url", "file"],
|
||||
)
|
||||
def test_token_counter_with_anthropic_image_block(source: dict[str, str]):
|
||||
"""Anthropic `image` blocks must count for every source variant, not raise `Invalid content item type` (which the router's context-window pre-call check swallows into an unfiltered dispatch)."""
|
||||
from litellm.constants import DEFAULT_IMAGE_TOKEN_COUNT
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{"type": "image", "source": source},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens = token_counter(
|
||||
model="anthropic/claude-sonnet-4-5-20250929",
|
||||
messages=messages,
|
||||
use_default_image_token_count=True,
|
||||
)
|
||||
assert tokens > DEFAULT_IMAGE_TOKEN_COUNT, (
|
||||
f"Expected the image block to contribute tokens, got {tokens}"
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_image_block_matches_equivalent_image_url():
|
||||
"""An Anthropic `image` block prices identically to the OpenAI `image_url` carrying the same bytes."""
|
||||
anthropic_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
openai_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
anthropic_tokens = token_counter(
|
||||
model="anthropic/claude-sonnet-4-5-20250929", messages=anthropic_messages
|
||||
)
|
||||
openai_tokens = token_counter(
|
||||
model="anthropic/claude-sonnet-4-5-20250929", messages=openai_messages
|
||||
)
|
||||
assert anthropic_tokens == openai_tokens
|
||||
|
||||
|
||||
def test_anthropic_image_block_nested_in_tool_result():
|
||||
"""An `image` block nested in a `tool_result.content` list is counted through the same recursion."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tokens = token_counter(
|
||||
model="anthropic/claude-sonnet-4-5-20250929",
|
||||
messages=messages,
|
||||
use_default_image_token_count=True,
|
||||
)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
({"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQ"}, "data:image/jpeg;base64,/9j/4AAQ"),
|
||||
({"type": "url", "url": "https://example.com/image.png"}, "https://example.com/image.png"),
|
||||
({"type": "file", "file_id": "file-abc123"}, ""),
|
||||
],
|
||||
ids=["base64", "url", "file"],
|
||||
)
|
||||
def test_anthropic_image_source_resolves_to_what_the_image_pricer_reads(source: dict[str, str], expected: str):
|
||||
"""base64 sources become a data URI, url sources pass through, file sources resolve to an empty string."""
|
||||
from litellm.litellm_core_utils.token_counter import _anthropic_image_source_data
|
||||
|
||||
assert _anthropic_image_source_data(source) == expected
|
||||
|
||||
|
||||
def test_anthropic_image_block_with_empty_base64_data():
|
||||
"""A base64 source with empty `data` prices as an image rather than raising."""
|
||||
from litellm.litellm_core_utils.token_counter import _count_content_list
|
||||
|
||||
tokens = _count_content_list(
|
||||
count_function=len,
|
||||
content_list=[
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}
|
||||
],
|
||||
use_default_image_token_count=False,
|
||||
default_token_count=None,
|
||||
)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_anthropic_image_block_without_source_raises():
|
||||
"""An `image` block with no `source` raises, matching the OpenAI `image_url`-without-`url` behavior."""
|
||||
from litellm.litellm_core_utils.token_counter import _count_content_list
|
||||
|
||||
with pytest.raises(ValueError, match="Error getting number of tokens from content list"):
|
||||
_count_content_list(
|
||||
count_function=len,
|
||||
content_list=[{"type": "image"}],
|
||||
use_default_image_token_count=False,
|
||||
default_token_count=None,
|
||||
)
|
||||
|
||||
# ... and `default_token_count`, the caller's opt-out from raising, still wins.
|
||||
assert (
|
||||
_count_content_list(
|
||||
count_function=len,
|
||||
content_list=[{"type": "image"}],
|
||||
use_default_image_token_count=False,
|
||||
default_token_count=7,
|
||||
)
|
||||
== 7
|
||||
)
|
||||
|
||||
|
||||
def _count_user_content(content: list[dict]) -> int:
|
||||
from litellm.litellm_core_utils.token_counter import token_counter
|
||||
|
||||
return token_counter(
|
||||
model="anthropic/claude-fable-5",
|
||||
messages=[{"role": "user", "content": content}],
|
||||
use_default_image_token_count=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
{"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"},
|
||||
{"type": "url", "url": "https://example.com/report.pdf"},
|
||||
{"type": "file", "file_id": "file-abc123"},
|
||||
],
|
||||
ids=["base64", "url", "file"],
|
||||
)
|
||||
def test_anthropic_document_block_with_opaque_source_is_priced_like_an_image(source: dict[str, str]):
|
||||
"""A `document` whose bytes can't be tokenized locally is priced like an `image`, not raised on."""
|
||||
prompt = {"type": "text", "text": "Summarize this file."}
|
||||
|
||||
assert _count_user_content([prompt, {"type": "document", "source": source}]) == _count_user_content(
|
||||
[prompt, {"type": "image", "source": source}]
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_document_block_text_sources_count_their_text():
|
||||
"""`text` and `content` document sources count the text they carry, as inline text blocks would."""
|
||||
prompt = {"type": "text", "text": "Summarize this file."}
|
||||
body = {"type": "text", "text": "Revenue grew eleven percent while churn fell to two percent."}
|
||||
picture = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}}
|
||||
|
||||
text_source = {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": body["text"]}}
|
||||
assert _count_user_content([prompt, text_source]) == _count_user_content([prompt, body])
|
||||
|
||||
string_content = {"type": "document", "source": {"type": "content", "content": body["text"]}}
|
||||
assert _count_user_content([prompt, string_content]) == _count_user_content([prompt, body])
|
||||
|
||||
block_content = {"type": "document", "source": {"type": "content", "content": [body, picture]}}
|
||||
assert _count_user_content([prompt, block_content]) == _count_user_content([prompt, body, picture])
|
||||
|
||||
|
||||
def test_anthropic_document_title_and_context_add_their_tokens():
|
||||
prompt = {"type": "text", "text": "Summarize this file."}
|
||||
source = {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}
|
||||
described = {"type": "document", "source": source, "title": "Q3 board packet", "context": "Shared by finance"}
|
||||
|
||||
assert _count_user_content([prompt, described]) == _count_user_content(
|
||||
[
|
||||
prompt,
|
||||
{"type": "text", "text": "Q3 board packet"},
|
||||
{"type": "text", "text": "Shared by finance"},
|
||||
{"type": "document", "source": source},
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -424,6 +424,29 @@ def test_virtual_key_llm_api_routes_rejects_non_get_mcp_server_discovery(route,
|
|||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_virtual_key_llm_api_routes_allows_model_group_info():
|
||||
"""Regression test: the UI mints virtual keys with key_type="llm_api", which
|
||||
maps to allowed_routes=["llm_api_routes"]. The Playground model picker loads
|
||||
its options from GET /model_group/info, so that key must reach the route or
|
||||
no model can be selected. The handler already scopes the response to the
|
||||
models the key can call.
|
||||
"""
|
||||
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
allowed_routes=["llm_api_routes"],
|
||||
)
|
||||
|
||||
assert (
|
||||
RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route="/model_group/info",
|
||||
valid_token=valid_token,
|
||||
request=_mock_request("GET"),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
|
|
@ -523,7 +546,7 @@ def test_virtual_key_llm_api_routes_allows_model_info(route):
|
|||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info"])
|
||||
@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info", "/model_group/info"])
|
||||
def test_model_info_not_classified_as_llm_api(route):
|
||||
"""Membership in `llm_api_routes` must not promote /model/info to an
|
||||
`is_llm_api_route()`. That predicate gates DISABLE_LLM_API_ENDPOINTS,
|
||||
|
|
@ -535,10 +558,10 @@ def test_model_info_not_classified_as_llm_api(route):
|
|||
assert RouteChecks.is_llm_api_route(route=route) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/v2/model/info", "/model_group/info"])
|
||||
@pytest.mark.parametrize("route", ["/v2/model/info"])
|
||||
def test_virtual_key_llm_api_routes_denies_other_model_info_routes(route):
|
||||
"""The grant is scoped to the two /model/info paths. The paginated Admin UI
|
||||
listing and the model-group endpoint stay outside it.
|
||||
"""The grant covers the model metadata reads an AI API key needs. The
|
||||
paginated Admin UI listing stays outside it.
|
||||
"""
|
||||
|
||||
valid_token = UserAPIKeyAuth(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import pytest
|
|||
from fastapi import Depends, FastAPI, Header, Query, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.management_endpoints.management_v1.common as common_module
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
import litellm.proxy.list_api.common as common_module
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_CONTENT_TYPE,
|
||||
ManagementProblem,
|
||||
_declared_query_params,
|
||||
|
|
@ -106,7 +106,17 @@ def test_declared_query_params_is_empty_when_the_route_has_no_dependant():
|
|||
# `fastapi>=0.136.3,<1.0`. Add a name here whenever a supported release drops one.
|
||||
FASTAPI_NAMES_REMOVED_IN_0_140_7 = frozenset({"get_flat_dependant"})
|
||||
|
||||
MANAGEMENT_V1_PACKAGE = Path(str(common_module.__file__)).parent
|
||||
LIST_API_PACKAGE = Path(str(common_module.__file__)).parent
|
||||
PROXY_PACKAGE = LIST_API_PACKAGE.parent
|
||||
GUARDED_PACKAGES = (
|
||||
LIST_API_PACKAGE,
|
||||
PROXY_PACKAGE / "management_endpoints" / "management_v1",
|
||||
PROXY_PACKAGE / "public_endpoints" / "public_v1",
|
||||
)
|
||||
FRAMEWORK_SOURCE_FILES = sorted(
|
||||
(path for package in GUARDED_PACKAGES for path in package.glob("*.py")),
|
||||
key=lambda path: (path.parent.name, path.name),
|
||||
)
|
||||
|
||||
|
||||
def _public_names(module: ModuleType) -> frozenset[str]:
|
||||
|
|
@ -123,17 +133,15 @@ def _fastapi_names_imported_by(source_file: Path) -> frozenset[str]:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_file", sorted(MANAGEMENT_V1_PACKAGE.glob("*.py")), ids=lambda path: path.name
|
||||
)
|
||||
@pytest.mark.parametrize("source_file", FRAMEWORK_SOURCE_FILES, ids=lambda path: f"{path.parent.name}/{path.name}")
|
||||
def test_no_module_imports_a_fastapi_name_removed_in_a_supported_release(source_file: Path):
|
||||
"""`pyproject.toml` allows fastapi up to <1.0, but CI only ever resolves 0.136.3.
|
||||
|
||||
Every other test here passes just as well against a module importing a name
|
||||
fastapi has since deleted, because the pinned fastapi still has it. On a user's
|
||||
fastapi>=0.140.7 that import is an ImportError, and `proxy_server` imports this
|
||||
package unguarded at module level, so it takes the whole proxy down rather than
|
||||
just these routes. Globbing the package means a new module is covered on sight.
|
||||
fastapi>=0.140.7 that import is an ImportError, and `proxy_server` imports every one
|
||||
of these packages unguarded at module level, so it takes the whole proxy down rather
|
||||
than just these routes. Globbing them means a new module is covered on sight.
|
||||
"""
|
||||
assert not _fastapi_names_imported_by(source_file) & FASTAPI_NAMES_REMOVED_IN_0_140_7
|
||||
|
||||
|
|
@ -148,7 +156,7 @@ def test_common_still_imports_when_fastapi_has_dropped_those_names(monkeypatch:
|
|||
for name in FASTAPI_NAMES_REMOVED_IN_0_140_7:
|
||||
monkeypatch.delattr(fastapi_dependency_utils, name, raising=False)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"management_v1_common__simulated_fastapi", Path(str(common_module.__file__))
|
||||
"list_api_common__simulated_fastapi", Path(str(common_module.__file__))
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
reimported = importlib.util.module_from_spec(spec)
|
||||
254
tests/test_litellm/proxy/list_api/test_in_memory.py
Normal file
254
tests/test_litellm/proxy/list_api/test_in_memory.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.list_api.in_memory import Cells, InMemoryListExecutor
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
AnyOf,
|
||||
Compare,
|
||||
IsNull,
|
||||
QueryPlan,
|
||||
SortKey,
|
||||
Within,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Row:
|
||||
name: str
|
||||
size: float | None = None
|
||||
tags: tuple[str | None, ...] = ()
|
||||
seen_at: datetime | None = None
|
||||
|
||||
|
||||
def _cells(row: Row) -> Cells:
|
||||
return MappingProxyType({"name": row.name, "size": row.size, "tags": row.tags, "seen_at": row.seen_at})
|
||||
|
||||
|
||||
def _executor(*rows: Row, **kwargs) -> InMemoryListExecutor[Row]:
|
||||
return InMemoryListExecutor(rows=rows, cells=_cells, **kwargs)
|
||||
|
||||
|
||||
def _plan(where=(), order=(SortKey(field="name", descending=False),), skip=0, take=50) -> QueryPlan:
|
||||
return QueryPlan(where=where, order=order, skip=skip, take=take)
|
||||
|
||||
|
||||
async def _names(executor: InMemoryListExecutor[Row], plan: QueryPlan) -> list[str]:
|
||||
return [row.name for row in await executor.find_many(plan)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_page_is_sliced_after_the_sort_not_before():
|
||||
executor = _executor(Row("c"), Row("a"), Row("b"), Row("d"))
|
||||
|
||||
assert await _names(executor, _plan(skip=1, take=2)) == ["b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_ignores_the_page_and_counts_the_match_set():
|
||||
executor = _executor(*(Row(f"r{index}") for index in range(7)))
|
||||
|
||||
assert await executor.count(()) == 7
|
||||
assert len(await executor.find_many(_plan(take=3))) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nulls_sort_last_in_both_directions():
|
||||
"""`order_by_sql` renders NULLS LAST both ways; an in-memory plan has to agree."""
|
||||
executor = _executor(Row("small", size=1.0), Row("unsized"), Row("big", size=9.0))
|
||||
|
||||
ascending = SortKey(field="size", descending=False)
|
||||
descending = SortKey(field="size", descending=True)
|
||||
assert await _names(executor, _plan(order=(ascending,))) == ["small", "big", "unsized"]
|
||||
assert await _names(executor, _plan(order=(descending,))) == ["big", "small", "unsized"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_last_sort_key_breaks_ties_in_the_first():
|
||||
executor = _executor(Row("b", size=1.0), Row("a", size=1.0), Row("c", size=0.0))
|
||||
|
||||
order = (SortKey(field="size", descending=False), SortKey(field="name", descending=False))
|
||||
|
||||
assert await _names(executor, _plan(order=order)) == ["c", "a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_predicate_holds_when_any_element_of_a_repeated_field_matches():
|
||||
executor = _executor(Row("azure", tags=("azure", "bedrock")), Row("openai", tags=("openai",)))
|
||||
|
||||
where = (Compare(field="tags", op="contains", value="bedrock"),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["azure"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_repeated_field_with_no_elements_matches_nothing():
|
||||
executor = _executor(Row("untagged"))
|
||||
|
||||
where = (Compare(field="tags", op="contains", value="anything"),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_repeated_field_is_matched_element_by_element_not_as_one_string():
|
||||
"""Without the per-element lift the tuple stringifies, and its punctuation becomes matchable."""
|
||||
executor = _executor(Row("azure", tags=("azure", "bedrock")))
|
||||
|
||||
where = (Compare(field="tags", op="contains", value="e', 'b"),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_within_matches_an_element_of_a_repeated_field():
|
||||
executor = _executor(Row("azure", tags=("azure", "bedrock")), Row("openai", tags=("openai",)))
|
||||
|
||||
where = (Within(field="tags", values=("bedrock",)),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["azure"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contains_is_case_insensitive_like_ilike():
|
||||
executor = _executor(Row("GPT-5"), Row("claude-opus"))
|
||||
|
||||
where = (Compare(field="name", op="contains", value="gpt"),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["GPT-5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("op", ["eq", "not", "gt", "gte", "lt", "lte", "contains"])
|
||||
async def test_a_null_cell_satisfies_no_comparison(op: str):
|
||||
"""SQL's three-valued logic: `col <> 1` does not return NULL rows, so neither does this."""
|
||||
executor = _executor(Row("unsized"))
|
||||
|
||||
where = (Compare(field="size", op=op, value=1.0),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_null_is_the_way_to_ask_for_the_null_rows():
|
||||
executor = _executor(Row("unsized"), Row("sized", size=2.0))
|
||||
|
||||
assert await _names(executor, _plan(where=(IsNull(field="size", negated=False),))) == ["unsized"]
|
||||
assert await _names(executor, _plan(where=(IsNull(field="size", negated=True),))) == ["sized"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_null_reads_a_repeated_field_element_by_element_too():
|
||||
"""Every other predicate lifts over a repeated field; `is_null` reading the container
|
||||
instead would make a field holding only nulls indistinguishable from a populated one."""
|
||||
executor = _executor(Row("only_nulls", tags=(None,)), Row("populated", tags=("openai",)))
|
||||
|
||||
assert await _names(executor, _plan(where=(IsNull(field="tags", negated=False),))) == ["only_nulls"]
|
||||
assert await _names(executor, _plan(where=(IsNull(field="tags", negated=True),))) == ["populated"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordering_comparisons_work_across_the_cell_types():
|
||||
when = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
executor = _executor(Row("early", seen_at=when), Row("late", seen_at=datetime(2026, 9, 1, tzinfo=timezone.utc)))
|
||||
|
||||
where = (Compare(field="seen_at", op="gt", value=when),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["late"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"op,expected",
|
||||
[
|
||||
("eq", ["mid"]),
|
||||
("not", ["low", "high"]),
|
||||
("gt", ["high"]),
|
||||
("gte", ["mid", "high"]),
|
||||
("lt", ["low"]),
|
||||
("lte", ["low", "mid"]),
|
||||
],
|
||||
)
|
||||
async def test_every_comparison_operator_selects_the_rows_sql_would(op: str, expected: list[str]):
|
||||
"""The endpoint only exposes eq/in/contains today, so without this the ordering
|
||||
operators are live code no test evaluates."""
|
||||
executor = _executor(Row("low", size=1.0), Row("mid", size=2.0), Row("high", size=3.0))
|
||||
|
||||
where = (Compare(field="size", op=op, value=2.0),)
|
||||
|
||||
assert sorted(await _names(executor, _plan(where=where))) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_value_of_the_wrong_type_matches_nothing_rather_than_raising():
|
||||
executor = _executor(Row("a", size=1.0))
|
||||
|
||||
where = (Compare(field="size", op="gt", value="not-a-number"),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_within_matches_any_of_its_values():
|
||||
executor = _executor(Row("a"), Row("b"), Row("c"))
|
||||
|
||||
where = (Within(field="name", values=("a", "c")),)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["a", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_any_of_is_a_disjunction_and_the_plan_is_a_conjunction():
|
||||
executor = _executor(Row("alpha", size=1.0), Row("beta", size=1.0), Row("alpha-2", size=9.0))
|
||||
|
||||
where = (
|
||||
Compare(field="size", op="lte", value=5.0),
|
||||
AnyOf(clauses=(Compare(field="name", op="contains", value="alpha"),)),
|
||||
)
|
||||
|
||||
assert await _names(executor, _plan(where=where)) == ["alpha"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_page_sees_the_page_and_only_the_page():
|
||||
seen: list[tuple[str, ...]] = []
|
||||
|
||||
async def _record(rows: Sequence[Row]) -> Sequence[Row]:
|
||||
seen.append(tuple(row.name for row in rows))
|
||||
return rows
|
||||
|
||||
executor = _executor(*(Row(f"r{index:02d}") for index in range(20)), enrich_page=_record)
|
||||
|
||||
await executor.find_many(_plan(skip=5, take=3))
|
||||
|
||||
assert seen == [("r05", "r06", "r07")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_page_can_replace_the_rows_it_is_given():
|
||||
async def _rename(rows: Sequence[Row]) -> Sequence[Row]:
|
||||
return tuple(Row(f"{row.name}!") for row in rows)
|
||||
|
||||
executor = _executor(Row("a"), Row("b"), enrich_page=_rename)
|
||||
|
||||
assert await _names(executor, _plan()) == ["a!", "b!"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counting_never_enriches():
|
||||
async def _explode(rows: Sequence[Row]) -> Sequence[Row]:
|
||||
raise AssertionError("count must not resolve anything a row does not already carry")
|
||||
|
||||
executor = _executor(Row("a"), Row("b"), enrich_page=_explode)
|
||||
|
||||
assert await executor.count(()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rows_pass_through_untouched_without_an_enricher():
|
||||
executor = _executor(Row("a"), Row("b"))
|
||||
|
||||
assert await _names(executor, _plan()) == ["a", "b"]
|
||||
|
|
@ -7,13 +7,12 @@ from fastapi import Request
|
|||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
build_page_links,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
AnyOf,
|
||||
Compare,
|
||||
FilterSpec,
|
||||
|
|
@ -30,6 +29,7 @@ from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
|||
order_by_sql,
|
||||
where_sql,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import (
|
||||
PageLinks,
|
||||
PageMeta,
|
||||
|
|
@ -450,6 +450,29 @@ def test_one_bad_key_rejects_the_whole_multi_key_sort():
|
|||
assert _problem({"sort": "-created_at,api_key"}).type == f"{PROBLEM_TYPE_BASE}invalid-sort-field"
|
||||
|
||||
|
||||
def test_a_repeated_sort_field_is_rejected():
|
||||
"""An in-memory executor sorts once per key, so a repeat is unbounded work an
|
||||
unauthenticated caller controls. Rejecting repeats caps it at len(sortable)."""
|
||||
problem = _problem({"sort": "created_at,max_budget,created_at"})
|
||||
|
||||
assert problem.status == 400
|
||||
assert problem.type == f"{PROBLEM_TYPE_BASE}duplicate-sort-field"
|
||||
assert "created_at" in problem.detail
|
||||
assert "max_budget" not in problem.detail
|
||||
|
||||
|
||||
def test_a_field_repeated_in_both_directions_is_still_a_repeat():
|
||||
assert _problem({"sort": "created_at,-created_at"}).type == f"{PROBLEM_TYPE_BASE}duplicate-sort-field"
|
||||
|
||||
|
||||
def test_the_appended_tiebreaker_does_not_count_as_a_repeat():
|
||||
"""The tiebreaker is added after parsing, so sorting by it explicitly stays legal."""
|
||||
assert _plan({"sort": "-budget_id"}).order == (
|
||||
SortKey(field="budget_id", descending=True),
|
||||
SortKey(field="budget_id", descending=False),
|
||||
)
|
||||
|
||||
|
||||
def test_a_double_dash_prefix_is_not_a_descending_sort():
|
||||
assert _problem({"sort": "--created_at"}).type == f"{PROBLEM_TYPE_BASE}invalid-sort-field"
|
||||
|
||||
|
|
@ -10,22 +10,22 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
)
|
||||
from litellm.proxy.list_api.list_framework import (
|
||||
Compare,
|
||||
ScopeWhere,
|
||||
build_query_plan,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1 import router
|
||||
from litellm.proxy.management_endpoints.management_v1.budgets import (
|
||||
BUDGETS_LIST_SPEC,
|
||||
BudgetListItem,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.list_framework import (
|
||||
Compare,
|
||||
ScopeWhere,
|
||||
build_query_plan,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
app = FastAPI()
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.management_v1 import router
|
||||
from litellm.proxy.management_endpoints.management_v1.common import (
|
||||
MANAGEMENT_V1_PREFIX,
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.management_v1 import router
|
||||
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
app = FastAPI()
|
||||
|
|
|
|||
|
|
@ -184,3 +184,69 @@ def test_transform_request_unsafe_body(client, auth_as, monkeypatch):
|
|||
response = client.post("/utils/transform_request", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert "unsafe" in response.text or "error" in response.text
|
||||
|
||||
|
||||
def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, auth_as, monkeypatch):
|
||||
"""The ``litellm.token_counter`` fallback counts the request's tools and system prompt, and Anthropic ``image``/``document`` blocks, instead of 500ing."""
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False)
|
||||
system = [{"type": "text", "text": "You are a terse assistant. Answer in one sentence."}]
|
||||
tools = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Look up the current weather for a city",
|
||||
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
||||
}
|
||||
]
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this file?"},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}},
|
||||
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
def count(payload: dict) -> int:
|
||||
with auth_as():
|
||||
response = client.post("/utils/token_counter", json={"model": "claude-fable-5", **payload})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["total_tokens"]
|
||||
|
||||
bare = count({"messages": messages})
|
||||
full = count({"messages": messages, "tools": tools, "system": system})
|
||||
|
||||
assert bare == litellm.token_counter(model="claude-fable-5", messages=messages)
|
||||
assert full == litellm.token_counter(
|
||||
model="claude-fable-5",
|
||||
messages=[{"role": "system", "content": system}, *messages],
|
||||
tools=tools,
|
||||
)
|
||||
assert full > bare
|
||||
|
||||
|
||||
def test_token_counter_fallback_prompt_with_tools_does_not_500(client, auth_as, monkeypatch):
|
||||
"""Regression: a ``prompt`` request carrying ``tools`` but no ``messages`` still counts, because the fallback attaches tools only when counting messages (``token_counter`` rejects tools on the text path)."""
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False)
|
||||
prompt = "count the tokens in this sentence please"
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Look up the current weather for a city",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with auth_as():
|
||||
response = client.post(
|
||||
"/utils/token_counter", json={"model": "claude-fable-5", "prompt": prompt, "tools": tools}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["total_tokens"] == litellm.token_counter(model="claude-fable-5", text=prompt)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
from litellm.proxy.proxy_server import app
|
||||
from litellm.types.router import ModelGroupInfo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
MODEL_HUB_PATH = "/public/v1/model_hub"
|
||||
LEGACY_MODEL_HUB_PATH = "/public/model_hub"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _FakeRouter:
|
||||
"""Stands in for the running Router: `_get_model_group_info` only ever asks it this."""
|
||||
|
||||
infos: Mapping[str, ModelGroupInfo]
|
||||
|
||||
def get_model_group_info(self, model_group: str) -> ModelGroupInfo | None:
|
||||
return self.infos.get(model_group)
|
||||
|
||||
|
||||
def _info(
|
||||
name: str,
|
||||
*,
|
||||
mode: str = "chat",
|
||||
providers: Sequence[str] = ("openai",),
|
||||
**overrides: object,
|
||||
) -> ModelGroupInfo:
|
||||
return ModelGroupInfo(model_group=name, mode=mode, providers=list(providers), **overrides)
|
||||
|
||||
|
||||
def _publish(monkeypatch, infos: Sequence[ModelGroupInfo], prisma_client: object | None = None) -> None:
|
||||
monkeypatch.setattr(litellm, "public_model_groups", [info.model_group for info in infos])
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.llm_router",
|
||||
_FakeRouter(infos=MappingProxyType({info.model_group: info for info in infos})),
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
|
||||
|
||||
def _named(count: int, **overrides: object) -> Sequence[ModelGroupInfo]:
|
||||
return tuple(_info(f"model-{index:03d}", **overrides) for index in range(count))
|
||||
|
||||
|
||||
def _get(query: str = "", **kwargs):
|
||||
suffix = f"?{query}" if query else ""
|
||||
return client.get(f"{MODEL_HUB_PATH}{suffix}", **kwargs)
|
||||
|
||||
|
||||
def _groups(response) -> list[str]:
|
||||
return [row["model_group"] for row in response.json()["data"]]
|
||||
|
||||
|
||||
def _health_check(model_name: str, status: str = "healthy"):
|
||||
check = MagicMock()
|
||||
check.model_name = model_name
|
||||
check.model_id = None
|
||||
check.status = status
|
||||
check.response_time_ms = 12.5
|
||||
check.checked_at = datetime(2026, 8, 1, 9, 30, tzinfo=timezone.utc)
|
||||
return check
|
||||
|
||||
|
||||
def _recording_prisma(checks: Sequence[object] = ()):
|
||||
"""A prisma client whose only exercised call is the health-check read, recorded for assertions."""
|
||||
read = AsyncMock(return_value=list(checks))
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.get_latest_health_checks_for_models = read
|
||||
return prisma_client, read
|
||||
|
||||
|
||||
def _asked_about(read) -> list[str]:
|
||||
return list(read.call_args.args[0]) if read.call_args.args else list(read.call_args.kwargs["model_names"])
|
||||
|
||||
|
||||
def test_the_route_is_registered_as_a_public_route():
|
||||
"""`public_routes` membership is an exact-string check, so the path has to match literally."""
|
||||
assert MODEL_HUB_PATH in LiteLLMRoutes.public_routes.value
|
||||
|
||||
|
||||
def test_a_page_slices_the_published_model_groups(monkeypatch):
|
||||
_publish(monkeypatch, _named(120))
|
||||
|
||||
response = _get("page=2&page_size=25")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert _groups(response) == [f"model-{index:03d}" for index in range(25, 50)]
|
||||
assert response.json()["meta"] == {"total_count": 120, "page": 2, "page_size": 25, "total_pages": 5}
|
||||
|
||||
|
||||
def test_every_page_link_resolves_to_the_page_it_names(monkeypatch):
|
||||
_publish(monkeypatch, _named(120))
|
||||
|
||||
links = _get("page=2&page_size=25").json()["links"]
|
||||
|
||||
assert client.get(links["first"]).json()["meta"]["page"] == 1
|
||||
assert client.get(links["prev"]).json()["meta"]["page"] == 1
|
||||
assert client.get(links["self"]).json()["meta"]["page"] == 2
|
||||
assert client.get(links["next"]).json()["meta"]["page"] == 3
|
||||
assert client.get(links["last"]).json()["meta"]["page"] == 5
|
||||
|
||||
|
||||
def test_total_count_counts_the_whole_match_set_not_the_page(monkeypatch):
|
||||
_publish(monkeypatch, (*_named(30), _info("embedder-1", mode="embedding")))
|
||||
|
||||
response = _get("filter[mode]=chat&page_size=5")
|
||||
|
||||
assert len(response.json()["data"]) == 5
|
||||
assert response.json()["meta"]["total_count"] == 30
|
||||
|
||||
|
||||
def test_health_is_resolved_only_for_the_rows_on_the_page(monkeypatch):
|
||||
"""The bug this endpoint exists to fix: enriching before slicing costs the whole collection.
|
||||
|
||||
An enrich-then-slice implementation asks about all 200 model groups here, not the 10 served.
|
||||
"""
|
||||
prisma_client, read = _recording_prisma()
|
||||
_publish(monkeypatch, _named(200), prisma_client=prisma_client)
|
||||
|
||||
response = _get("page=1&page_size=10")
|
||||
|
||||
assert len(response.json()["data"]) == 10
|
||||
assert _asked_about(read) == [f"model-{index:03d}" for index in range(10)]
|
||||
|
||||
|
||||
def test_health_is_asked_about_the_second_page_not_the_first(monkeypatch):
|
||||
prisma_client, read = _recording_prisma()
|
||||
_publish(monkeypatch, _named(200), prisma_client=prisma_client)
|
||||
|
||||
_get("page=4&page_size=10")
|
||||
|
||||
assert _asked_about(read) == [f"model-{index:03d}" for index in range(30, 40)]
|
||||
|
||||
|
||||
def test_the_latest_health_check_lands_on_its_row(monkeypatch):
|
||||
prisma_client, _ = _recording_prisma([_health_check("model-001", status="unhealthy")])
|
||||
_publish(monkeypatch, _named(3), prisma_client=prisma_client)
|
||||
|
||||
rows = {row["model_group"]: row for row in _get().json()["data"]}
|
||||
|
||||
assert rows["model-001"]["health_status"] == "unhealthy"
|
||||
assert rows["model-001"]["health_response_time"] == 12.5
|
||||
assert rows["model-001"]["health_checked_at"] == "2026-08-01T09:30:00+00:00"
|
||||
assert rows["model-000"]["health_status"] is None
|
||||
|
||||
|
||||
def test_a_health_read_that_returns_nothing_still_serves_the_page(monkeypatch):
|
||||
prisma_client, read = _recording_prisma()
|
||||
read.return_value = []
|
||||
_publish(monkeypatch, _named(3), prisma_client=prisma_client)
|
||||
|
||||
response = _get()
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert _groups(response) == ["model-000", "model-001", "model-002"]
|
||||
|
||||
|
||||
def test_rows_are_alphabetical_by_default(monkeypatch):
|
||||
_publish(monkeypatch, (_info("zeta"), _info("alpha"), _info("mid")))
|
||||
|
||||
assert _groups(_get()) == ["alpha", "mid", "zeta"]
|
||||
|
||||
|
||||
def test_a_descending_sort_reverses_the_order(monkeypatch):
|
||||
_publish(monkeypatch, (_info("zeta"), _info("alpha"), _info("mid")))
|
||||
|
||||
assert _groups(_get("sort=-model_group")) == ["zeta", "mid", "alpha"]
|
||||
|
||||
|
||||
def test_sorting_by_a_numeric_field_puts_the_unset_ones_last_in_both_directions(monkeypatch):
|
||||
_publish(
|
||||
monkeypatch,
|
||||
(
|
||||
_info("cheap", input_cost_per_token=0.000001),
|
||||
_info("unpriced"),
|
||||
_info("dear", input_cost_per_token=0.00003),
|
||||
),
|
||||
)
|
||||
|
||||
assert _groups(_get("sort=input_cost_per_token")) == ["cheap", "dear", "unpriced"]
|
||||
assert _groups(_get("sort=-input_cost_per_token")) == ["dear", "cheap", "unpriced"]
|
||||
|
||||
|
||||
def test_an_undeclared_sort_field_is_a_problem_naming_the_allowed_fields(monkeypatch):
|
||||
_publish(monkeypatch, _named(3))
|
||||
|
||||
response = _get("sort=providers")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
body = response.json()
|
||||
assert "providers" in body["detail"]
|
||||
assert body["allowed"] == [
|
||||
"input_cost_per_token",
|
||||
"max_input_tokens",
|
||||
"max_output_tokens",
|
||||
"mode",
|
||||
"model_group",
|
||||
"output_cost_per_token",
|
||||
]
|
||||
|
||||
|
||||
def test_a_repeated_sort_field_is_rejected_rather_than_sorted_twice(monkeypatch):
|
||||
"""The route is unauthenticated and sorts in memory once per key, so an unbounded
|
||||
key list is CPU any caller can spend."""
|
||||
_publish(monkeypatch, _named(3))
|
||||
|
||||
response = _get("sort=model_group,model_group")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["type"] == "urn:litellm:error:duplicate-sort-field"
|
||||
|
||||
|
||||
def test_an_unknown_query_parameter_is_a_problem_outside_management_v1(monkeypatch):
|
||||
"""The `ManagementProblem` handler is registered on the app, not on the `/management/v1` prefix."""
|
||||
_publish(monkeypatch, _named(3))
|
||||
|
||||
response = _get("limit=10")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert "limit" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_repeated_query_parameter_is_rejected(monkeypatch):
|
||||
_publish(monkeypatch, _named(3))
|
||||
|
||||
response = _get("page=1&page=99")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "page" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_mode_filter_narrows_the_list(monkeypatch):
|
||||
_publish(monkeypatch, (_info("chatter"), _info("embedder", mode="embedding")))
|
||||
|
||||
assert _groups(_get("filter[mode]=embedding")) == ["embedder"]
|
||||
assert _groups(_get("filter[mode][in]=chat,embedding")) == ["chatter", "embedder"]
|
||||
|
||||
|
||||
def test_a_provider_filter_matches_a_model_group_serving_that_provider(monkeypatch):
|
||||
_publish(
|
||||
monkeypatch,
|
||||
(
|
||||
_info("openai-only"),
|
||||
_info("mixed", providers=["azure", "bedrock"]),
|
||||
),
|
||||
)
|
||||
|
||||
assert _groups(_get("filter[providers][contains]=bedrock")) == ["mixed"]
|
||||
assert _groups(_get("filter[providers][contains]=openai")) == ["openai-only"]
|
||||
assert _groups(_get("filter[providers][contains]=e, b")) == []
|
||||
|
||||
|
||||
def test_the_search_matches_model_group_names_case_insensitively(monkeypatch):
|
||||
_publish(monkeypatch, (_info("gpt-4o"), _info("claude-opus"), _info("GPT-5")))
|
||||
|
||||
assert _groups(_get("q=gpt")) == ["GPT-5", "gpt-4o"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarded(monkeypatch):
|
||||
"""A proxy with a master key set, so anything but a public route would demand credentials."""
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-1234")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
|
||||
|
||||
def test_an_unauthenticated_caller_is_served(monkeypatch, guarded):
|
||||
_publish(monkeypatch, _named(2))
|
||||
|
||||
response = _get()
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
|
||||
def test_a_bad_api_key_does_not_turn_a_public_route_into_a_401(monkeypatch, guarded):
|
||||
_publish(monkeypatch, _named(2))
|
||||
|
||||
response = _get(headers={"Authorization": "Bearer sk-definitely-not-a-real-key"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
|
||||
def test_no_published_model_groups_yields_an_empty_but_coherent_envelope(monkeypatch):
|
||||
_publish(monkeypatch, ())
|
||||
monkeypatch.setattr(litellm, "public_model_groups", None)
|
||||
|
||||
response = _get()
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["data"] == []
|
||||
assert body["meta"] == {"total_count": 0, "page": 1, "page_size": 50, "total_pages": 0}
|
||||
assert body["links"]["first"].endswith("page=1")
|
||||
assert body["links"]["last"].endswith("page=1")
|
||||
assert body["links"]["next"] is None
|
||||
assert body["links"]["prev"] is None
|
||||
|
||||
|
||||
def test_no_router_answers_with_a_problem_rather_than_the_openai_error_shape(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
|
||||
response = _get()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["type"] == "urn:litellm:error:no-llm-router"
|
||||
|
||||
|
||||
def test_an_unexpected_router_failure_answers_as_a_problem_not_the_openai_error_shape(monkeypatch):
|
||||
class _Exploding:
|
||||
def get_model_group_info(self, model_group: str) -> ModelGroupInfo:
|
||||
raise RuntimeError("router blew up")
|
||||
|
||||
monkeypatch.setattr(litellm, "public_model_groups", ["boom"])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", _Exploding())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
|
||||
response = _get()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["type"] == "urn:litellm:error:internal-server-error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["", "page=1&page_size=2"])
|
||||
def test_the_endpoint_it_supersedes_still_answers_with_its_bare_array(monkeypatch, query: str):
|
||||
"""`/public/model_hub` is what the shipped UI calls; this PR must not move it at all."""
|
||||
_publish(monkeypatch, _named(3))
|
||||
suffix = f"?{query}" if query else ""
|
||||
|
||||
response = client.get(f"{LEGACY_MODEL_HUB_PATH}{suffix}")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert isinstance(body, list)
|
||||
assert [row["model_group"] for row in body] == ["model-000", "model-001", "model-002"]
|
||||
|
|
@ -9,6 +9,7 @@ Symbols pinned here:
|
|||
- ``PrismaClient.save_health_check_result``
|
||||
- ``PrismaClient.get_health_check_history``
|
||||
- ``PrismaClient.get_all_latest_health_checks``
|
||||
- ``PrismaClient.get_latest_health_checks_for_models``
|
||||
- ``PrismaClient._is_sha256_hex`` (a nested helper inside
|
||||
``migrate_passwords_to_scrypt_async``; the pin list assigns it to this
|
||||
cluster as a documentation artifact)
|
||||
|
|
@ -290,3 +291,40 @@ async def test_get_all_latest_health_checks_db_error_returns_empty_list(
|
|||
side_effect=RuntimeError("oops")
|
||||
)
|
||||
assert await prisma_client.get_all_latest_health_checks() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_health_checks_for_models_bounds_the_query_to_those_models(
|
||||
prisma_client: PrismaClient,
|
||||
) -> None:
|
||||
"""A paged caller reads health for its page; an unbounded read is the bug this exists to avoid."""
|
||||
prisma_client.db.litellm_healthchecktable.find_many = AsyncMock(return_value=[])
|
||||
await prisma_client.get_latest_health_checks_for_models(["gpt-5", "claude-opus"])
|
||||
kwargs = prisma_client.db.litellm_healthchecktable.find_many.await_args.kwargs
|
||||
actual = {
|
||||
"where": kwargs["where"],
|
||||
"distinct": kwargs["distinct"],
|
||||
"order": kwargs["order"],
|
||||
}
|
||||
assert actual == {
|
||||
"where": {"model_name": {"in": ["gpt-5", "claude-opus"]}},
|
||||
"distinct": ["model_id", "model_name"],
|
||||
"order": [{"model_id": "asc"}, {"model_name": "asc"}, {"checked_at": "desc"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_health_checks_for_models_does_not_query_for_an_empty_page(
|
||||
prisma_client: PrismaClient,
|
||||
) -> None:
|
||||
prisma_client.db.litellm_healthchecktable.find_many = AsyncMock(return_value=[])
|
||||
assert await prisma_client.get_latest_health_checks_for_models([]) == ()
|
||||
assert prisma_client.db.litellm_healthchecktable.find_many.await_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_health_checks_for_models_db_error_returns_empty_list(
|
||||
prisma_client: PrismaClient,
|
||||
) -> None:
|
||||
prisma_client.db.litellm_healthchecktable.find_many = AsyncMock(side_effect=RuntimeError("oops"))
|
||||
assert await prisma_client.get_latest_health_checks_for_models(["gpt-5"]) == ()
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ A test may reach for a component library's own CSS class only when that library
|
|||
Rules beyond the enabled set were measured against the whole suite and left off rather than recorded in a budget file, because a ceiling that permits a violation anywhere is worse than an honest gap. `no-node-access` and `no-container` are the ones worth revisiting first, since they catch the DOM archaeology the rules above only discourage. `prefer-implicit-assert` and `prefer-explicit-assert` contradict each other, so neither is enabled
|
||||
|
||||
Never run the full unit suite (`npx vitest run` with no path). It is 380 files and thousands of tests, it saturates the machine for many minutes, and CI runs it anyway. Run only the test files your change touches, plus any file whose failure your change could plausibly explain, by passing explicit paths
|
||||
|
||||
Type tests are `*.test-d.ts` files run by the `types` vitest project (`npm run test:types`). Keep them out of the `src/app/(dashboard)/` route group. Vitest matches a tsc error back to the test file by path, the parentheses break that match, and `ignoreSourceErrors: true` then drops the error as if it came from a source file. The test still collects and still reports as passing, so a `.test-d.ts` under a parenthesized directory is green no matter what it asserts. Confirm any new one has teeth by breaking the type it guards and watching it fail
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ vi.mock("@/components/shared/charts", () => ({
|
|||
DonutChart: () => <div />,
|
||||
BarChart: () => <div />,
|
||||
CustomLegend: () => <div />,
|
||||
chartColorValue: (color: string) => color,
|
||||
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
|
||||
SEQUENTIAL_COLOR_RAMP: ["indigo"],
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useMod
|
|||
|
||||
vi.mock("@/components/shared/charts", () => ({
|
||||
DonutChart: ({ label }: { label: string }) => <div data-testid="donut">{label}</div>,
|
||||
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
|
||||
SEQUENTIAL_COLOR_RAMP: ["indigo", "blue"],
|
||||
chartColorValue: (color: string) => color,
|
||||
}));
|
||||
|
|
@ -111,6 +112,19 @@ describe("TierTurnsChart", () => {
|
|||
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists a custom tier's models, which the built-in name guard used to hide", () => {
|
||||
render(
|
||||
<TierTurnsChart
|
||||
view={groupView({ tier_turns: { CASUAL: 3, SECURITY_REVIEW: 1 } })}
|
||||
autoRouters={[deployment({ tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["o1-preview"] } })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/SECURITY_REVIEW/)).toBeInTheDocument();
|
||||
expect(screen.getByText("o1-preview")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the model line for a tier with no configured models", () => {
|
||||
render(<TierTurnsChart view={groupView()} autoRouters={[deployment({ tiers: { SIMPLE: [] } })]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type ComplexityTiers,
|
||||
} from "@/components/add_model/ComplexityRouterConfig";
|
||||
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
|
||||
import { chartColorValue, DonutChart, type ChartColor } from "@/components/shared/charts";
|
||||
import { chartColorValue, DEFAULT_COLOR_CYCLE, DonutChart } from "@/components/shared/charts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { viewGroup, type BenchmarkView } from "./autoRouterBenchmarks";
|
||||
|
|
@ -71,7 +71,6 @@ const tierModelsFor = (
|
|||
routerType: string,
|
||||
autoRouters: readonly AutoRouterDeployment[],
|
||||
): string[] => {
|
||||
if (!isComplexityTier(tier)) return [];
|
||||
const deployment = deploymentFor(routerName, routerType, autoRouters);
|
||||
if (!deployment) return [];
|
||||
const config = asRecord(deployment.litellm_params?.complexity_router_config);
|
||||
|
|
@ -84,8 +83,6 @@ interface TierTurnsChartProps {
|
|||
autoRouters: readonly AutoRouterDeployment[];
|
||||
}
|
||||
|
||||
const TIER_DONUT_COLORS: readonly ChartColor[] = ["#c7d2fe", "#1e293b", "#d4b483", "#87a878"];
|
||||
|
||||
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) => {
|
||||
const group = viewGroup(view);
|
||||
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
|
||||
|
|
@ -98,7 +95,7 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
|
|||
turns,
|
||||
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
|
||||
}));
|
||||
const colors = slices.map((_, idx) => TIER_DONUT_COLORS[idx % TIER_DONUT_COLORS.length]);
|
||||
const colors = slices.map((_, idx) => DEFAULT_COLOR_CYCLE[idx % DEFAULT_COLOR_CYCLE.length]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -69,9 +69,6 @@ describe("CreateSearchTools submit payload", () => {
|
|||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
api_key: "sk-secret",
|
||||
api_base: undefined,
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
},
|
||||
search_tool_info: { description: "finds things" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -345,7 +345,6 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
>
|
||||
Close
|
||||
</Button>
|
||||
, ]
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -95,9 +95,6 @@ describe("SearchTools edit payload", () => {
|
|||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
api_key: "sk-test-key",
|
||||
api_base: undefined,
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
},
|
||||
search_tool_info: { description: "Test description" },
|
||||
});
|
||||
|
|
@ -136,9 +133,6 @@ describe("SearchTools edit payload", () => {
|
|||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
api_key: "sk-test-key",
|
||||
api_base: undefined,
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
},
|
||||
search_tool_info: undefined,
|
||||
});
|
||||
|
|
@ -181,9 +175,6 @@ describe("SearchTools edit payload", () => {
|
|||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
api_key: null,
|
||||
api_base: undefined,
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
},
|
||||
search_tool_info: undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,15 +14,12 @@ describe("buildSearchToolPayload", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps the full key set in the object even when the optional params are absent", () => {
|
||||
it("builds only the params a form actually collects", () => {
|
||||
expect(buildSearchToolPayload(minimal)).toStrictEqual({
|
||||
search_tool_name: "tool",
|
||||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
api_key: undefined,
|
||||
api_base: undefined,
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
},
|
||||
search_tool_info: undefined,
|
||||
});
|
||||
|
|
@ -32,7 +29,7 @@ describe("buildSearchToolPayload", () => {
|
|||
expect(buildSearchToolPayload({ ...minimal, api_key: "sk-secret" }).litellm_params.api_key).toBe("sk-secret");
|
||||
});
|
||||
|
||||
it("keeps an explicitly emptied api key as an empty string, matching the antd store", () => {
|
||||
it("keeps an explicitly emptied api key as an empty string, so the backend clears it", () => {
|
||||
expect(buildSearchToolPayload({ ...minimal, api_key: "" }).litellm_params.api_key).toBe("");
|
||||
});
|
||||
|
||||
|
|
@ -46,19 +43,11 @@ describe("buildSearchToolPayload", () => {
|
|||
expect(buildSearchToolPayload({ ...minimal, description: "" }).search_tool_info).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses timeout as a float", () => {
|
||||
expect(buildSearchToolPayload({ ...minimal, timeout: "2.5" }).litellm_params.timeout).toBe(2.5);
|
||||
});
|
||||
|
||||
it("parses max_retries as an integer and truncates a decimal", () => {
|
||||
expect(buildSearchToolPayload({ ...minimal, max_retries: "3.9" }).litellm_params.max_retries).toBe(3);
|
||||
});
|
||||
|
||||
it('parses a "0" timeout as 0, because the original guard tests the string not the number', () => {
|
||||
expect(buildSearchToolPayload({ ...minimal, timeout: "0" }).litellm_params.timeout).toBe(0);
|
||||
});
|
||||
|
||||
it("treats an empty timeout string as absent", () => {
|
||||
expect(buildSearchToolPayload({ ...minimal, timeout: "" }).litellm_params.timeout).toBeUndefined();
|
||||
it("sends the same wire body with an api key and a description as it did before the params were pruned", () => {
|
||||
expect(
|
||||
JSON.stringify(buildSearchToolPayload({ ...minimal, api_key: "sk-secret", description: "finds things" })),
|
||||
).toBe(
|
||||
'{"search_tool_name":"tool","litellm_params":{"search_provider":"perplexity","api_key":"sk-secret"},"search_tool_info":{"description":"finds things"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,16 @@
|
|||
import type { SearchToolInfo, SearchToolLiteLLMParams } from "./types";
|
||||
|
||||
export interface SearchToolFormValues {
|
||||
search_tool_name: string;
|
||||
search_provider: string;
|
||||
api_key?: string | null;
|
||||
api_base?: string;
|
||||
timeout?: string;
|
||||
max_retries?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchToolPayload {
|
||||
search_tool_name: string;
|
||||
litellm_params: {
|
||||
search_provider: string;
|
||||
api_key: string | null | undefined;
|
||||
api_base: string | undefined;
|
||||
timeout: number | undefined;
|
||||
max_retries: number | undefined;
|
||||
};
|
||||
search_tool_info: { description: string } | undefined;
|
||||
litellm_params: SearchToolLiteLLMParams;
|
||||
search_tool_info: SearchToolInfo | undefined;
|
||||
}
|
||||
|
||||
export const buildSearchToolPayload = (values: SearchToolFormValues): SearchToolPayload => ({
|
||||
|
|
@ -25,9 +18,6 @@ export const buildSearchToolPayload = (values: SearchToolFormValues): SearchTool
|
|||
litellm_params: {
|
||||
search_provider: values.search_provider,
|
||||
api_key: values.api_key,
|
||||
api_base: values.api_base,
|
||||
timeout: values.timeout ? parseFloat(values.timeout) : undefined,
|
||||
max_retries: values.max_retries ? parseInt(values.max_retries, 10) : undefined,
|
||||
},
|
||||
search_tool_info: values.description ? { description: values.description } : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
export interface SearchToolLiteLLMParams {
|
||||
search_provider: string;
|
||||
api_key?: string | null;
|
||||
api_base?: string;
|
||||
timeout?: number;
|
||||
max_retries?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type SearchToolLiteLLMParams = components["schemas"]["SearchToolLiteLLMParams"];
|
||||
|
||||
export interface SearchToolInfo {
|
||||
description?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SearchTool {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import ClassifierPromptEditor from "./ClassifierPromptEditor";
|
||||
import { Restricted, RestrictedSection, restrictedBy } from "./TierRestrictions";
|
||||
import HeuristicScoringConfig from "./HeuristicScoringConfig";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
usesLlmClassifier,
|
||||
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
|
||||
HEURISTIC_FIRST_MAX_TIER_KEYS,
|
||||
effectiveClassifierType,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
const DEFAULT_SCORING_EXPLANATION =
|
||||
|
|
@ -89,18 +91,21 @@ const boundaryRanges = (
|
|||
const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => {
|
||||
// The shipped boundaries come from the proxy, so this card cannot state ranges the router stopped using.
|
||||
const { data: scorerDefaults, isError } = useComplexityScorerDefaults();
|
||||
const scorerRuns = heuristicScoringRole(value) !== "never";
|
||||
const ranges = boundaryRanges(
|
||||
scorerDefaults?.tier_boundaries,
|
||||
value.tier_boundaries,
|
||||
value.reasoning_override_min_score,
|
||||
);
|
||||
|
||||
if (value.custom_tier_set) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-muted mt-4">
|
||||
<CardContent>
|
||||
<strong className="block mb-2 font-semibold">How Classification Works</strong>
|
||||
<span className="text-[13px] text-muted-foreground">{scoringExplanation(value)}</span>
|
||||
{ranges && (
|
||||
{scorerRuns && ranges && (
|
||||
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score < {ranges.simpleMedium}
|
||||
|
|
@ -141,6 +146,54 @@ interface ClassificationMethodConfigProps {
|
|||
defaultModel?: string;
|
||||
}
|
||||
|
||||
const ClassifierTypeRadios: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
classifierType: ClassifierType;
|
||||
onTypeChange: (classifierType: ClassifierType) => void;
|
||||
}> = ({ value, classifierType, onTypeChange }) => {
|
||||
const scorerLocked = Boolean(value.custom_tier_set);
|
||||
const scorerLockedReason = restrictedBy(value, "heuristicClassifier")?.reason;
|
||||
return (
|
||||
<RadioGroup
|
||||
value={classifierType}
|
||||
onValueChange={(classifierType: unknown) => onTypeChange(classifierType as ClassifierType)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<SimpleTooltip content={scorerLockedReason}>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default), rule-based scoring with no API calls and <1ms latency
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="llm" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">LLM Classifier</strong>{" "}
|
||||
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
|
||||
</span>
|
||||
</Label>
|
||||
<SimpleTooltip content={scorerLockedReason}>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic_first" className="mt-0.5" disabled={scorerLocked} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic first</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -151,8 +204,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
defaultModel,
|
||||
}) => {
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const classifierModelMissing =
|
||||
showValidationErrors && usesLlmClassifier(value.classifier_type) && !value.classifier_llm_config?.model;
|
||||
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
|
||||
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
|
||||
|
|
@ -264,41 +318,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={value.classifier_type}
|
||||
onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default), rule-based scoring with no API calls and <1ms latency
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="llm" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">LLM Classifier</strong>{" "}
|
||||
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic_first" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic first</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />
|
||||
|
||||
{value.classifier_type === "heuristic_first" && (
|
||||
{classifierType === "heuristic_first" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="block font-semibold">Decide locally up to</strong>
|
||||
<Select
|
||||
|
|
@ -323,7 +345,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{usesLlmClassifier(value.classifier_type) && (
|
||||
{usesLlmClassifier(classifierType) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Model</strong>
|
||||
|
|
@ -361,7 +383,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</SimpleTooltip>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
|
||||
content={
|
||||
restrictedBy(value, "classificationRubric")?.reason ??
|
||||
(usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
|
|
@ -373,7 +398,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
onValueChange={(preset: ClassificationRubric | null) =>
|
||||
preset && handleClassificationRubricChange(preset)
|
||||
}
|
||||
disabled={usesCustomPrompt}
|
||||
disabled={usesCustomPrompt || Boolean(value.custom_tier_set)}
|
||||
>
|
||||
<SelectTrigger aria-label="Classification Rubric" className="w-full">
|
||||
<SelectValue />
|
||||
|
|
@ -388,23 +413,25 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</Select>
|
||||
</SimpleTooltip>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
|
||||
{restrictedBy(value, "classificationRubric")?.reason ??
|
||||
(usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
<Restricted by={restrictedBy(value, "classifierPrompt")}>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
</Restricted>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
|
||||
<RestrictedSection heading="If the classifier fails" by={restrictedBy(value, "classifierFallback")}>
|
||||
<RadioGroup
|
||||
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
|
||||
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
|
||||
|
|
@ -439,7 +466,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<span className="block text-xs text-muted-foreground">
|
||||
Applies when the classifier call errors, times out, or returns an unparseable response.
|
||||
</span>
|
||||
</div>
|
||||
</RestrictedSection>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Window Size</strong>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1056,3 +1056,216 @@ describe("ComplexityRouterConfig custom technical keywords", () => {
|
|||
expect(screen.queryByText("Custom Technical Keywords")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig tier editing", () => {
|
||||
const renderEditor = (
|
||||
value?: ComplexityRouterConfigValue,
|
||||
props: Partial<React.ComponentProps<typeof ComplexityRouterConfig>> = {},
|
||||
) => {
|
||||
const onChange = vi.fn();
|
||||
const view = renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
{...(value ? { value } : {})}
|
||||
onChange={onChange}
|
||||
editingTiers
|
||||
onEditingTiersChange={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...view, committed: () => onChange.mock.calls[0][0] as ComplexityRouterConfigValue, onChange };
|
||||
};
|
||||
|
||||
const customValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-3.5-turbo"] },
|
||||
{ id: "sec", name: "SECURITY_REVIEW", definition: "audits", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
|
||||
it("offers Edit tiers only when the parent owns the editor flag", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the four built-in tiers before any edit, unchanged", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Tier 1 of 4", { exact: false })).toHaveTextContent("SIMPLE");
|
||||
});
|
||||
|
||||
it("adds a row and moves the form into an edited tier set, which the built-in record never leaves", () => {
|
||||
const { committed } = renderEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add tier" }));
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers).toHaveLength(5);
|
||||
expect(next.tiers).toEqual(defaultValue.tiers);
|
||||
});
|
||||
|
||||
it("renames a built-in tier straight from the editor, which is what makes the set custom", () => {
|
||||
const { committed } = renderEditor();
|
||||
fireEvent.change(screen.getByLabelText("Name for tier 3"), { target: { value: "SECURITY_REVIEW" } });
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers.map((row) => row.name)).toEqual([
|
||||
"SIMPLE",
|
||||
"MEDIUM",
|
||||
"SECURITY_REVIEW",
|
||||
"REASONING",
|
||||
]);
|
||||
expect(next.tiers).toEqual(defaultValue.tiers);
|
||||
});
|
||||
|
||||
it("opening the editor and changing nothing leaves the router on the built-in tiers", () => {
|
||||
const { onChange } = renderEditor();
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps the display-name field for the tier-name field while the editor is open", () => {
|
||||
const { rerender } = renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
|
||||
rerender(<ComplexityRouterConfig {...baseProps} editingTiers onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name for tier 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the prompt editor with the reason an edited tier set forbids it", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByText("A replacement prompt drops the tier bullets", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Change default prompt" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the scorer card entirely once an edited tier set replaces the heuristic", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("scores each request across 7 dimensions", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scorer card on a built-in router, whose tiers the score still decides", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
|
||||
expect(screen.getByText("scores each request across 7 dimensions", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says why a custom row is blocked instead of only reddening its border", () => {
|
||||
const missingDefinition: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [customValue.custom_tier_set!.tiers[0], { id: "b", name: "AUDIT", definition: "", models: ["gpt-4"] }],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
renderEditor(missingDefinition, { showValidationErrors: true });
|
||||
expect(screen.getByText("A definition is required", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Done disabled while a row is incomplete and says what is missing", async () => {
|
||||
const incomplete: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [customValue.custom_tier_set!.tiers[0], { id: "new", name: "", definition: "", models: [] }],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
renderEditor(incomplete);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Done once every row carries a name, a definition and a model", () => {
|
||||
renderEditor(customValue);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("refuses to remove a row that would take the set below the backend's minimum", () => {
|
||||
renderEditor(customValue);
|
||||
expect(screen.getByRole("button", { name: "Remove the CASUAL tier" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps a definition on one line, because the backend rejects a newline in it", () => {
|
||||
const { committed } = renderEditor(customValue);
|
||||
fireEvent.change(screen.getByLabelText("Definition for tier 2"), { target: { value: "audits\nand reviews" } });
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers[1].definition).toBe("audits and reviews");
|
||||
});
|
||||
|
||||
it("moves a keyword rule with the tier it points at when that tier is renamed", () => {
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={customValue}
|
||||
keywordTierRules={[{ id: "r1", keywords: ["audit"], tier: "SECURITY_REVIEW" }]}
|
||||
onKeywordTierRulesChange={onKeywordTierRulesChange}
|
||||
editingTiers
|
||||
onEditingTiersChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Name for tier 2"), { target: { value: "AUDIT" } });
|
||||
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "r1", keywords: ["audit"], tier: "AUDIT" }]);
|
||||
});
|
||||
|
||||
it("re-points the fallback tier when the row it named is removed, never leaving it dangling", () => {
|
||||
const threeRows: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
...customValue.custom_tier_set!.tiers,
|
||||
{ id: "third", name: "MEDIUM", definition: "", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "sec",
|
||||
},
|
||||
};
|
||||
const { committed } = renderEditor(threeRows);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers.some((row) => row.id === next.custom_tier_set?.fallback_tier_id)).toBe(true);
|
||||
});
|
||||
|
||||
it("turns off a plan-mode floor whose row was removed, rather than leaving it pointing at nothing", () => {
|
||||
const withFloor: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
plan_mode_min_tier: "sec",
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
...customValue.custom_tier_set!.tiers,
|
||||
{ id: "third", name: "BULK", definition: "d", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
const { committed } = renderEditor(withFloor);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
|
||||
expect(committed().plan_mode_min_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces the display-name inputs with the reason an edited tier set forbids them", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Display names rename the built-in tiers", { exact: false })).toBeInTheDocument();
|
||||
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");
|
||||
expect(
|
||||
screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves built-in routers with their display-name inputs and no restriction copy", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Display names rename the built-in tiers", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,32 +2,50 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronRight, Info, X } from "lucide-react";
|
||||
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type CustomTierSet,
|
||||
type TierRow,
|
||||
MAX_TIER_COUNT,
|
||||
MAX_TIER_DEFINITION_CHARS,
|
||||
MAX_TIER_NAME_CHARS,
|
||||
MIN_TIER_COUNT,
|
||||
TIER_ORDER,
|
||||
activeTierName,
|
||||
activeTierRows,
|
||||
getCustomTierRowsError,
|
||||
isBuiltInTierName,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "./tier_rows";
|
||||
import React from "react";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import { Restricted, restrictedBy } from "./TierRestrictions";
|
||||
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
|
||||
import {
|
||||
REASONING_EFFORT_OPTIONS,
|
||||
ReasoningEffort,
|
||||
TierModelParamsByTier,
|
||||
pruneTierModelParams,
|
||||
setTierModelReasoningEffort,
|
||||
tierRowLabel,
|
||||
} from "./complexity_router_tiers";
|
||||
import TierModelEffortRows from "./TierModelEffortRows";
|
||||
import EscalationKeywords from "./EscalationKeywords";
|
||||
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
|
||||
import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
||||
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
|
||||
import { type CustomTierSet, type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
export type { CustomTierSet, TierRow } from "./tier_rows";
|
||||
|
||||
export type { DimensionWeights, TierBoundaries, TokenThresholds };
|
||||
export type { CustomTierSet, TierRow } from "./tier_rows";
|
||||
|
||||
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
|
||||
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
|
||||
|
|
@ -141,12 +159,210 @@ export const effectiveClassifierType = (
|
|||
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
|
||||
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
|
||||
|
||||
const rowOrigin = (row: TierRow, editing: boolean): string => {
|
||||
if (!editing) return row.id;
|
||||
return isBuiltInTierName(row.name) ? "built-in" : "custom";
|
||||
};
|
||||
|
||||
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
|
||||
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
|
||||
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
|
||||
};
|
||||
|
||||
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
|
||||
const builtIn = TIER_ORDER.find((tier) => tier === rowId);
|
||||
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
|
||||
};
|
||||
|
||||
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
|
||||
<>
|
||||
<span className="block mb-6 text-muted-foreground">
|
||||
{heuristicScoringRole(value) === "never"
|
||||
? "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier."
|
||||
: "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier."}
|
||||
</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
{restrictedBy(value, "displayNames")?.reason ??
|
||||
"Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
|
||||
{!value.custom_tier_set &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const TierSetToolbar: React.FC<{
|
||||
editing: boolean;
|
||||
isCustomSet: boolean;
|
||||
rowCount: number;
|
||||
rowsError: string | null;
|
||||
onEditingChange: ((editing: boolean) => void) | undefined;
|
||||
onAdd: () => void;
|
||||
onRestore: () => void;
|
||||
}> = ({ editing, isCustomSet, rowCount, rowsError, onEditingChange, onAdd, onRestore }) => (
|
||||
<>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onAdd} disabled={rowCount >= MAX_TIER_COUNT}>
|
||||
<Plus />
|
||||
Add tier
|
||||
</Button>
|
||||
<SimpleTooltip content={rowsError || undefined}>
|
||||
<Button variant="outline" disabled={Boolean(rowsError)} onClick={() => onEditingChange?.(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{isCustomSet && (
|
||||
<Button variant="outline" size="sm" onClick={onRestore}>
|
||||
Restore defaults
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onEditingChange && (
|
||||
<Button variant="outline" onClick={() => onEditingChange(true)}>
|
||||
Edit tiers
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes on,
|
||||
and an edited set requires the LLM classification method
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const FallbackTierField: React.FC<{
|
||||
rows: readonly TierRow[];
|
||||
fallbackTierId: string;
|
||||
onValueChange: (rowId: string) => void;
|
||||
}> = ({ rows, fallbackTierId, onValueChange }) => (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Fallback Tier</strong>
|
||||
<SimpleTooltip content="Where requests route when the LLM classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<TierRowSelect
|
||||
label="Fallback tier"
|
||||
options={rows.filter((row) => activeTierName(row)).map((row) => ({ value: row.id, label: activeTierName(row) }))}
|
||||
value={fallbackTierId || null}
|
||||
onValueChange={onValueChange}
|
||||
placeholder="Pick the tier classifier failures route to"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TierRowHeader: React.FC<{
|
||||
row: TierRow;
|
||||
index: number;
|
||||
rowCount: number;
|
||||
label: string;
|
||||
description: string | undefined;
|
||||
editing: boolean;
|
||||
isCustomSet: boolean;
|
||||
onRemove: () => void;
|
||||
}> = ({ row, index, rowCount, label, description, editing, isCustomSet, onRemove }) => (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
row.definition.trim() ||
|
||||
description ||
|
||||
"A tier you defined. The classifier routes requests matching its definition here."
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {rowCount} · {rowOrigin(row, isCustomSet)}
|
||||
</span>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${activeTierName(row) || `tier ${index + 1}`} tier`}
|
||||
disabled={rowCount <= MIN_TIER_COUNT}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TierRowEditFields: React.FC<{
|
||||
row: TierRow;
|
||||
index: number;
|
||||
definitionMissing: boolean;
|
||||
onPatch: (patch: Partial<Omit<TierRow, "id">>) => void;
|
||||
}> = ({ row, index, definitionMissing, onPatch }) => (
|
||||
<>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => onPatch({ name: event.target.value })}
|
||||
placeholder="Tier name, e.g. SECURITY_REVIEW"
|
||||
aria-label={`Name for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_NAME_CHARS}
|
||||
className="mb-2"
|
||||
/>
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) => onPatch({ definition: event.target.value.replace(/[\r\n]+/g, " ") })}
|
||||
placeholder={
|
||||
isBuiltInTierName(row.name)
|
||||
? "Leave blank to keep the built-in definition"
|
||||
: "What belongs in this tier, e.g. requests asking for a security audit"
|
||||
}
|
||||
aria-label={`Definition for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_DEFINITION_CHARS}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="mb-2 block text-xs text-destructive">
|
||||
A definition is required: it is the rubric the classifier routes on for this tier
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const TierRowSelect: React.FC<{
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string | null;
|
||||
onValueChange: (rowId: string) => void;
|
||||
placeholder?: string;
|
||||
}> = ({ label, options, value, onValueChange, placeholder }) => (
|
||||
<Select items={options} value={value} onValueChange={(rowId: string | null) => rowId && onValueChange(rowId)}>
|
||||
<SelectTrigger aria-label={label} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
export type AdaptiveEligible = "all" | "classified_tier";
|
||||
|
||||
export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>>;
|
||||
|
||||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
|
|
@ -161,7 +377,7 @@ export interface ComplexityRouterConfigValue {
|
|||
heuristic_first_max_tier?: string;
|
||||
session_affinity?: boolean;
|
||||
deployment_affinity?: boolean;
|
||||
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
|
||||
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
|
||||
plan_mode_min_tier?: string;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
|
|
@ -185,7 +401,6 @@ export interface ComplexityRouterConfigValue {
|
|||
* params object is held, not just reasoning_effort, so keys authored in config.yaml survive an
|
||||
* edit round-trip.
|
||||
*/
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_model_params?: TierModelParamsByTier;
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +408,9 @@ interface ComplexityRouterConfigProps {
|
|||
modelInfo: ModelGroup[];
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
/** Parent-owned: this component unmounts when its section collapses. */
|
||||
editingTiers?: boolean;
|
||||
onEditingTiersChange?: (editing: boolean) => void;
|
||||
customTechnicalKeywords?: string[];
|
||||
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
|
||||
// Optional: the edit-auto-router modal doesn't yet support editing keyword tier
|
||||
|
|
@ -268,14 +486,16 @@ const AffinityControls: React.FC<{
|
|||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
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">
|
||||
Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment.
|
||||
{restrictedBy(value, "sessionAffinity")?.reason ??
|
||||
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
|
@ -307,22 +527,12 @@ const PlanModeOverrideControls: React.FC<{
|
|||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Select
|
||||
items={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier}
|
||||
onValueChange={(tier: string | null) => tier && onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
>
|
||||
<SelectTrigger aria-label="Plan-mode minimum tier" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{planModeTierOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TierRowSelect
|
||||
label="Plan-mode minimum tier"
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -351,6 +561,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
modelInfo,
|
||||
value,
|
||||
onChange,
|
||||
editingTiers = false,
|
||||
onEditingTiersChange,
|
||||
customTechnicalKeywords,
|
||||
onCustomTechnicalKeywordsChange,
|
||||
keywordTierRules = [],
|
||||
|
|
@ -365,13 +577,31 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onEscalationKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const tierRows = activeTierRows(value);
|
||||
const planModeTierOptions = tierRows
|
||||
.filter((row) => row.models.length > 0)
|
||||
.map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) }));
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
|
||||
const planModeRows = tierRows.filter((row) => row.models.length > 0);
|
||||
const planModeTierOptions = planModeRows.map((row) => ({
|
||||
value: row.id,
|
||||
label: tierRowLabel(row, value.tier_labels),
|
||||
}));
|
||||
const derivedDefaultModel = resolveComplexityDefaultModel(value);
|
||||
const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet));
|
||||
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
|
||||
|
||||
const dispatch = (action: TierSetAction) => {
|
||||
const next = applyTierSetAction(value, keywordTierRules, action);
|
||||
if (next.keywordTierRules !== keywordTierRules) onKeywordTierRulesChange?.([...next.keywordTierRules]);
|
||||
onChange(next.value);
|
||||
};
|
||||
|
||||
const setRowModels = (row: TierRow, models: string[]) => dispatch({ kind: "models", id: row.id, models });
|
||||
const updateTierRow = (id: string, patch: Partial<Omit<TierRow, "id">>) => dispatch({ kind: "patch", id, patch });
|
||||
const addCustomTier = () => dispatch({ kind: "add" });
|
||||
const removeTierRow = (id: string) => dispatch({ kind: "remove", id });
|
||||
const exitToBuiltInTiers = () => dispatch({ kind: "restore" });
|
||||
|
||||
// An absent list means the proxy does not send the field yet, so every level is offered as before.
|
||||
// An empty list is the group's own answer that its deployments share no level, and is left empty.
|
||||
const effortOptionsByModel: Record<string, string[]> = Object.fromEntries(
|
||||
|
|
@ -389,19 +619,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
label: model.model_group,
|
||||
}));
|
||||
|
||||
const handleTierChange = (tier: keyof ComplexityTiers, models: string[]) => {
|
||||
onChange({
|
||||
...value,
|
||||
tiers: { ...value.tiers, [tier]: models },
|
||||
tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTierModelEffortChange = (
|
||||
tier: keyof ComplexityTiers,
|
||||
model: string,
|
||||
effort: ReasoningEffort | undefined,
|
||||
) => {
|
||||
const handleTierModelEffortChange = (tier: string, model: string, effort: ReasoningEffort | undefined) => {
|
||||
onChange({
|
||||
...value,
|
||||
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
|
||||
|
|
@ -430,62 +648,67 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
<span className="block mb-6 text-muted-foreground">
|
||||
The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls,
|
||||
<1ms latency). Configure which model(s) handle each tier.
|
||||
</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how
|
||||
requests are classified, and callers never see these names.
|
||||
{usesLlmClassifier(value.classifier_type) &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
<TierConfigIntro value={value} />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{tierRows.map((row: TierRow, index) => {
|
||||
const tier = row.id as keyof ComplexityTiers;
|
||||
const tierInfo = TIER_DESCRIPTIONS[tier];
|
||||
const label = effectiveTierLabel(tier, value.tier_labels);
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && row.models.length === 0;
|
||||
const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
const definitionMissing = showValidationErrors && needsDefinition;
|
||||
const showsDisplayName = !customTierSet && !editingTiers;
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip content={tierInfo.description}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {tierRows.length} · {row.id}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[tier] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
<TierRowHeader
|
||||
row={row}
|
||||
index={index}
|
||||
rowCount={tierRows.length}
|
||||
label={label}
|
||||
description={tierInfo?.description}
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
onRemove={() => removeTierRow(row.id)}
|
||||
/>
|
||||
{tierInfo && !customTierSet && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
)}
|
||||
{editingTiers && (
|
||||
<TierRowEditFields
|
||||
row={row}
|
||||
index={index}
|
||||
definitionMissing={definitionMissing}
|
||||
onPatch={(patch) => updateTierRow(row.id, patch)}
|
||||
/>
|
||||
{value.tier_labels?.[tier] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(tier, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
{showsDisplayName && tierInfo && (
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => handleTierChange(tier, models)}
|
||||
onValueChange={(models: string[]) => setRowModels(row, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
|
|
@ -494,12 +717,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
tierLabel={label}
|
||||
models={row.models}
|
||||
effortOptionsByModel={effortOptionsByModel}
|
||||
paramsByModel={value.tier_model_params?.[tier]}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
|
||||
paramsByModel={row.params}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(row.id, model, effort)}
|
||||
/>
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
|
||||
Multiple models selected: the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -508,6 +731,25 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<TierSetToolbar
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
rowCount={tierRows.length}
|
||||
rowsError={tierRowsError}
|
||||
onEditingChange={onEditingTiersChange}
|
||||
onAdd={addCustomTier}
|
||||
onRestore={exitToBuiltInTiers}
|
||||
/>
|
||||
|
||||
{customTierSet && (
|
||||
<FallbackTierField
|
||||
rows={tierRows}
|
||||
fallbackTierId={customTierSet.fallback_tier_id}
|
||||
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="mb-2">
|
||||
|
|
@ -521,11 +763,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
options={modelOptions}
|
||||
value={value.default_model ?? ""}
|
||||
onValueChange={handleDefaultModelChange}
|
||||
placeholder={
|
||||
derivedDefaultModel
|
||||
? `Derived from tiers: ${derivedDefaultModel}`
|
||||
: "Add a model to the Simple or Medium tier"
|
||||
}
|
||||
placeholder={defaultModelPlaceholder}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
/>
|
||||
|
|
@ -559,7 +797,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
|
||||
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "adaptive")}>
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "affinity",
|
||||
|
|
@ -583,7 +825,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "escalation")}>
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -599,6 +845,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierNames={customTierSet && tierRows.map(activeTierName).filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ interface KeywordTierRulesProps {
|
|||
rules: KeywordTierRule[];
|
||||
onChange: (rules: KeywordTierRule[]) => void;
|
||||
tierLabels?: Partial<Record<ComplexityTier, string>>;
|
||||
tierNames?: string[];
|
||||
}
|
||||
|
||||
// A row exists only because the caller asked for it, so it reports its own gap straight away
|
||||
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
|
||||
// there is no failed attempt left to surface it.
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels }) => {
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels, tierNames }) => {
|
||||
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
|
||||
|
||||
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
|
||||
|
|
@ -35,7 +36,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: tierNames?.[0] ?? "COMPLEX" }]);
|
||||
};
|
||||
|
||||
const updateRule = (id: string, updates: Partial<Omit<KeywordTierRule, "id">>) => {
|
||||
|
|
@ -98,7 +99,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<div style={{ width: 220 }}>
|
||||
<strong className="mb-2 block font-semibold">Route to tier</strong>
|
||||
<Select
|
||||
items={tierOptions(tierLabels)}
|
||||
items={tierOptions(tierLabels, tierNames)}
|
||||
value={rule.tier}
|
||||
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
|
||||
>
|
||||
|
|
@ -106,7 +107,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tierOptions(tierLabels).map((option) => (
|
||||
{tierOptions(tierLabels, tierNames).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
|
||||
|
||||
export const restrictedBy = (
|
||||
value: { custom_tier_set?: CustomTierSet },
|
||||
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
|
||||
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
|
||||
|
||||
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
|
||||
by,
|
||||
children,
|
||||
}) => (by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : <>{children}</>);
|
||||
|
||||
/** A labelled section whose body is replaced by the reason an edited tier set forbids it. */
|
||||
export const RestrictedSection: React.FC<{
|
||||
heading: string;
|
||||
by: TierRestriction | undefined;
|
||||
children: React.ReactNode;
|
||||
}> = ({ heading, by, children }) => (
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">{heading}</strong>
|
||||
{by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -210,21 +210,6 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits when the dry-run passes, so the gate is not simply blocking everything", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
validateAutoRouterConfig.mockResolvedValueOnce({ valid: true });
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "accepted-router");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// A second submit while the dry-run round-trip is pending must not start another create: the
|
||||
// button disables, and the handler itself refuses re-entry since a form submit (Enter) fires it
|
||||
// regardless of the button's disabled state.
|
||||
it("creates the router once when the form is submitted again mid dry-run", async () => {
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
let resolveVerdict: (verdict: { valid: boolean }) => void = () => {};
|
||||
|
|
@ -248,6 +233,18 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("submits when the dry-run passes, so the gate is not simply blocking everything", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
validateAutoRouterConfig.mockResolvedValueOnce({ valid: true });
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "accepted-router");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// LIT-5133: "Add keyword rule" seeds a row with no keywords, and the semantic toggle that used
|
||||
// to be the only thing checking them is off by default. The row was dropped on the way to the
|
||||
// payload, so the create succeeded and the caller's rule was gone with nothing said about it.
|
||||
|
|
@ -651,28 +648,6 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Every step between the bundled JSON and the payload drops these params silently.
|
||||
it("carries a preset's per-tier reasoning effort through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForPresetEnabled("Anthropic Family");
|
||||
await selectTemplate("Anthropic Family");
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
|
||||
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]).toMatchObject({
|
||||
complexity_router_config: {
|
||||
tier_model_configs: {
|
||||
REASONING: [{ model_name: "claude-opus-5", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Bugbot-found bug: submitBlockedReason disables the button for this, but Form's onFinish
|
||||
// (wired to the same handler as the button) fires whenever the form itself is submitted,
|
||||
// independent of the button's own disabled state. Without submitRecommendedRouter re-checking
|
||||
|
|
@ -700,6 +675,27 @@ describe("AddAutoRouterTab", () => {
|
|||
await waitFor(() => expect(toast.fromError).toHaveBeenCalledWith(expect.stringContaining("no longer available")));
|
||||
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries a preset's per-tier reasoning effort through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForPresetEnabled("Anthropic Family");
|
||||
await selectTemplate("Anthropic Family");
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
|
||||
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]).toMatchObject({
|
||||
complexity_router_config: {
|
||||
tier_model_configs: {
|
||||
REASONING: [{ model_name: "claude-opus-5", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("default model pin", () => {
|
||||
|
|
@ -975,6 +971,23 @@ describe("getSubmitBlockedReason", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("blocks an edited tier set with no classifier model, since the set forces the LLM classifier", () => {
|
||||
const config = {
|
||||
tiers,
|
||||
classifier_type: "heuristic" as const,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "a", name: "CASUAL", definition: "d", models: ["gpt-4o-mini"] },
|
||||
{ id: "b", name: "AUDIT", definition: "d", models: ["gpt-4o-mini"] },
|
||||
],
|
||||
fallback_tier_id: "a",
|
||||
},
|
||||
};
|
||||
expect(getSubmitBlockedReason(config, [], referenced, availability)).toContain(
|
||||
"an edited tier set routes with the LLM classifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a keyword rule aimed at a tier this router does not have", () => {
|
||||
const rules = [{ id: "r1", keywords: ["audit"], tier: "AUDIT" }];
|
||||
expect(getSubmitBlockedReason({ tiers, classifier_type: "heuristic" }, rules, referenced, availability)).toContain(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -14,6 +14,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
import { modelAvailableCall, validateAutoRouterConfig } from "../networking";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
|
|
@ -22,6 +23,7 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
|||
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
effectiveClassifierType,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
|
|
@ -33,17 +35,16 @@ import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
|
|||
import {
|
||||
BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
dryRunRejection,
|
||||
getKeywordTierRulesError,
|
||||
getClassifierModelError,
|
||||
getMissingTiersError,
|
||||
getPlanModeTierError,
|
||||
getSemanticConfigError,
|
||||
getTierLabelsError,
|
||||
dryRunRejection,
|
||||
} from "./build_complexity_router_config";
|
||||
import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
import { tierRowLabel } from "./complexity_router_tiers";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
|
|
@ -110,7 +111,7 @@ const presets = getAllPresets();
|
|||
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
|
||||
const parts = activeTierRows(config)
|
||||
.filter((row) => row.models.length > 0)
|
||||
.map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`);
|
||||
.map((row) => `${tierRowLabel(row, config.tier_labels)}: ${row.models.join(", ")}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
|
||||
};
|
||||
|
||||
|
|
@ -124,8 +125,8 @@ export const getSubmitBlockedReason = (
|
|||
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
|
||||
availability: ModelAvailability,
|
||||
): string | null =>
|
||||
(config.custom_tier_set ? getCustomTierRowsError(config.custom_tier_set) : getTierLabelsError(config.tier_labels)) ??
|
||||
getMissingTiersError(activeTierRows(config)) ??
|
||||
getTierLabelsError(config.tier_labels) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
|
||||
getClassifierModelError(config) ??
|
||||
|
|
@ -146,16 +147,6 @@ const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
|
|||
model_access_group: undefined,
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const teamScopePayload = (requiresTeamScope: boolean, teamId: string): { team_id?: string } =>
|
||||
requiresTeamScope ? { team_id: teamId } : {};
|
||||
|
||||
|
|
@ -197,7 +188,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
|
||||
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [editingTiers, setEditingTiers] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined);
|
||||
// Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
|
||||
|
|
@ -297,6 +289,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
);
|
||||
|
||||
const applyPrefill = (prefill: PresetPrefill) => {
|
||||
setEditingTiers(false);
|
||||
setComplexityRouterConfig(prefill.complexityRouterConfig);
|
||||
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
|
||||
setKeywordTierRules(prefill.keywordTierRules);
|
||||
|
|
@ -327,8 +320,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const referencedModelsParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
tiers: Object.fromEntries(activeTierRows(complexityRouterConfig).map((row) => [activeTierName(row), row.models])),
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
|
|
@ -344,6 +337,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
customTierSet: complexityRouterConfig.custom_tier_set,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
|
||||
|
|
@ -375,8 +369,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
const { tiers } = complexityRouterConfigParams;
|
||||
|
||||
// The one answer the submit button reads, so a disabled button and a refused submit cannot
|
||||
// disagree about why. The handler needs it in its own right: the form fires this on Enter
|
||||
// regardless of the button's disabled state.
|
||||
|
|
@ -403,6 +395,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const complexityRouterConfigPayload = buildComplexityRouterConfig(complexityRouterConfigParams);
|
||||
const serverVerdict = await validateAutoRouterConfig(
|
||||
accessToken,
|
||||
|
|
@ -416,10 +412,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const submitValues: AddAutoRouterValues = {
|
||||
auto_router_name: name,
|
||||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
|
|
@ -579,6 +571,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
|
|
@ -642,7 +636,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Test Routing
|
||||
|
|
@ -666,7 +660,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ import {
|
|||
type KeywordMatchingState,
|
||||
} from "./edit_auto_router_modal";
|
||||
|
||||
// The custom-tier router these cases round-trip, so a variant differs only by what it overrides.
|
||||
const storedCustomConfig = (overrides: Record<string, unknown> = {}) => ({
|
||||
tiers: { CASUAL: ["gpt-4o-mini"], AUDIT: ["o1"] },
|
||||
tier_definitions: [
|
||||
{ name: "CASUAL", description: "small talk" },
|
||||
{ name: "AUDIT", description: "security review" },
|
||||
],
|
||||
fallback_tier: "CASUAL",
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const STORED = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
|
|
@ -476,14 +489,49 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
reasoning_override_min_score: 0.3,
|
||||
};
|
||||
|
||||
it("carries every managed key through hydrate then save", () => {
|
||||
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses, so
|
||||
// no single stored config can hold every managed key. They get their own round trip below.
|
||||
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier"]);
|
||||
|
||||
it("carries every managed key a built-in router can hold through hydrate then save", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated);
|
||||
|
||||
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS].filter((key) => saved[key] === undefined);
|
||||
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
|
||||
.filter((key) => !CUSTOM_TIER_ONLY_KEYS.has(key))
|
||||
.filter((key) => saved[key] === undefined);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a stored local-scorer threshold when the operator converts the router to custom tiers", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const converted = {
|
||||
...hydrated,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
|
||||
{ id: "b", name: "AUDIT", definition: "security review", models: ["o1"] },
|
||||
],
|
||||
fallback_tier_id: "a",
|
||||
},
|
||||
};
|
||||
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, converted);
|
||||
|
||||
expect(saved.heuristic_first_max_tier).toBeUndefined();
|
||||
expect(saved.classifier_type).toBe("llm");
|
||||
expect(saved.tier_definitions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("carries the custom-tier keys through their own round trip", () => {
|
||||
const storedCustom = storedCustomConfig();
|
||||
const hydrated = hydrateComplexityRouterConfig(storedCustom, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(storedCustom, hydrated);
|
||||
|
||||
expect(saved.tier_definitions).toEqual(storedCustom.tier_definitions);
|
||||
expect(saved.fallback_tier).toBe("CASUAL");
|
||||
expect(saved.tiers).toEqual(storedCustom.tiers);
|
||||
});
|
||||
|
||||
it("round-trips the heuristic_first threshold, which save requires and the backend rejects without", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
expect(hydrated.heuristic_first_max_tier).toBe("SIMPLE");
|
||||
|
|
|
|||
|
|
@ -10,15 +10,13 @@ vi.mock(
|
|||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
);
|
||||
|
||||
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall } = vi.hoisted(() => ({
|
||||
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
|
||||
}));
|
||||
|
||||
const { validateAutoRouterConfig } = vi.hoisted(() => ({
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
}));
|
||||
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall, validateAutoRouterConfig } =
|
||||
vi.hoisted(() => ({
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
modelPatchUpdateCall,
|
||||
|
|
@ -101,6 +99,8 @@ describe("EditAutoRouterModal keyword matching", () => {
|
|||
expect(config.match_threshold).toBe(0.72);
|
||||
});
|
||||
|
||||
// Same gate as the create form: the dry-run's verdict has to stop the PATCH, or an operator sees
|
||||
// a raw 400 instead of the inline message the dry-run was added to give them.
|
||||
it("does not PATCH when the backend's dry-run rejects the config", async () => {
|
||||
const user = userEvent.setup();
|
||||
validateAutoRouterConfig.mockResolvedValueOnce({
|
||||
|
|
@ -819,3 +819,74 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
|
|||
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal with a stored custom tier set", () => {
|
||||
const CUSTOM_STORED = {
|
||||
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["gpt-4o-mini"] },
|
||||
tier_definitions: [
|
||||
{ name: "CASUAL", description: "small talk" },
|
||||
{ name: "SECURITY_REVIEW", description: "audits and vulnerability review" },
|
||||
],
|
||||
fallback_tier: "CASUAL",
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
plan_mode_min_tier: "SECURITY_REVIEW",
|
||||
classification_prompt: "operator written preamble",
|
||||
tier_model_configs: {
|
||||
SECURITY_REVIEW: [{ model_name: "gpt-4o-mini", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const renderCustomModal = () =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config: CUSTOM_STORED },
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
||||
it("shows the stored tier names rather than the built-in four", async () => {
|
||||
renderCustomModal();
|
||||
expect(await screen.findByText("SECURITY_REVIEW Tier")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Simple Tier")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an untouched custom-tier router back byte-identically, tier set and floor included", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCustomModal();
|
||||
|
||||
await screen.findByText("SECURITY_REVIEW Tier");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
const config = savedConfig();
|
||||
expect(config.tier_definitions).toEqual(CUSTOM_STORED.tier_definitions);
|
||||
expect(config.tiers).toEqual(CUSTOM_STORED.tiers);
|
||||
expect(config.fallback_tier).toBe("CASUAL");
|
||||
expect(config.plan_mode_min_tier).toBe("SECURITY_REVIEW");
|
||||
expect(config.classification_prompt).toBe("operator written preamble");
|
||||
expect(config.classifier_type).toBe("llm");
|
||||
});
|
||||
|
||||
it("keeps the stored per-model reasoning effort, which hydrates by tier name and saves by row id", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCustomModal();
|
||||
|
||||
await screen.findByText("SECURITY_REVIEW Tier");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
expect(savedConfig().tier_model_configs).toEqual(CUSTOM_STORED.tier_model_configs);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,16 +15,26 @@ import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } fr
|
|||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
|
||||
import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows";
|
||||
import {
|
||||
type ActiveTierSet,
|
||||
CUSTOM_TIER_OMITTED_KEYS,
|
||||
activeTierRows,
|
||||
getCustomTierRowsError,
|
||||
tierParamsByRowId,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "../add_model/tier_rows";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import {
|
||||
type BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
getClassifierModelError,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getSemanticConfigError,
|
||||
getPlanModeTierError,
|
||||
getTierLabelsError,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
hydrateTierLabels,
|
||||
dryRunRejection,
|
||||
} from "../add_model/build_complexity_router_config";
|
||||
|
|
@ -113,16 +123,18 @@ export const hydrateComplexityRouterConfig = (
|
|||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
};
|
||||
|
||||
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
|
||||
const activeTiers = { tiers: hydratedTiers, custom_tier_set };
|
||||
|
||||
return {
|
||||
tiers: hydratedTiers,
|
||||
tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
|
||||
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, {
|
||||
tiers: hydratedTiers,
|
||||
}),
|
||||
plan_mode_min_tier:
|
||||
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
|
||||
? parsedConfig.plan_mode_min_tier
|
||||
: undefined,
|
||||
custom_tier_set,
|
||||
tier_model_params: tierParamsByRowId(
|
||||
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
|
||||
activeTierRows(activeTiers),
|
||||
),
|
||||
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, activeTiers),
|
||||
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
|
||||
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
|
||||
classifier_type: parsedConfig.classifier_type || "heuristic",
|
||||
classifier_llm_config: parsedConfig.classifier_llm_config,
|
||||
|
|
@ -166,6 +178,8 @@ export const hydrateComplexityRouterConfig = (
|
|||
|
||||
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"tier_definitions",
|
||||
"fallback_tier",
|
||||
"tier_model_configs",
|
||||
"default_model",
|
||||
"plan_mode_min_tier",
|
||||
|
|
@ -233,6 +247,10 @@ export interface KeywordMatchingState {
|
|||
matchThreshold: number;
|
||||
}
|
||||
|
||||
// A custom save drops the stored keys an edited tier set forbids.
|
||||
const customTierDroppedKeys = (value: ComplexityRouterConfigValue): readonly string[] =>
|
||||
value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : [];
|
||||
|
||||
export const buildUpdatedComplexityRouterConfig = (
|
||||
storedConfig: unknown,
|
||||
value: ComplexityRouterConfigValue,
|
||||
|
|
@ -244,10 +262,14 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
|
||||
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
|
||||
};
|
||||
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
|
||||
const dropped = customTierDroppedKeys(value);
|
||||
const preservedConfig = Object.fromEntries(
|
||||
Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key) && !dropped.includes(key)),
|
||||
);
|
||||
|
||||
const builderParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: value.tiers,
|
||||
customTierSet: value.custom_tier_set,
|
||||
defaultModel: value.default_model,
|
||||
planModeMinTier: value.plan_mode_min_tier,
|
||||
heuristicFirstMaxTier: value.heuristic_first_max_tier,
|
||||
|
|
@ -341,6 +363,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [editingTiers, setEditingTiers] = useState(false);
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
|
||||
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
|
||||
|
|
@ -365,10 +388,12 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// is legal today stays legal.
|
||||
const submitBlockedReason = !isComplexityRouterModel
|
||||
? null
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ??
|
||||
getTierLabelsError(complexityRouterConfig.tier_labels) ??
|
||||
: (complexityRouterConfig.custom_tier_set
|
||||
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
|
||||
getMissingTiersError(activeTierRows(complexityRouterConfig))
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
getClassifierModelError(complexityRouterConfig);
|
||||
|
|
@ -407,6 +432,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}, [isVisible, accessToken]);
|
||||
|
||||
const initializeForm = () => {
|
||||
setEditingTiers(false);
|
||||
try {
|
||||
if (isComplexityRouterModel) {
|
||||
// Parse the complexity_router_config if it exists and is a string
|
||||
|
|
@ -473,10 +499,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
const saveValues = async (values: EditAutoRouterFormValues) => {
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
const { tiers, custom_tier_set, classifier_llm_config } = complexityRouterConfig;
|
||||
const rows = activeTierRows(complexityRouterConfig);
|
||||
const builtInTiersEmpty = Object.values(tiers).every((models) => models.length === 0);
|
||||
const tierSetError = custom_tier_set
|
||||
? getCustomTierRowsError(custom_tier_set) ?? getMissingTiersError(rows)
|
||||
: builtInTiersEmpty && "Please select at least one model for a complexity tier";
|
||||
if (tierSetError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
toast.fromError(tierSetError);
|
||||
return;
|
||||
}
|
||||
const classifierError = getClassifierModelError(complexityRouterConfig);
|
||||
|
|
@ -489,7 +520,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig));
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules, rows);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
|
|
@ -517,6 +548,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedConfig = buildUpdatedComplexityRouterConfig(
|
||||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
|
|
@ -531,9 +565,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
complexity_router_config: updatedConfig,
|
||||
|
|
@ -635,6 +666,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
|
|
|
|||
57
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
57
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -12061,6 +12061,36 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/public/v1/model_hub": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Public Model Hub List
|
||||
* @description The public model groups this proxy publishes, paged, sortable, searchable and
|
||||
* filterable, for the public Model Hub page. No authentication.
|
||||
*
|
||||
* A rejected request answers with the parameters, sort fields and filter operators
|
||||
* it would have accepted, so the accepted set stays discoverable from the endpoint
|
||||
* itself rather than from a copy of the spec kept here.
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
* curl --location --globoff 'http://0.0.0.0:4000/public/v1/model_hub?sort=-input_cost_per_token&filter[mode][in]=chat&page_size=25'
|
||||
* ```
|
||||
*/
|
||||
get: operations["public_model_hub_list_public_v1_model_hub_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/queue/chat/completions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -27457,6 +27487,13 @@ export interface components {
|
|||
links: components["schemas"]["ListLinks"];
|
||||
meta: components["schemas"]["ListMeta"];
|
||||
};
|
||||
/** ListResponse[ModelGroupInfoProxy] */
|
||||
ListResponse_ModelGroupInfoProxy_: {
|
||||
/** Data */
|
||||
data: components["schemas"]["ModelGroupInfoProxy"][];
|
||||
links: components["schemas"]["ListLinks"];
|
||||
meta: components["schemas"]["ListMeta"];
|
||||
};
|
||||
/**
|
||||
* ListRunsResponse
|
||||
* @description Response from listing runs
|
||||
|
|
@ -53373,6 +53410,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
public_model_hub_list_public_v1_model_hub_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListResponse_ModelGroupInfoProxy_"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
async_queue_request_queue_chat_completions_post: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
27
ui/litellm-dashboard/src/lib/http/searchToolTypes.test-d.ts
Normal file
27
ui/litellm-dashboard/src/lib/http/searchToolTypes.test-d.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expectTypeOf, test } from "vitest";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import type { SearchToolInfo, SearchToolLiteLLMParams } from "@/app/(dashboard)/search-tools/_components/types";
|
||||
import type { SearchToolPayload } from "@/app/(dashboard)/search-tools/_components/searchToolPayload";
|
||||
|
||||
describe("search tool types", () => {
|
||||
test("litellm params are the generated OpenAPI component", () => {
|
||||
expectTypeOf<SearchToolLiteLLMParams>().toEqualTypeOf<components["schemas"]["SearchToolLiteLLMParams"]>();
|
||||
});
|
||||
|
||||
test("a param the backend has not declared does not type-check", () => {
|
||||
// @ts-expect-error search_engine_id type-checks only once litellm/types/search.py declares it
|
||||
const params: SearchToolLiteLLMParams = { search_provider: "google_pse", search_engine_id: "cx-123" };
|
||||
expectTypeOf(params).toExtend<{ search_provider: string }>();
|
||||
});
|
||||
|
||||
test("tool info carries a description and nothing else", () => {
|
||||
// @ts-expect-error search_tool_info has no owner field
|
||||
const info: SearchToolInfo = { description: "finds things", owner: "platform-team" };
|
||||
expectTypeOf(info).toExtend<{ description?: string | null }>();
|
||||
});
|
||||
|
||||
test("the payload sends litellm params the backend declares", () => {
|
||||
expectTypeOf<SearchToolPayload["litellm_params"]>().toEqualTypeOf<SearchToolLiteLLMParams>();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue