mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(proxy): serve the auto-router preset catalog at runtime (#39412)
The dashboard's template picker imported autorouter_presets.json at build time, so every catalog change needed a dashboard rebuild and artifacts refresh. The catalog now lives in litellm/proxy/public_endpoints/ and GET /public/autorouter_presets serves it, fetching litellm.autorouter_presets_url (GitHub raw on main, 1h in-process cache, bundled fallback) so a merged catalog change propagates to running proxies like the model cost map does. The dashboard fetches it at runtime via useAutoRouterPresets and keeps no local copy. Resolves LIT-6764
This commit is contained in:
parent
ff1f21aea9
commit
993766be0e
13 changed files with 592 additions and 24 deletions
|
|
@ -424,6 +424,10 @@ anthropic_beta_headers_url: str = os.getenv(
|
|||
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json",
|
||||
)
|
||||
autorouter_presets_url: str = os.getenv(
|
||||
"LITELLM_AUTOROUTER_PRESETS_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
|
||||
)
|
||||
suppress_debug_info: bool = False
|
||||
dynamodb_table_name: Optional[str] = None
|
||||
s3_callback_params: Optional[Dict] = None
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Awaitable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from importlib.resources import files
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
|
|
@ -28,6 +30,7 @@ from litellm.types.proxy.management_endpoints.model_management_endpoints import
|
|||
)
|
||||
from litellm.types.proxy.public_endpoints.public_endpoints import (
|
||||
AgentCreateInfo,
|
||||
AutoRouterPresetRecord,
|
||||
ComplexityScorerDefaults,
|
||||
ProviderCreateInfo,
|
||||
PublicModelHubInfo,
|
||||
|
|
@ -464,6 +467,86 @@ async def get_litellm_blog_posts():
|
|||
return BlogPostsResponse(posts=posts)
|
||||
|
||||
|
||||
_AUTOROUTER_PRESETS_ADAPTER: Final = TypeAdapter(dict[str, AutoRouterPresetRecord])
|
||||
|
||||
|
||||
def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
|
||||
raw: Final = json.loads(
|
||||
files("litellm.proxy.public_endpoints").joinpath("autorouter_presets.json").read_text(encoding="utf-8")
|
||||
)
|
||||
return _AUTOROUTER_PRESETS_ADAPTER.validate_python(raw)
|
||||
|
||||
|
||||
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.UI)
|
||||
response: Final = await client.get(url, timeout=5.0)
|
||||
response.raise_for_status()
|
||||
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_python(response.json())
|
||||
if not presets:
|
||||
raise ValueError("remote auto-router preset catalog is empty")
|
||||
return presets
|
||||
|
||||
|
||||
async def _resolve_autorouter_presets(
|
||||
url: str,
|
||||
fetch: Callable[[str], Awaitable[Mapping[str, AutoRouterPresetRecord]]],
|
||||
) -> Mapping[str, AutoRouterPresetRecord]:
|
||||
if os.getenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "").lower() == "true":
|
||||
return _load_bundled_autorouter_presets()
|
||||
try:
|
||||
return await fetch(url)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: failed to fetch auto-router presets from %s: %s. Serving the bundled catalog for the life of this process.",
|
||||
url,
|
||||
str(e),
|
||||
)
|
||||
return _load_bundled_autorouter_presets()
|
||||
|
||||
|
||||
class _AutoRouterPresetsCache:
|
||||
presets: Mapping[str, AutoRouterPresetRecord] | None = None
|
||||
lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
async def get_autorouter_presets(
|
||||
url: str,
|
||||
fetch: Callable[[str], Awaitable[Mapping[str, AutoRouterPresetRecord]]] = _fetch_remote_autorouter_presets,
|
||||
) -> Mapping[str, AutoRouterPresetRecord]:
|
||||
cached: Final = _AutoRouterPresetsCache.presets
|
||||
if cached is not None:
|
||||
return cached
|
||||
if _AutoRouterPresetsCache.lock is None:
|
||||
_AutoRouterPresetsCache.lock = asyncio.Lock()
|
||||
async with _AutoRouterPresetsCache.lock:
|
||||
held: Final = _AutoRouterPresetsCache.presets
|
||||
if held is not None:
|
||||
return held
|
||||
resolved: Final = await _resolve_autorouter_presets(url=url, fetch=fetch)
|
||||
_AutoRouterPresetsCache.presets = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/autorouter_presets",
|
||||
tags=["public", "auto router"], # mutable-ok: FastAPI route tags take a list
|
||||
response_model=dict[str, AutoRouterPresetRecord],
|
||||
)
|
||||
async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
|
||||
"""
|
||||
Return the auto-router preset catalog the dashboard's template picker renders.
|
||||
|
||||
Resolved once per process, like the model cost map: fetched from ``litellm.autorouter_presets_url``
|
||||
(override with ``LITELLM_AUTOROUTER_PRESETS_URL``) on the first request, falling back to the
|
||||
catalog bundled with the package on any failure. Set ``LITELLM_LOCAL_AUTOROUTER_PRESETS=True``
|
||||
to serve the bundled catalog only. A restart picks up a newly published catalog.
|
||||
"""
|
||||
return await get_autorouter_presets(url=litellm.autorouter_presets_url)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/endpoints",
|
||||
tags=["public"],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PublicModelHubInfo(BaseModel):
|
||||
|
|
@ -73,6 +73,44 @@ class SupportedEndpointsResponse(BaseModel):
|
|||
endpoints: list[SupportedEndpoint]
|
||||
|
||||
|
||||
class AutoRouterPresetTiers(BaseModel):
|
||||
"""Exactly the four built-in tiers the dashboard's preset prefill can apply.
|
||||
|
||||
extra="forbid" on purpose: a tier name this dashboard cannot apply would grey out or crash the
|
||||
picker, so such a catalog is rejected wholesale and the bundled one serves instead.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
SIMPLE: Sequence[str]
|
||||
MEDIUM: Sequence[str]
|
||||
COMPLEX: Sequence[str]
|
||||
REASONING: Sequence[str]
|
||||
|
||||
|
||||
class AutoRouterPresetConfig(BaseModel):
|
||||
"""The complexity_router_config a preset prefills.
|
||||
|
||||
Only tiers is validated, because every dashboard consumer dereferences it; everything else
|
||||
passes through verbatim with unknown fields kept (extra="allow"), so a catalog published after
|
||||
this proxy shipped still serves its new fields intact.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
tiers: AutoRouterPresetTiers
|
||||
|
||||
|
||||
class AutoRouterPresetRecord(BaseModel):
|
||||
"""One auto-router preset as served to the dashboard's template picker."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
label: str
|
||||
description: str
|
||||
complexity_router_config: AutoRouterPresetConfig
|
||||
|
||||
|
||||
class ComplexityScorerDefaults(BaseModel):
|
||||
"""The complexity router's shipped heuristic scorer defaults.
|
||||
|
||||
|
|
|
|||
|
|
@ -1077,3 +1077,243 @@ def test_public_mcp_hub_does_not_expose_upstream_url():
|
|||
assert all("url" not in item for item in data)
|
||||
assert secret_url not in response.text
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_autorouter_presets_cache():
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
|
||||
|
||||
_AutoRouterPresetsCache.presets = None
|
||||
_AutoRouterPresetsCache.lock = None
|
||||
yield
|
||||
_AutoRouterPresetsCache.presets = None
|
||||
_AutoRouterPresetsCache.lock = None
|
||||
|
||||
|
||||
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/public/autorouter_presets")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "anthropic_family" in payload
|
||||
for preset in payload.values():
|
||||
assert isinstance(preset["label"], str)
|
||||
assert isinstance(preset["description"], str)
|
||||
assert "tiers" in preset["complexity_router_config"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_autorouter_presets_fetches_once_per_process(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
from litellm.proxy.public_endpoints.public_endpoints import (
|
||||
_AUTOROUTER_PRESETS_ADAPTER,
|
||||
get_autorouter_presets,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
||||
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
}
|
||||
}
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(url):
|
||||
calls.append(url)
|
||||
return remote
|
||||
|
||||
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
||||
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
||||
|
||||
assert first == remote
|
||||
assert second == remote
|
||||
assert calls == ["https://example.test/presets.json"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
import asyncio
|
||||
|
||||
from litellm.proxy.public_endpoints.public_endpoints import (
|
||||
_AUTOROUTER_PRESETS_ADAPTER,
|
||||
get_autorouter_presets,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
||||
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
}
|
||||
}
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def slow_fetch(url):
|
||||
calls.append(url)
|
||||
await asyncio.sleep(0.05)
|
||||
return remote
|
||||
|
||||
results = await asyncio.gather(
|
||||
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
||||
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
||||
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
||||
)
|
||||
|
||||
assert all(result == remote for result in results)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_autorouter_presets_caches_bundled_fallback_on_remote_failure(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
from litellm.proxy.public_endpoints.public_endpoints import get_autorouter_presets
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
||||
calls = []
|
||||
|
||||
async def broken_fetch(url):
|
||||
calls.append(url)
|
||||
raise ValueError("remote catalog unavailable")
|
||||
|
||||
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
||||
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
||||
|
||||
assert "anthropic_family" in first
|
||||
assert second == first
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autorouter_presets_adapter_rejects_wrong_shapes():
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _AUTOROUTER_PRESETS_ADAPTER
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python({"bad": {"label": "no description or config"}})
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python(["not", "a", "mapping"])
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{"no_tiers": {"label": "L", "description": "D", "complexity_router_config": {}}}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"missing_builtin_tier": {
|
||||
"label": "L",
|
||||
"description": "D",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"]}},
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"unknown_tier_name": {
|
||||
"label": "L",
|
||||
"description": "D",
|
||||
"complexity_router_config": {
|
||||
"tiers": {
|
||||
"SIMPLE": ["m1"],
|
||||
"MEDIUM": ["m2"],
|
||||
"COMPLEX": ["m3"],
|
||||
"REASONING": ["m4"],
|
||||
"ULTRA": ["m5"],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"bad_tiers": {
|
||||
"label": "L",
|
||||
"description": "D",
|
||||
"complexity_router_config": {"tiers": "not-a-mapping"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
from litellm.proxy.public_endpoints.public_endpoints import (
|
||||
_AUTOROUTER_PRESETS_ADAPTER,
|
||||
_AutoRouterPresetsCache,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
||||
_AutoRouterPresetsCache.presets = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
||||
{
|
||||
"future_preset": {
|
||||
"label": "Future",
|
||||
"description": "carries fields this proxy version does not know",
|
||||
"complexity_router_config": {
|
||||
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]},
|
||||
"future_config_knob": 3,
|
||||
},
|
||||
"icon": "sparkles",
|
||||
}
|
||||
}
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/public/autorouter_presets")
|
||||
|
||||
assert response.status_code == 200
|
||||
served = response.json()["future_preset"]
|
||||
assert served["icon"] == "sparkles"
|
||||
assert served["complexity_router_config"]["future_config_knob"] == 3
|
||||
assert served["complexity_router_config"]["tiers"]["SIMPLE"] == ["m1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypatch):
|
||||
import litellm.llms.custom_httpx.http_handler as http_handler_module
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _fetch_remote_autorouter_presets
|
||||
|
||||
catalog = {
|
||||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
}
|
||||
}
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=catalog)
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=response)
|
||||
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
|
||||
|
||||
presets = await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
||||
assert presets["remote_only"].label == "Remote Only"
|
||||
response.raise_for_status.assert_called_once()
|
||||
|
||||
response.json = MagicMock(return_value={})
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { AutoRouterPreset, hydratePresets } from "@/lib/autorouter_presets";
|
||||
import { getAutoRouterPresets } from "@/components/networking";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const presetKeys = createQueryKeys("autoRouterPresets");
|
||||
|
||||
export const useAutoRouterPresets = () => {
|
||||
const options = {
|
||||
queryKey: presetKeys.list({}),
|
||||
queryFn: async () => hydratePresets(await getAutoRouterPresets()),
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
gcTime: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return useQuery<AutoRouterPreset[]>(options);
|
||||
};
|
||||
|
|
@ -9,11 +9,19 @@ import { getSubmitBlockedReason } from "./add_auto_router_tab";
|
|||
import { buildModelAvailability } from "@/lib/autorouter_presets";
|
||||
import { testAutoRouterRouting } from "../networking";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { getAllPresets, getPresetByKey, getRequiredModelsInPreset } from "@/lib/autorouter_presets";
|
||||
import { AutoRouterPreset, getRequiredModelsInPreset } from "@/lib/autorouter_presets";
|
||||
import { BUNDLED_PRESETS, LOADED_PRESETS_QUERY, useAutoRouterPresets } from "../../../tests/mocks/autoRouterPresets";
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
);
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets",
|
||||
async () => await import("../../../tests/mocks/autoRouterPresets"),
|
||||
);
|
||||
|
||||
const getAllPresets = (): AutoRouterPreset[] => BUNDLED_PRESETS;
|
||||
const getPresetByKey = (key: string): AutoRouterPreset | undefined => BUNDLED_PRESETS.find((p) => p.key === key);
|
||||
|
||||
const ANTHROPIC_PRESET = getPresetByKey("anthropic_family")!;
|
||||
const ANTHROPIC_TIERS = ANTHROPIC_PRESET.complexity_router_config.tiers;
|
||||
|
|
@ -1142,3 +1150,52 @@ describe("getSubmitBlockedReason", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset catalog fetch states", () => {
|
||||
afterEach(() => vi.mocked(useAutoRouterPresets).mockReturnValue(LOADED_PRESETS_QUERY));
|
||||
|
||||
it("keeps showing cached presets without the error banner when only a refetch fails", () => {
|
||||
vi.mocked(useAutoRouterPresets).mockReturnValue({
|
||||
...LOADED_PRESETS_QUERY,
|
||||
isError: true,
|
||||
} as never);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
expect(screen.queryByText(/Could not load templates/)).not.toBeInTheDocument();
|
||||
|
||||
openTemplateDropdown();
|
||||
expect(screen.queryAllByRole("option").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("shows a loading hint while the catalog fetch is pending", () => {
|
||||
vi.mocked(useAutoRouterPresets).mockReturnValue({
|
||||
...LOADED_PRESETS_QUERY,
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
} as never);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
expect(screen.getByText("Loading templates...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("degrades to Custom Configuration with a retry hint that refetches the catalog", async () => {
|
||||
const refetch = vi.fn();
|
||||
vi.mocked(useAutoRouterPresets).mockReturnValue({
|
||||
...LOADED_PRESETS_QUERY,
|
||||
data: undefined,
|
||||
isError: true,
|
||||
refetch,
|
||||
} as never);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
expect(await screen.findByText(/Could not load templates/)).toBeInTheDocument();
|
||||
|
||||
openTemplateDropdown();
|
||||
const options = screen.queryAllByRole("option");
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent("Custom Configuration");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,8 +50,6 @@ import AutoRouterConnectionTest from "./auto_router_connection_test";
|
|||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
getAllPresets,
|
||||
getPresetByKey,
|
||||
getMissingModelsInPreset,
|
||||
getReferencedModelsError,
|
||||
buildEmptyPrefill,
|
||||
|
|
@ -62,6 +60,7 @@ import {
|
|||
PresetPrefill,
|
||||
AutoRouterPreset,
|
||||
} from "@/lib/autorouter_presets";
|
||||
import { useAutoRouterPresets } from "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface AddAutoRouterTabProps {
|
||||
|
|
@ -102,9 +101,7 @@ const presetDisabledHint = (availability: PresetAvailability): string | null =>
|
|||
// caller-specific missing-model reason gets the alarming red treatment.
|
||||
const isPresetHintAlarming = (availability: PresetAvailability): boolean => availability.kind === "missing_models";
|
||||
|
||||
// getAllPresets() already returns a stable, module-level array (see autorouter_presets.ts), so
|
||||
// this is resolved once at import time rather than re-called from inside the component every render.
|
||||
const presets = getAllPresets();
|
||||
const NO_PRESETS: AutoRouterPreset[] = [];
|
||||
|
||||
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
|
||||
// caller can see the shape of the config without opening it.
|
||||
|
|
@ -229,6 +226,14 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
});
|
||||
const modelsLoading = groupsLoading || deploymentsLoading;
|
||||
const modelInfo = React.useMemo(() => data ?? [], [data]);
|
||||
const {
|
||||
data: presetsData,
|
||||
isPending: presetsPending,
|
||||
isError: presetsError,
|
||||
refetch: refetchPresets,
|
||||
} = useAutoRouterPresets();
|
||||
const presets = presetsData ?? NO_PRESETS;
|
||||
const presetsUnavailable = presetsError && presetsData === undefined;
|
||||
// react-query keeps the last successful list around when a later refetch fails, so isError alone
|
||||
// can't tell "never loaded" apart from "loaded, then a background refetch errored" - only the
|
||||
// former leaves us with nothing trustworthy to verify a preset's models against.
|
||||
|
|
@ -277,7 +282,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
presets
|
||||
.map((preset) => ({ preset, availability: presetAvailability(preset) }))
|
||||
.sort((a, b) => Number(b.availability.kind === "available") - Number(a.availability.kind === "available")),
|
||||
[presetAvailability],
|
||||
[presets, presetAvailability],
|
||||
);
|
||||
|
||||
const templateItems = React.useMemo(
|
||||
|
|
@ -307,7 +312,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const preset = getPresetByKey(presetKey);
|
||||
const preset = presets.find((p) => p.key === presetKey);
|
||||
// Refuse to apply a preset whose models are not verified available. The dropdown disables
|
||||
// these options, so this is a guard against a stale click resolving after the list changed.
|
||||
if (!preset) return;
|
||||
|
|
@ -538,6 +543,15 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import type {
|
|||
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
|
||||
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
|
||||
import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity_router_config";
|
||||
import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets";
|
||||
import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab";
|
||||
import type { RoutingDecision } from "./view_logs/LogDetailsDrawer/RoutingDecisionCard";
|
||||
import {
|
||||
|
|
@ -410,6 +411,15 @@ export const getComplexityScorerDefaults = async (): Promise<ComplexityScorerDef
|
|||
return await apiClient.get(`/public/complexity_router/scorer_defaults`);
|
||||
};
|
||||
|
||||
export const getAutoRouterPresets = async (): Promise<AutoRouterPresetsResponse> => {
|
||||
/**
|
||||
* Fetch the auto-router preset catalog from the proxy's public endpoint. The template picker
|
||||
* renders from this rather than from a copy in the dashboard, so a catalog update propagates
|
||||
* without a dashboard release.
|
||||
*/
|
||||
return await apiClient.get(`/public/autorouter_presets`);
|
||||
};
|
||||
|
||||
export const getAgentCreateMetadata = async (): Promise<AgentCreateInfo[]> => {
|
||||
/**
|
||||
* Fetch agent type metadata from the proxy's public endpoint.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import bundledPresets from "../../../../litellm/proxy/public_endpoints/autorouter_presets.json";
|
||||
import {
|
||||
getAllPresets,
|
||||
getPresetByKey,
|
||||
hydratePresets,
|
||||
AutoRouterPreset,
|
||||
AutoRouterPresetsResponse,
|
||||
getRequiredModelsInPreset,
|
||||
getMissingModelsInPreset,
|
||||
getRequiredModels,
|
||||
|
|
@ -18,8 +20,13 @@ import { DEFAULT_ESCALATION_KEYWORDS } from "@/components/add_model/EscalationKe
|
|||
|
||||
const groupsOnly = (models: Iterable<string>) => buildModelAvailability(models, []);
|
||||
|
||||
// Hydrated from the real bundled catalog so a catalog edit flows into these expectations.
|
||||
const PRESETS = hydratePresets(bundledPresets as AutoRouterPresetsResponse);
|
||||
const getAllPresets = (): AutoRouterPreset[] => PRESETS;
|
||||
const getPresetByKey = (key: string): AutoRouterPreset | undefined => PRESETS.find((p) => p.key === key);
|
||||
|
||||
describe("autorouter_presets", () => {
|
||||
it("loads exactly the bundled presets", () => {
|
||||
it("hydrates exactly the bundled presets", () => {
|
||||
const presets = getAllPresets();
|
||||
expect(presets.map((p) => p.label).sort()).toEqual(["Anthropic Family", "Gemini Family", "Lite", "OpenAI Family"]);
|
||||
// Every preset carries all four fields the UI relies on; a JSON typo dropping one fails here.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
} from "@/components/add_model/complexity_router_tiers";
|
||||
import { DEFAULT_ESCALATION_KEYWORDS } from "@/components/add_model/EscalationKeywords";
|
||||
import { DEFAULT_MATCH_THRESHOLD } from "@/components/add_model/SemanticKeywordMatching";
|
||||
import presetsRaw from "@/autorouter_presets.json";
|
||||
|
||||
// `key` is the stable JSON object key (e.g. "anthropic_family"); `label` is display text and
|
||||
// never an identity.
|
||||
|
|
@ -31,16 +30,10 @@ export interface AutoRouterPreset {
|
|||
complexity_router_config: ComplexityRouterConfigPayload;
|
||||
}
|
||||
|
||||
// The bundled JSON is a developer-authored, build-time asset, so it is trusted at the import
|
||||
// boundary rather than re-validated at runtime (resolveJsonModule widens its string literals,
|
||||
// hence this one cast). autorouter_presets.test.ts pins the parsed shape, so a JSON typo fails CI.
|
||||
const RAW = presetsRaw as Record<string, Omit<AutoRouterPreset, "key">>;
|
||||
export type AutoRouterPresetsResponse = Record<string, Omit<AutoRouterPreset, "key">>;
|
||||
|
||||
const PRESETS: AutoRouterPreset[] = Object.entries(RAW).map(([key, preset]) => ({ key, ...preset }));
|
||||
|
||||
export const getAllPresets = (): AutoRouterPreset[] => PRESETS;
|
||||
|
||||
export const getPresetByKey = (key: string): AutoRouterPreset | undefined => PRESETS.find((p) => p.key === key);
|
||||
export const hydratePresets = (raw: AutoRouterPresetsResponse): AutoRouterPreset[] =>
|
||||
Object.entries(raw).map(([key, preset]) => ({ key, ...preset }));
|
||||
|
||||
// Generalized over ComplexityRouterConfigPayload so the same accessors check either a preset's own
|
||||
// bundled config or a caller's actually-built config - the two need to agree, since a preset only
|
||||
|
|
|
|||
90
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
90
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -12129,6 +12129,31 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/public/autorouter_presets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Public Autorouter Presets
|
||||
* @description Return the auto-router preset catalog the dashboard's template picker renders.
|
||||
*
|
||||
* Resolved once per process, like the model cost map: fetched from ``litellm.autorouter_presets_url``
|
||||
* (override with ``LITELLM_AUTOROUTER_PRESETS_URL``) on the first request, falling back to the
|
||||
* catalog bundled with the package on any failure. Set ``LITELLM_LOCAL_AUTOROUTER_PRESETS=True``
|
||||
* to serve the bundled catalog only. A restart picks up a newly published catalog.
|
||||
*/
|
||||
get: operations["get_public_autorouter_presets_public_autorouter_presets_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/public/complexity_router/scorer_defaults": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -23391,6 +23416,49 @@ export interface components {
|
|||
/** Tier Definitions */
|
||||
tier_definitions: components["schemas"]["TierDefinition"][];
|
||||
};
|
||||
/**
|
||||
* AutoRouterPresetConfig
|
||||
* @description The complexity_router_config a preset prefills.
|
||||
*
|
||||
* Only tiers is validated, because every dashboard consumer dereferences it; everything else
|
||||
* passes through verbatim with unknown fields kept (extra="allow"), so a catalog published after
|
||||
* this proxy shipped still serves its new fields intact.
|
||||
*/
|
||||
AutoRouterPresetConfig: {
|
||||
tiers: components["schemas"]["AutoRouterPresetTiers"];
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* AutoRouterPresetRecord
|
||||
* @description One auto-router preset as served to the dashboard's template picker.
|
||||
*/
|
||||
AutoRouterPresetRecord: {
|
||||
complexity_router_config: components["schemas"]["AutoRouterPresetConfig"];
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Label */
|
||||
label: string;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* AutoRouterPresetTiers
|
||||
* @description Exactly the four built-in tiers the dashboard's preset prefill can apply.
|
||||
*
|
||||
* extra="forbid" on purpose: a tier name this dashboard cannot apply would grey out or crash the
|
||||
* picker, so such a catalog is rejected wholesale and the bundled one serves instead.
|
||||
*/
|
||||
AutoRouterPresetTiers: {
|
||||
/** Complex */
|
||||
COMPLEX: string[];
|
||||
/** Medium */
|
||||
MEDIUM: string[];
|
||||
/** Reasoning */
|
||||
REASONING: string[];
|
||||
/** Simple */
|
||||
SIMPLE: string[];
|
||||
};
|
||||
/**
|
||||
* AutoRouterRoutingTestRequest
|
||||
* @description A single request to classify against a complexity-router config that need not be saved yet.
|
||||
|
|
@ -54679,6 +54747,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_public_autorouter_presets_public_autorouter_presets_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: components["schemas"]["AutoRouterPresetRecord"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_complexity_scorer_defaults_public_complexity_router_scorer_defaults_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
16
ui/litellm-dashboard/tests/mocks/autoRouterPresets.ts
Normal file
16
ui/litellm-dashboard/tests/mocks/autoRouterPresets.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { vi } from "vitest";
|
||||
import bundledPresets from "../../../../litellm/proxy/public_endpoints/autorouter_presets.json";
|
||||
import { hydratePresets, type AutoRouterPresetsResponse } from "@/lib/autorouter_presets";
|
||||
|
||||
// Derived from the real bundled catalog so a preset edit there flows into test expectations
|
||||
// instead of redding on a stale copy. Exported as vi.fn so a test can override the query state.
|
||||
export const BUNDLED_PRESETS = hydratePresets(bundledPresets as AutoRouterPresetsResponse);
|
||||
|
||||
export const LOADED_PRESETS_QUERY = {
|
||||
data: BUNDLED_PRESETS,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
|
||||
export const useAutoRouterPresets = vi.fn(() => LOADED_PRESETS_QUERY);
|
||||
Loading…
Add table
Reference in a new issue