mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #35009 from BerriAI/litellm_routing_nav_autorouter
feat(ui): give auto-routers their own tab on Models + Endpoints
This commit is contained in:
commit
708d010115
47 changed files with 2621 additions and 930 deletions
|
|
@ -11827,6 +11827,22 @@ def _sort_models(
|
|||
return all_models
|
||||
|
||||
|
||||
def _is_auto_router_model(model: Mapping[str, object]) -> bool:
|
||||
"""
|
||||
True for any auto-router deployment, i.e. every `auto_router/*` strategy
|
||||
(semantic, complexity, adaptive, quality).
|
||||
|
||||
Router._is_auto_router_deployment is deliberately narrower; it answers "is this the
|
||||
*semantic* auto-router strategy" and returns False for the complexity and adaptive
|
||||
prefixes, so it is not reusable here.
|
||||
"""
|
||||
litellm_params = model.get("litellm_params")
|
||||
if not isinstance(litellm_params, Mapping):
|
||||
return False
|
||||
litellm_model = litellm_params.get("model")
|
||||
return isinstance(litellm_model, str) and litellm_model.startswith("auto_router/")
|
||||
|
||||
|
||||
def _paginate_models_response(
|
||||
all_models: List[Dict[str, Any]],
|
||||
page: int,
|
||||
|
|
@ -12121,6 +12137,15 @@ async def model_info_v2(
|
|||
"asc",
|
||||
description="Sort order. Options: asc, desc",
|
||||
),
|
||||
exclude_auto_routers: bool | None = fastapi.Query(
|
||||
False,
|
||||
description=(
|
||||
"Omit auto-router deployments (litellm model prefixed `auto_router/`). "
|
||||
"They select among deployments rather than being deployments themselves, so a "
|
||||
"caller rendering a deployment list can leave them out. Defaults to false, so "
|
||||
"existing callers are unaffected"
|
||||
),
|
||||
),
|
||||
):
|
||||
"""
|
||||
Paginated model metadata for proxy deployments (pricing, provider, team access).
|
||||
|
|
@ -12288,6 +12313,11 @@ async def model_info_v2(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a
|
||||
# truthy sentinel object rather than False.
|
||||
if exclude_auto_routers is True:
|
||||
all_models = [m for m in all_models if not _is_auto_router_model(m)]
|
||||
|
||||
# Update total count to include agents
|
||||
search_total_count = len(all_models)
|
||||
|
||||
|
|
|
|||
|
|
@ -292,3 +292,149 @@ def test_model_group_info_invalid_method(client, auth_as, null_router):
|
|||
response = client.post("/model_group/info", json={})
|
||||
assert response.status_code == 405
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v2/model/info?exclude_auto_routers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_auto_router_router(monkeypatch):
|
||||
"""Router carrying one ordinary deployment per auto-router strategy plus two plain ones."""
|
||||
model_list = [
|
||||
{
|
||||
"model_name": "gpt-4o-mini",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini"},
|
||||
"model_info": {"id": "plain-1", "db_model": False},
|
||||
},
|
||||
{
|
||||
"model_name": "tri-tier-router",
|
||||
"litellm_params": {"model": "auto_router/complexity_router"},
|
||||
"model_info": {"id": "auto-complexity", "db_model": True},
|
||||
},
|
||||
{
|
||||
"model_name": "support-router",
|
||||
"litellm_params": {"model": "auto_router/support-router"},
|
||||
"model_info": {"id": "auto-semantic", "db_model": True},
|
||||
},
|
||||
{
|
||||
"model_name": "adaptive-router",
|
||||
"litellm_params": {"model": "auto_router/adaptive_router"},
|
||||
"model_info": {"id": "auto-adaptive", "db_model": True},
|
||||
},
|
||||
{
|
||||
"model_name": "claude-opus",
|
||||
"litellm_params": {"model": "anthropic/claude-opus-4-6"},
|
||||
"model_info": {"id": "plain-2", "db_model": False},
|
||||
},
|
||||
]
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
router = MagicMock()
|
||||
router.model_list = model_list
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(proxy_server, "llm_model_list", model_list)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "user_model", None)
|
||||
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"_apply_search_filter_to_models",
|
||||
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
|
||||
|
||||
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
|
||||
|
||||
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
|
||||
yield router
|
||||
|
||||
|
||||
def _model_names(payload) -> list:
|
||||
return [m["model_name"] for m in payload["data"]]
|
||||
|
||||
|
||||
def test_v2_model_info_includes_auto_routers_by_default(client, auth_as, mixed_auto_router_router):
|
||||
"""The new param is opt-in; omitting it must not change what any existing caller sees."""
|
||||
with auth_as():
|
||||
response = client.get("/v2/model/info")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "tri-tier-router" in _model_names(payload)
|
||||
assert payload["total_count"] == 5
|
||||
|
||||
|
||||
def test_v2_model_info_excludes_every_auto_router_strategy(client, auth_as, mixed_auto_router_router):
|
||||
"""All four `auto_router/*` strategies go, not just the semantic one that
|
||||
Router._is_auto_router_deployment recognises."""
|
||||
with auth_as():
|
||||
response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert _model_names(payload) == ["gpt-4o-mini", "claude-opus"]
|
||||
|
||||
|
||||
def test_v2_model_info_exclude_auto_routers_shrinks_total_count(client, auth_as, mixed_auto_router_router):
|
||||
"""The filter must run before the count, or the table pages off a total that
|
||||
includes rows it never renders (49 shown, 50 claimed)."""
|
||||
with auth_as():
|
||||
response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
|
||||
payload = response.json()
|
||||
assert payload["total_count"] == 2
|
||||
assert len(payload["data"]) == payload["total_count"]
|
||||
|
||||
|
||||
def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(
|
||||
client, auth_as, mixed_auto_router_router
|
||||
):
|
||||
"""Page size applies to the filtered list, so no page silently comes back short."""
|
||||
with auth_as():
|
||||
response = client.get(
|
||||
"/v2/model/info", params={"exclude_auto_routers": "true", "page": 1, "size": 1}
|
||||
)
|
||||
payload = response.json()
|
||||
assert payload["total_count"] == 2
|
||||
assert payload["total_pages"] == 2
|
||||
assert len(payload["data"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_auto_router_router):
|
||||
"""Called directly (not through FastAPI) the default arrives as a truthy Query object.
|
||||
Guarding on `is True` is what stops every direct-call test from silently filtering."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"_apply_search_filter_to_models",
|
||||
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
|
||||
|
||||
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
|
||||
|
||||
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
|
||||
|
||||
admin = UserAPIKeyAuth(user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
# Deliberately omit exclude_auto_routers, exactly as the pre-existing direct-call tests do.
|
||||
resp = await proxy_server.model_info_v2(
|
||||
user_api_key_dict=admin,
|
||||
model=None,
|
||||
user_models_only=False,
|
||||
include_team_models=False,
|
||||
debug=False,
|
||||
page=1,
|
||||
size=50,
|
||||
search=None,
|
||||
modelId=None,
|
||||
teamId=None,
|
||||
sortBy=None,
|
||||
sortOrder="asc",
|
||||
)
|
||||
|
||||
assert "tri-tier-router" in [m["model_name"] for m in resp["data"]]
|
||||
|
|
|
|||
|
|
@ -151,14 +151,11 @@
|
|||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
|
||||
|
|
@ -210,11 +207,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1696,7 +1688,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 2
|
||||
|
|
@ -2550,17 +2542,12 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_auto_router_tab.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_auto_router_tab.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_model_modes.tsx": {
|
||||
|
|
@ -2568,19 +2555,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_model_tab.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_model_tab.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/add_model/advanced_settings.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3343,7 +3317,7 @@
|
|||
"count": 5
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 153
|
||||
"count": 152
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 32
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Form } from "antd";
|
||||
|
||||
import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
|
||||
|
||||
interface AutorouterTabProps {
|
||||
accessToken: string | null;
|
||||
userId: string | null;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
const AutorouterTab: React.FC<AutorouterTabProps> = ({ accessToken, userRole }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AddAutoRouterTab form={form} handleOk={() => form.resetFields()} accessToken={accessToken} userRole={userRole} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutorouterTab;
|
||||
|
|
@ -27,7 +27,6 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
|
|||
}));
|
||||
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div /> }));
|
||||
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div /> }));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
||||
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div data-testid="autorouter-tab" /> }));
|
||||
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () => <div data-testid="caching-tab" /> }));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
|
@ -11,13 +10,13 @@ import CostOptimizationView from "./CostOptimizationView";
|
|||
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
|
||||
|
||||
describe("CostOptimizationView", () => {
|
||||
it("renders all four cost-optimization tabs", () => {
|
||||
const { getByText } = renderView();
|
||||
it("renders the three cost-optimization tabs and no autorouter tab", () => {
|
||||
const { getByText, queryByText } = renderView();
|
||||
|
||||
expect(getByText("Usage")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Compression")).toBeInTheDocument();
|
||||
expect(getByText("Autorouter")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Caching")).toBeInTheDocument();
|
||||
expect(queryByText("Autorouter")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to the Usage tab and switches the active tab on click", () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Alert, Tabs } from "antd";
|
|||
|
||||
import UsageTab from "./UsageTab";
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
import AutorouterTab from "./AutorouterTab";
|
||||
import PromptCachingTab from "./PromptCachingTab";
|
||||
import { useDailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
|
|
@ -30,11 +29,6 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
label: "Prompt Compression",
|
||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||
},
|
||||
{
|
||||
key: "autorouter",
|
||||
label: "Autorouter",
|
||||
children: <AutorouterTab accessToken={accessToken} userId={userId} userRole={userRole} />,
|
||||
},
|
||||
{
|
||||
key: "caching",
|
||||
label: "Prompt Caching",
|
||||
|
|
@ -50,7 +44,8 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing
|
||||
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
|
||||
live under Models + Endpoints, on the Auto-Routers tab
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
selectAutoRouterModelGroups,
|
||||
useAllProxyModels,
|
||||
useAutoRouterModelGroups,
|
||||
useAutoRouters,
|
||||
useInfiniteModelInfo,
|
||||
useModelHub,
|
||||
useModelsInfo,
|
||||
|
|
@ -113,6 +114,9 @@ describe("useModelsInfo", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
// exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
|
||||
// every other consumer of this hook keeps seeing auto-routers.
|
||||
false,
|
||||
);
|
||||
expect(modelInfoCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -137,6 +141,9 @@ describe("useModelsInfo", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
// exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
|
||||
// every other consumer of this hook keeps seeing auto-routers.
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1079,4 +1086,24 @@ describe("useAutoRouterModelGroups", () => {
|
|||
await waitFor(() => expect(modelInfoCall).toHaveBeenCalled());
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
// The Auto-Routers tab and the models table read the same /v2/model/info data. Six call
|
||||
// sites across the app invalidate ["models","list"] after a write; if the auto-router query
|
||||
// sits in its own namespace, an edit through ModelInfoView leaves the tab stale until a full
|
||||
// reload, and every future writer has to remember a second key.
|
||||
describe("auto-router cache namespace", () => {
|
||||
it("keys the auto-router list under models/list so existing invalidations reach it", async () => {
|
||||
(modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);
|
||||
const { result } = renderHook(() => useAutoRouters(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const keys = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ["models", "list"] })
|
||||
.map((query) => query.queryKey);
|
||||
|
||||
expect(keys.some((key) => JSON.stringify(key).includes("autoRouters"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery, useInfiniteQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { useQuery, useInfiniteQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
|
|
@ -24,7 +24,6 @@ export interface PaginatedModelInfoResponse {
|
|||
|
||||
const modelKeys = createQueryKeys("models");
|
||||
const modelHubKeys = createQueryKeys("modelHub");
|
||||
const autoRouterKeys = createQueryKeys("autoRouterModelGroups");
|
||||
const allProxyModelsKeys = createQueryKeys("allProxyModels");
|
||||
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
|
||||
const infiniteModelKeys = createQueryKeys("infiniteModels");
|
||||
|
|
@ -38,6 +37,7 @@ export const useModelsInfo = (
|
|||
teamId?: string,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
excludeAutoRouters: boolean = false,
|
||||
) => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
return useQuery<PaginatedModelInfoResponse>({
|
||||
|
|
@ -52,10 +52,25 @@ export const useModelsInfo = (
|
|||
...(teamId && { teamId }),
|
||||
...(sortBy && { sortBy }),
|
||||
...(sortOrder && { sortOrder }),
|
||||
// Part of the key: callers that exclude auto-routers must not share a cache entry
|
||||
// with callers that keep them.
|
||||
...(excludeAutoRouters && { excludeAutoRouters: "true" }),
|
||||
},
|
||||
}),
|
||||
queryFn: async () =>
|
||||
await modelInfoCall(accessToken!, userId!, userRole!, page, size, search, modelId, teamId, sortBy, sortOrder),
|
||||
await modelInfoCall(
|
||||
accessToken!,
|
||||
userId!,
|
||||
userRole!,
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
modelId,
|
||||
teamId,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
excludeAutoRouters,
|
||||
),
|
||||
enabled: Boolean(accessToken && userId && userRole),
|
||||
});
|
||||
};
|
||||
|
|
@ -69,6 +84,30 @@ export interface AutoRouterCandidateDeployment {
|
|||
litellm_params?: { model?: string | null } | null;
|
||||
}
|
||||
|
||||
export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
|
||||
litellm_params?: {
|
||||
model?: string | null;
|
||||
complexity_router_config?: unknown;
|
||||
complexity_router_default_model?: string | null;
|
||||
auto_router_config?: unknown;
|
||||
auto_router_default_model?: string | null;
|
||||
auto_router_embedding_model?: string | null;
|
||||
adaptive_router_config?: unknown;
|
||||
adaptive_router_default_model?: string | null;
|
||||
quality_router_config?: unknown;
|
||||
quality_router_default_model?: string | null;
|
||||
} | null;
|
||||
model_info?: {
|
||||
id?: string | null;
|
||||
/** False for config.yaml-defined deployments, which the update and delete routes refuse. */
|
||||
db_model?: boolean | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
team_id?: string | null;
|
||||
created_by?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean =>
|
||||
Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX));
|
||||
|
||||
|
|
@ -80,11 +119,14 @@ export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDepl
|
|||
.filter((modelName): modelName is string => Boolean(modelName)),
|
||||
);
|
||||
|
||||
export const selectAutoRouterDeployments = (deployments: AutoRouterDeployment[]): AutoRouterDeployment[] =>
|
||||
deployments.filter(isAutoRouterDeployment);
|
||||
|
||||
const fetchAllModelDeployments = async (
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
): Promise<AutoRouterCandidateDeployment[]> => {
|
||||
): Promise<AutoRouterDeployment[]> => {
|
||||
const firstPage: PaginatedModelInfoResponse = await modelInfoCall(
|
||||
accessToken,
|
||||
userId,
|
||||
|
|
@ -100,18 +142,28 @@ const fetchAllModelDeployments = async (
|
|||
);
|
||||
return [firstPage, ...remainingPages].flatMap(
|
||||
(page: PaginatedModelInfoResponse) => page?.data ?? [],
|
||||
) as AutoRouterCandidateDeployment[];
|
||||
) as AutoRouterDeployment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Deliberately under the same `models/list` namespace as useModelsInfo: it is the same
|
||||
* /v2/model/info data, and every writer in the app already invalidates ["models","list"].
|
||||
* A private namespace meant an edit through ModelInfoView left this list stale, and every
|
||||
* future writer would have had to remember a second key.
|
||||
*/
|
||||
const autoRouterListKey = (userId: string | null, userRole: string | null) =>
|
||||
modelKeys.list({
|
||||
filters: {
|
||||
scope: "autoRouters",
|
||||
...(userId && { userId }),
|
||||
...(userRole && { userRole }),
|
||||
},
|
||||
});
|
||||
|
||||
export const useAutoRouterModelGroups = (): ReadonlySet<string> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const { data } = useQuery<AutoRouterCandidateDeployment[], Error, ReadonlySet<string>>({
|
||||
queryKey: autoRouterKeys.list({
|
||||
filters: {
|
||||
...(userId && { userId }),
|
||||
...(userRole && { userRole }),
|
||||
},
|
||||
}),
|
||||
const { data } = useQuery<AutoRouterDeployment[], Error, ReadonlySet<string>>({
|
||||
queryKey: autoRouterListKey(userId, userRole),
|
||||
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
|
||||
enabled: Boolean(accessToken && userId && userRole),
|
||||
select: selectAutoRouterModelGroups,
|
||||
|
|
@ -119,6 +171,23 @@ export const useAutoRouterModelGroups = (): ReadonlySet<string> => {
|
|||
return data ?? NO_AUTO_ROUTERS;
|
||||
};
|
||||
|
||||
export const useAutoRouters = (): UseQueryResult<AutoRouterDeployment[], Error> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
return useQuery<AutoRouterDeployment[], Error, AutoRouterDeployment[]>({
|
||||
queryKey: autoRouterListKey(userId, userRole),
|
||||
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
|
||||
enabled: Boolean(accessToken && userId && userRole),
|
||||
select: selectAutoRouterDeployments,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInvalidateAutoRouters = (): (() => Promise<void>) => {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
|
||||
};
|
||||
};
|
||||
|
||||
export const useModelHub = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery({
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ const AllModelsTab = ({
|
|||
teamIdForQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
// Auto-routers are routing constructs, not deployments; the sibling Auto-Routers tab
|
||||
// lists and manages them. Excluded server-side so total_count stays honest.
|
||||
true,
|
||||
);
|
||||
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
|
||||
|
||||
import { AutoRoutersPanel } from "./AutoRoutersPanel";
|
||||
|
||||
const { modelInfoCall, modelDeleteCall } = vi.hoisted(() => ({
|
||||
modelInfoCall: vi.fn(),
|
||||
modelDeleteCall: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
modelInfoCall,
|
||||
modelDeleteCall,
|
||||
modelHubCall: vi.fn(),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const { openModel } = vi.hoisted(() => ({ openModel: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/detailNavigation", () => ({
|
||||
useModelDetailRouting: () => ({ openModel, modelId: null, teamId: null, openTeam: vi.fn(), close: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/edit_auto_router/edit_auto_router_modal", () => ({
|
||||
__esModule: true,
|
||||
default: ({ modelData }: { modelData: { model_name?: string; model_info?: { id?: string } } }) => (
|
||||
<div data-testid="edit-auto-router-modal">
|
||||
edit:{modelData.model_name}:{modelData.model_info?.id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add_model/add_auto_router_tab", () => ({
|
||||
__esModule: true,
|
||||
default: ({ handleOk }: { handleOk: () => void }) => (
|
||||
<button type="button" onClick={handleOk}>
|
||||
Submit auto router
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// A realistic /v2/model/info page: two auto-routers among ordinary deployments. The panel must
|
||||
// render exactly the auto_router/* rows; a view that renders page.data unfiltered passes a
|
||||
// "renders a table" assertion but fails this one.
|
||||
const DEPLOYMENTS = [
|
||||
{
|
||||
// DB-created adaptive router: no editor for its shape, but it must stay deletable, since
|
||||
// auto-routers are excluded from Models + Endpoints and this tab is the only delete path.
|
||||
model_name: "adaptive-router",
|
||||
litellm_params: { model: "auto_router/adaptive_router" },
|
||||
model_info: { id: "auto-3", db_model: true },
|
||||
},
|
||||
{
|
||||
// config.yaml row: the API refuses both update and delete, so neither control may appear.
|
||||
model_name: "config-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: { tiers: {}, classifier_type: "llm" },
|
||||
},
|
||||
model_info: { id: "auto-4", db_model: false },
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4o-mini",
|
||||
litellm_params: { model: "openai/gpt-4o-mini" },
|
||||
model_info: { id: "plain-1" },
|
||||
},
|
||||
{
|
||||
model_name: "tri-tier-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: { tiers: { SIMPLE: ["gpt-4o-mini"] }, classifier_type: "heuristic" },
|
||||
complexity_router_default_model: "gpt-4o-mini",
|
||||
},
|
||||
model_info: { id: "auto-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
|
||||
},
|
||||
{
|
||||
model_name: "anthropic-opus-4-6",
|
||||
litellm_params: { model: "anthropic/claude-opus-4-6" },
|
||||
model_info: { id: "plain-2" },
|
||||
},
|
||||
{
|
||||
model_name: "support-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/support-router",
|
||||
auto_router_config: JSON.stringify({ routes: [{ name: "gpt-4o-mini" }] }),
|
||||
auto_router_default_model: "gpt-4o-mini",
|
||||
},
|
||||
model_info: { id: "auto-2", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
|
||||
},
|
||||
];
|
||||
|
||||
const pageOf = (data: typeof DEPLOYMENTS) => ({
|
||||
data,
|
||||
total_count: data.length,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
const mockDeploymentsPage = () => {
|
||||
modelInfoCall.mockResolvedValue(pageOf(DEPLOYMENTS));
|
||||
};
|
||||
|
||||
const renderPanel = (canModify = true) =>
|
||||
renderWithProviders(
|
||||
<AutoRoutersPanel
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
userID="u-admin"
|
||||
teams={null}
|
||||
createScope={canModify ? "unscoped-ok" : "forbidden"}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("AutoRoutersPanel", () => {
|
||||
beforeEach(() => {
|
||||
// The shared test client caches with staleTime: Infinity and refetchOnMount: false, so
|
||||
// without this every test after the first reads the previous test's deployment page.
|
||||
testQueryClient.clear();
|
||||
modelInfoCall.mockReset();
|
||||
modelDeleteCall.mockClear();
|
||||
openModel.mockClear();
|
||||
mockDeploymentsPage();
|
||||
});
|
||||
|
||||
it("lists only auto_router deployments, not every model on the proxy", async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
|
||||
expect(await screen.findByText("support-router")).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-4o-mini", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("anthropic-opus-4-6", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels Type by classifier rather than by router family", async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("Heuristic")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Semantic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reuses the models-page drill-in, so an auto router opens the full ModelInfoView with
|
||||
// Model Settings and Edit Settings, not a parallel detail view that reimplements part of it.
|
||||
it("opens the shared model detail view on row click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "support-router" }));
|
||||
|
||||
expect(openModel).toHaveBeenCalledWith("auto-2");
|
||||
});
|
||||
|
||||
it("opens the create form in a dialog and refetches the list after a create", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText("tri-tier-router");
|
||||
const callsBeforeCreate = modelInfoCall.mock.calls.length;
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
|
||||
// A dialog, not a full-panel swap: the list stays mounted behind it.
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Add Auto Router");
|
||||
expect(screen.getByText("tri-tier-router")).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Submit auto router" }));
|
||||
|
||||
// Back on the list, and the deployment query was invalidated so a new router shows up
|
||||
// without a manual page reload.
|
||||
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
|
||||
await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeCreate));
|
||||
});
|
||||
|
||||
// The page decides who may write (proxy admin or team admin); the panel just has to make
|
||||
// every write affordance absent when told no, rather than let a submit 403 later. Reading
|
||||
// stays open: a read-only caller can still drill into the detail view.
|
||||
it("shows the list but no write affordances when canModify is false", async () => {
|
||||
renderPanel(false);
|
||||
|
||||
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add Auto Router" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("auto-router-actions-auto-1")).not.toBeInTheDocument();
|
||||
// Still navigable, because opening the detail view is a read.
|
||||
expect(screen.getByRole("button", { name: "tri-tier-router" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Auto-routers are hidden from Models + Endpoints, which used to be the only route to the
|
||||
// delete action, so this tab is now the only place an auto router can be removed.
|
||||
it("deletes the chosen router by its model id and refetches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText("support-router");
|
||||
const callsBeforeDelete = modelInfoCall.mock.calls.length;
|
||||
|
||||
await user.click(screen.getByTestId("auto-router-actions-auto-2"));
|
||||
await user.click(await screen.findByTestId("auto-router-action-delete"));
|
||||
await user.click(await screen.findByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-2"));
|
||||
await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeDelete));
|
||||
});
|
||||
|
||||
it("does not delete when the confirmation is dismissed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText("support-router");
|
||||
|
||||
await user.click(screen.getByTestId("auto-router-actions-auto-2"));
|
||||
await user.click(await screen.findByTestId("auto-router-action-delete"));
|
||||
await user.click(await screen.findByRole("button", { name: /cancel/i }));
|
||||
|
||||
expect(modelDeleteCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives a read-only caller no delete affordance", async () => {
|
||||
renderPanel(false);
|
||||
|
||||
await screen.findByText("support-router");
|
||||
expect(screen.queryByTestId("auto-router-actions-auto-2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty state when the proxy has models but no auto routers", async () => {
|
||||
modelInfoCall.mockResolvedValue(
|
||||
pageOf(DEPLOYMENTS.filter((d) => !d.litellm_params.model.startsWith("auto_router/"))),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("No auto routers yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps delete available on a DB-created adaptive router that has no editor", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText("adaptive-router");
|
||||
await user.click(screen.getByTestId("auto-router-actions-auto-3"));
|
||||
await user.click(await screen.findByTestId("auto-router-action-delete"));
|
||||
await user.click(await screen.findByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-3"));
|
||||
});
|
||||
|
||||
it("offers no delete on a config-defined router, which the API would refuse", async () => {
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText("config-router");
|
||||
expect(screen.queryByTestId("auto-router-actions-auto-4")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAutoRouters, useInvalidateAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { modelDeleteCall } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import { Team } from "@/components/networking";
|
||||
|
||||
import { AutoRoutersTable } from "./AutoRoutersTable";
|
||||
import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
|
||||
|
||||
interface AutoRoutersPanelProps {
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
userID: string | null;
|
||||
teams: Team[] | null;
|
||||
/** Owned by the page, which knows how this caller must scope what they create. */
|
||||
createScope: ModelWriteScope;
|
||||
}
|
||||
|
||||
export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) {
|
||||
const canCreate = createScope !== "forbidden";
|
||||
const { data: deployments, isLoading } = useAutoRouters();
|
||||
const invalidateAutoRouters = useInvalidateAutoRouters();
|
||||
// Clicking a router opens the same ?model= drill-in the All Models table uses, so an auto
|
||||
// router gets the full ModelInfoView: Model Settings, Edit Settings, Edit Auto Router and
|
||||
// Delete. A separate detail view here would be a worse copy of it.
|
||||
const { openModel } = useModelDetailRouting();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingRouter, setDeletingRouter] = useState<AutoRouterRow | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const routers = useMemo(
|
||||
() => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
|
||||
[deployments, userRole, userID, teams],
|
||||
);
|
||||
|
||||
const handleCreated = () => {
|
||||
setIsCreating(false);
|
||||
void invalidateAutoRouters();
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingRouter) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await modelDeleteCall(accessToken, deletingRouter.id);
|
||||
NotificationsManager.success(`Deleted auto router: ${deletingRouter.name}`);
|
||||
setDeletingRouter(null);
|
||||
await invalidateAutoRouters();
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(`Failed to delete auto router: ${error}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Auto routers</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Auto routers sit above your deployments and pick a model per request. They are called like any other model,
|
||||
so clients keep using a single model name.
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button onClick={() => setIsCreating(true)} className="shrink-0">
|
||||
<Plus />
|
||||
Add Auto Router
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AutoRoutersTable
|
||||
routers={routers}
|
||||
isLoading={isLoading}
|
||||
canModify={canCreate}
|
||||
onRouterClick={(row) => openModel(row.id)}
|
||||
onDeleteClick={setDeletingRouter}
|
||||
/>
|
||||
|
||||
<Dialog open={isCreating} onOpenChange={setIsCreating}>
|
||||
{/* The form is long, so the dialog caps its height and scrolls its body rather than
|
||||
growing past the viewport. */}
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Auto Router</DialogTitle>
|
||||
<DialogDescription>
|
||||
Routes each request to a model by classifying its complexity. Called like any other model, so clients keep
|
||||
using a single model name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddAutoRouterTab
|
||||
handleOk={handleCreated}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
createScope={createScope}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingRouter && (
|
||||
<DeleteResourceModal
|
||||
isOpen
|
||||
title="Delete Auto Router"
|
||||
message={`Are you sure you want to delete "${deletingRouter.name}"? Any client still calling this model name will start failing.`}
|
||||
resourceInformationTitle="Auto router"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: deletingRouter.name },
|
||||
{ label: "Type", value: deletingRouter.typeLabel },
|
||||
{ label: "ID", value: deletingRouter.id },
|
||||
]}
|
||||
onCancel={() => setDeletingRouter(null)}
|
||||
onOk={handleConfirmDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { AutoRouterIcon } from "@/components/shared/table_cells";
|
||||
|
||||
import { getAutoRoutersTableColumns } from "./AutoRoutersTableColumns";
|
||||
import { AutoRouterRow } from "./autoRouterRows";
|
||||
|
||||
interface AutoRoutersTableProps {
|
||||
routers: AutoRouterRow[];
|
||||
isLoading: boolean;
|
||||
canModify: boolean;
|
||||
onRouterClick: (row: AutoRouterRow) => void;
|
||||
onDeleteClick: (row: AutoRouterRow) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50];
|
||||
|
||||
function EmptyState({ canModify }: { canModify: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<AutoRouterIcon size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No auto routers yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{canModify
|
||||
? "Create an auto router to pick the right model per request instead of pinning one."
|
||||
: "An auto router picks the right model per request instead of pinning one."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoRoutersTable({
|
||||
routers,
|
||||
isLoading,
|
||||
canModify,
|
||||
onRouterClick,
|
||||
onDeleteClick,
|
||||
}: AutoRoutersTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }),
|
||||
[canModify, onRouterClick, onDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={routers}
|
||||
columns={columns}
|
||||
getRowId={(router) => router.id}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
paginationMode="client"
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading auto routers…"
|
||||
noDataMessage={<EmptyState canModify={canModify} />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { AutoRouterRow } from "./autoRouterRows";
|
||||
import { fitPills } from "./fitPills";
|
||||
|
||||
function TypeCell({ row }: { row: AutoRouterRow }) {
|
||||
return (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{row.typeLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsCell({ targets }: { targets: string[] }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const node = containerRef.current;
|
||||
if (!node || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const measured = entries[0]?.contentRect.width;
|
||||
if (typeof measured === "number") setWidth(measured);
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { visible, overflow } = useMemo(() => fitPills(targets, width), [targets, width]);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
||||
{visible.map((target) => (
|
||||
<Badge key={target} variant="secondary" className="max-w-full shrink truncate font-normal">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground" title={targets.join(", ")}>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoRouterRowActions({
|
||||
row,
|
||||
onDeleteClick,
|
||||
}: {
|
||||
row: AutoRouterRow;
|
||||
onDeleteClick: (row: AutoRouterRow) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open actions for ${row.name}`}
|
||||
data-testid={`auto-router-actions-${row.id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="auto-router-action-delete"
|
||||
onClick={() => onDeleteClick(row)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete auto router
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoRoutersTableColumnsDeps {
|
||||
canModify: boolean;
|
||||
onRouterClick: (row: AutoRouterRow) => void;
|
||||
onDeleteClick: (row: AutoRouterRow) => void;
|
||||
}
|
||||
|
||||
export const getAutoRoutersTableColumns = ({
|
||||
canModify,
|
||||
onRouterClick,
|
||||
onDeleteClick,
|
||||
}: AutoRoutersTableColumnsDeps): ColumnDef<AutoRouterRow>[] => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <IdentityCell title={row.original.name || "-"} onClick={() => onRouterClick(row.original)} />,
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
meta: { title: "Type" },
|
||||
header: "Type",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TypeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "targets",
|
||||
meta: { title: "Routes to" },
|
||||
header: "Routes to",
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TargetsCell targets={row.original.targets} />,
|
||||
},
|
||||
{
|
||||
id: "defaultModel",
|
||||
accessorKey: "defaultModel",
|
||||
meta: { title: "Default model" },
|
||||
header: "Default model",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.defaultModel ? (
|
||||
<Badge variant="secondary" className="max-w-full truncate font-normal" title={row.original.defaultModel}>
|
||||
{row.original.defaultModel}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => <DateCell value={row.original.createdAt} precision="date" />,
|
||||
},
|
||||
...(canModify
|
||||
? [
|
||||
{
|
||||
id: "actions",
|
||||
meta: { title: "" },
|
||||
header: "",
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.canDelete ? <AutoRouterRowActions row={row.original} onDeleteClick={onDeleteClick} /> : null,
|
||||
} satisfies ColumnDef<AutoRouterRow>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies";
|
||||
import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
|
||||
|
||||
// Existing cases assert resource classification, so they run as a proxy admin: the actor
|
||||
// gate is then a pass-through and canEdit/canDelete still reflect the row itself.
|
||||
const ADMIN = { userRole: "Admin", userID: "u-admin" };
|
||||
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
|
||||
|
||||
const complexityDeployment = {
|
||||
model_name: "tri-tier-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: {
|
||||
tiers: {
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
MEDIUM: ["anthropic-sonnet-4-6"],
|
||||
COMPLEX: ["anthropic-opus-4-6", "gpt-4o-mini"],
|
||||
REASONING: [],
|
||||
},
|
||||
classifier_type: "heuristic",
|
||||
},
|
||||
complexity_router_default_model: "gpt-4o-mini",
|
||||
},
|
||||
model_info: { id: "cid-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
|
||||
};
|
||||
|
||||
const semanticDeployment = {
|
||||
model_name: "support-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/support-router",
|
||||
auto_router_config: JSON.stringify({
|
||||
routes: [
|
||||
{ name: "gpt-4o-mini", utterances: ["reset my password"] },
|
||||
{ name: "anthropic-opus-4-6", utterances: ["design a distributed system"] },
|
||||
],
|
||||
}),
|
||||
auto_router_default_model: "gpt-4o-mini",
|
||||
},
|
||||
model_info: { id: "sid-1", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
|
||||
};
|
||||
|
||||
describe("autoRouterRows", () => {
|
||||
it("classifies a complexity router and unions its tier models as targets", () => {
|
||||
const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);
|
||||
|
||||
expect(row.kind).toBe("complexity");
|
||||
expect(row.typeLabel).toBe("Heuristic");
|
||||
// Union across tiers, de-duplicated: gpt-4o-mini appears in both SIMPLE and COMPLEX.
|
||||
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6", "anthropic-opus-4-6"]);
|
||||
expect(row.defaultModel).toBe("gpt-4o-mini");
|
||||
expect(row.id).toBe("cid-1");
|
||||
});
|
||||
|
||||
it("parses a semantic router whose config arrives as a JSON string", () => {
|
||||
const row = toAutoRouterRow(semanticDeployment, 0, ADMIN, null);
|
||||
|
||||
expect(row.kind).toBe("semantic");
|
||||
expect(row.typeLabel).toBe("Semantic");
|
||||
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-opus-4-6"]);
|
||||
expect(row.defaultModel).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("shows a tier pinned as a bare string, which the backend accepts as `str | list[str]`", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
...complexityDeployment,
|
||||
litellm_params: {
|
||||
...complexityDeployment.litellm_params,
|
||||
complexity_router_config: {
|
||||
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: ["anthropic-sonnet-4-6"], COMPLEX: "", REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
},
|
||||
},
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
it("labels a router using the LLM classifier", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
...complexityDeployment,
|
||||
litellm_params: {
|
||||
...complexityDeployment.litellm_params,
|
||||
complexity_router_config: { tiers: {}, classifier_type: "llm", adaptive: true },
|
||||
},
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.typeLabel).toBe("LLM Classifier");
|
||||
});
|
||||
|
||||
it("treats a deployment carrying complexity_router_config as complexity even off the canonical model string", () => {
|
||||
expect(isComplexityRouter({ model: "auto_router/legacy", complexity_router_config: { tiers: {} } })).toBe(true);
|
||||
});
|
||||
|
||||
it("survives an unparseable config instead of throwing", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
model_name: "broken",
|
||||
litellm_params: { model: "auto_router/broken", auto_router_config: "{not json" },
|
||||
model_info: { id: "bid-1" },
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.kind).toBe("semantic");
|
||||
expect(row.targets).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to a stable synthetic id when the deployment has no model_info id", () => {
|
||||
const rows = toAutoRouterRows(
|
||||
[
|
||||
{ model_name: "a", litellm_params: { model: "auto_router/a" } },
|
||||
{ model_name: "b", litellm_params: { model: "auto_router/b" } },
|
||||
],
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["a-0", "b-1"]);
|
||||
});
|
||||
// Regression: adaptive and quality routers used to fall through to the semantic branch,
|
||||
// which read the wrong config key and reported an empty route list and a null default.
|
||||
it("classifies an adaptive router as adaptive, not semantic", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
model_name: "smart-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/adaptive_router",
|
||||
adaptive_router_default_model: "gpt-4o-mini",
|
||||
adaptive_router_config: { available_models: ["gpt-4o", "gpt-4o-mini"] },
|
||||
},
|
||||
model_info: { id: "ad-1" },
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.kind).toBe("adaptive");
|
||||
expect(row.typeLabel).toBe("Adaptive");
|
||||
expect(row.targets).toEqual(["gpt-4o", "gpt-4o-mini"]);
|
||||
expect(row.defaultModel).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("classifies a quality router as quality, not semantic", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
model_name: "quality-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/quality_router",
|
||||
quality_router_default_model: "gpt-4o",
|
||||
quality_router_config: { available_models: ["gpt-4o"] },
|
||||
},
|
||||
model_info: { id: "q-1" },
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.kind).toBe("quality");
|
||||
expect(row.typeLabel).toBe("Quality");
|
||||
expect(row.targets).toEqual(["gpt-4o"]);
|
||||
});
|
||||
|
||||
it("mirrors the backend prefix ordering, so a named strategy never reads as semantic", () => {
|
||||
const kindOf = (model: string) => autoRouterStrategy({ model }).kind;
|
||||
expect(kindOf("auto_router/complexity_router")).toBe("complexity");
|
||||
expect(kindOf("auto_router/adaptive_router")).toBe("adaptive");
|
||||
expect(kindOf("auto_router/quality_router")).toBe("quality");
|
||||
expect(kindOf("auto_router/my-own-router")).toBe("semantic");
|
||||
});
|
||||
|
||||
// The capability matrix. Origin and strategy constrain DIFFERENT capabilities, and
|
||||
// collapsing them into one "editable" flag is what stranded DB-created adaptive routers
|
||||
// with no delete control. Live-verified: for a config row PATCH /model/{id}/update 404s
|
||||
// and POST /model/delete 400s.
|
||||
const rowFor = (model: string, dbModel: boolean) =>
|
||||
toAutoRouterRow(
|
||||
{ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } },
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ model: "auto_router/complexity_router", db: true, canEdit: true, canDelete: true, reason: null },
|
||||
{ model: "auto_router/my-semantic", db: true, canEdit: true, canDelete: true, reason: null },
|
||||
// No editor for its shape, but deleting never reads the config, so delete stays.
|
||||
{ model: "auto_router/adaptive_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
|
||||
{ model: "auto_router/quality_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
|
||||
// config.yaml rows: the API refuses both, whatever the strategy.
|
||||
{ model: "auto_router/complexity_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
|
||||
{ model: "auto_router/adaptive_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
|
||||
])("$model (db_model=$db) -> canEdit=$canEdit canDelete=$canDelete", (spec) => {
|
||||
const row = rowFor(spec.model, spec.db);
|
||||
expect(row.canEdit).toBe(spec.canEdit);
|
||||
expect(row.canDelete).toBe(spec.canDelete);
|
||||
expect(row.editBlockedReason).toBe(spec.reason);
|
||||
});
|
||||
|
||||
it("treats a missing db_model as config-defined rather than assuming it is writable", () => {
|
||||
const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0, ADMIN, null);
|
||||
expect(row.canEdit).toBe(false);
|
||||
expect(row.canDelete).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoRouterRows actor gating", () => {
|
||||
const TEAMS = [
|
||||
{ team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] },
|
||||
] as never;
|
||||
|
||||
const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) =>
|
||||
toAutoRouterRow(
|
||||
{ ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } },
|
||||
0,
|
||||
actor,
|
||||
TEAMS,
|
||||
);
|
||||
|
||||
// Opening the tab to team admins puts rows they cannot act on in the same list: other
|
||||
// teams' routers, and the proxy-level unscoped ones. PATCH and DELETE both 403 those, so
|
||||
// the affordance has to be per row rather than per tab.
|
||||
it("hides write affordances on another team's router", () => {
|
||||
const row = rowIn(TEAM_ADMIN, "other-team");
|
||||
expect(row.canEdit).toBe(false);
|
||||
expect(row.canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it("hides them on an unscoped router a proxy admin owns", () => {
|
||||
const row = rowIn(TEAM_ADMIN, null);
|
||||
expect(row.canEdit).toBe(false);
|
||||
expect(row.canDelete).toBe(false);
|
||||
});
|
||||
|
||||
// Authorizing on created_by would fail this: the API lets any admin of the owning team act.
|
||||
it("keeps them on the team's router regardless of who created it", () => {
|
||||
const row = rowIn(TEAM_ADMIN, "team-1");
|
||||
expect(row.canEdit).toBe(true);
|
||||
expect(row.canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a proxy admin act on any team's router", () => {
|
||||
const row = rowIn(ADMIN, "other-team");
|
||||
expect(row.canEdit).toBe(true);
|
||||
expect(row.canDelete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import {
|
||||
AutoRouterKind,
|
||||
EditBlockedReason,
|
||||
autoRouterCapabilities,
|
||||
autoRouterStrategy,
|
||||
} from "@/components/add_model/auto_router_strategies";
|
||||
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
|
||||
import { Team } from "@/components/networking";
|
||||
import { type ModelActor, canModifyModel } from "@/utils/modelPermissions";
|
||||
|
||||
export type { AutoRouterKind };
|
||||
|
||||
/** Who is looking at the list; decides which rows offer write affordances. */
|
||||
export type AutoRouterActor = ModelActor;
|
||||
|
||||
export interface AutoRouterRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: AutoRouterKind;
|
||||
typeLabel: string;
|
||||
/** Edit needs an API-created row AND a strategy the dashboard has a form for. */
|
||||
canEdit: boolean;
|
||||
/**
|
||||
* Resource capability ANDed with the caller's standing on this specific row. A team admin
|
||||
* sees rows they cannot delete (another team's, or one a teammate created), and the API
|
||||
* would 403 those, so the affordance has to be per row rather than per tab.
|
||||
*/
|
||||
canDelete: boolean;
|
||||
editBlockedReason: EditBlockedReason | null;
|
||||
targets: string[];
|
||||
defaultModel: string | null;
|
||||
createdAt: string | null;
|
||||
deployment: AutoRouterDeployment;
|
||||
}
|
||||
|
||||
const safeParse = (value: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => {
|
||||
const parsed: unknown = typeof value === "string" ? safeParse(value) : value;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
};
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
|
||||
const dedupe = (models: string[]): string[] => Array.from(new Set(models));
|
||||
|
||||
export const complexityTypeLabel = (config: Record<string, unknown>): string =>
|
||||
config.classifier_type === "llm" ? "LLM Classifier" : "Heuristic";
|
||||
|
||||
interface Presentation {
|
||||
typeLabel: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
// Adaptive and quality both declare a flat pool and have no editor here, so the row reports
|
||||
// what is configured rather than interpreting it.
|
||||
const configManaged = (label: string, config: Record<string, unknown>): Presentation => ({
|
||||
typeLabel: label,
|
||||
targets: asStringArray(config.available_models),
|
||||
});
|
||||
|
||||
/** How each strategy renders itself, given its own config object. */
|
||||
const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Presentation> = {
|
||||
complexity: (config) => ({
|
||||
typeLabel: complexityTypeLabel(config),
|
||||
targets: dedupe(Object.values(asRecord(config.tiers)).flatMap(normalizeTierModels)),
|
||||
}),
|
||||
semantic: (config) => {
|
||||
const routes = dedupe(
|
||||
(Array.isArray(config.routes) ? config.routes : [])
|
||||
.map((route) => asRecord(route).name)
|
||||
.filter((name): name is string => typeof name === "string" && name.length > 0),
|
||||
);
|
||||
return { typeLabel: "Semantic", targets: routes };
|
||||
},
|
||||
adaptive: (config) => configManaged("Adaptive", config),
|
||||
quality: (config) => configManaged("Quality", config),
|
||||
};
|
||||
|
||||
export const toAutoRouterRow = (
|
||||
deployment: AutoRouterDeployment,
|
||||
index: number,
|
||||
actor: AutoRouterActor,
|
||||
teams: Team[] | null,
|
||||
): AutoRouterRow => {
|
||||
const params = deployment.litellm_params ?? {};
|
||||
const info = deployment.model_info ?? {};
|
||||
const name = deployment.model_name ?? "";
|
||||
const strategy = autoRouterStrategy(params);
|
||||
const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info);
|
||||
const mayActOnRow = canModifyModel(actor, teams, { teamId: info.team_id, isDbModel: info.db_model === true });
|
||||
|
||||
return {
|
||||
id: info.id ?? `${name}-${index}`,
|
||||
name,
|
||||
kind: strategy.kind,
|
||||
canEdit: canEdit && mayActOnRow,
|
||||
canDelete: canDelete && mayActOnRow,
|
||||
editBlockedReason,
|
||||
createdAt: info.created_at ?? null,
|
||||
defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
|
||||
deployment,
|
||||
...PRESENTERS[strategy.kind](asRecord(params[strategy.configKey])),
|
||||
};
|
||||
};
|
||||
|
||||
export const toAutoRouterRows = (
|
||||
deployments: AutoRouterDeployment[],
|
||||
actor: AutoRouterActor,
|
||||
teams: Team[] | null,
|
||||
): AutoRouterRow[] => deployments.map((deployment, index) => toAutoRouterRow(deployment, index, actor, teams));
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fitPills, pillWidth } from "./fitPills";
|
||||
|
||||
const TARGETS = ["anthropic-sonnet-4-6", "gpt-4o-mini", "anthropic-opus-4-6", "voyage-4-large"];
|
||||
|
||||
describe("fitPills", () => {
|
||||
it("keeps everything on one row when it all fits", () => {
|
||||
const wide = TARGETS.reduce((total, label) => total + pillWidth(label) + 4, 0) + 40;
|
||||
expect(fitPills(TARGETS, wide)).toEqual({ visible: TARGETS, overflow: 0 });
|
||||
});
|
||||
|
||||
it("shows more pills as the column gets wider", () => {
|
||||
const narrow = fitPills(TARGETS, 200);
|
||||
const wider = fitPills(TARGETS, 420);
|
||||
|
||||
expect(narrow.visible.length).toBeLessThan(wider.visible.length);
|
||||
expect(narrow.visible.length + narrow.overflow).toBe(TARGETS.length);
|
||||
expect(wider.visible.length + wider.overflow).toBe(TARGETS.length);
|
||||
});
|
||||
|
||||
it("reserves room for the +N counter so the row never overflows", () => {
|
||||
const { visible } = fitPills(TARGETS, 220);
|
||||
const used = visible.reduce((total, label, index) => total + pillWidth(label) + (index === 0 ? 0 : 4), 0);
|
||||
// 28px counter + its 4px gap must still fit alongside the visible pills.
|
||||
expect(used + 32).toBeLessThanOrEqual(220);
|
||||
});
|
||||
|
||||
it("always shows at least one pill, even when a single name is wider than the column", () => {
|
||||
expect(fitPills(["an-extremely-long-deployment-name-that-never-fits"], 40)).toEqual({
|
||||
visible: ["an-extremely-long-deployment-name-that-never-fits"],
|
||||
overflow: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows one pill before the first measurement rather than flashing every pill", () => {
|
||||
expect(fitPills(TARGETS, 0)).toEqual({ visible: [TARGETS[0]], overflow: 3 });
|
||||
});
|
||||
|
||||
it("handles an empty target list", () => {
|
||||
expect(fitPills([], 300)).toEqual({ visible: [], overflow: 0 });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* How many pills fit on ONE row of a given width, leaving room for a "+N" counter.
|
||||
*
|
||||
* jsdom reports no layout and the shared ResizeObserver mock only fires inside chart
|
||||
* subtrees, so this stays a pure width-in / count-out function: the component measures and
|
||||
* this decides, which keeps the overflow rule unit-testable.
|
||||
*/
|
||||
|
||||
const CHAR_WIDTH = 6.5;
|
||||
const PILL_PADDING = 18;
|
||||
const PILL_GAP = 4;
|
||||
const OVERFLOW_WIDTH = 28;
|
||||
|
||||
export const pillWidth = (label: string): number => label.length * CHAR_WIDTH + PILL_PADDING;
|
||||
|
||||
export interface FittedPills {
|
||||
visible: string[];
|
||||
overflow: number;
|
||||
}
|
||||
|
||||
export const fitPills = (labels: string[], availableWidth: number): FittedPills => {
|
||||
if (labels.length === 0) return { visible: [], overflow: 0 };
|
||||
|
||||
// Unmeasured (0 or negative) means the first paint before ResizeObserver reports. Show one
|
||||
// pill rather than all of them, so the row never flashes multi-line and then collapses.
|
||||
if (availableWidth <= 0) {
|
||||
return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
|
||||
}
|
||||
|
||||
const fitted: string[] = [];
|
||||
let used = 0;
|
||||
|
||||
for (const [index, label] of labels.entries()) {
|
||||
const remaining = labels.length - index - 1;
|
||||
const gap = fitted.length === 0 ? 0 : PILL_GAP;
|
||||
// Anything still queued after this pill needs room for the "+N" counter beside it.
|
||||
const reserve = remaining > 0 ? PILL_GAP + OVERFLOW_WIDTH : 0;
|
||||
|
||||
if (used + gap + pillWidth(label) + reserve > availableWidth) break;
|
||||
|
||||
used += gap + pillWidth(label);
|
||||
fitted.push(label);
|
||||
}
|
||||
|
||||
// Always show at least one pill; a single over-long name truncates via CSS instead of
|
||||
// collapsing the cell to a bare "+N".
|
||||
if (fitted.length === 0) {
|
||||
return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
|
||||
}
|
||||
|
||||
return { visible: fitted, overflow: labels.length - fitted.length };
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ import ModelsAndEndpointsPage from "./page";
|
|||
|
||||
vi.mock("./panels/AllModelsPanel", () => ({ default: () => <div data-testid="panel-all-models" /> }));
|
||||
vi.mock("./panels/AddModelPanel", () => ({ default: () => <div data-testid="panel-add" /> }));
|
||||
vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () => <div data-testid="panel-auto-routers" /> }));
|
||||
vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () => <div data-testid="panel-credentials" /> }));
|
||||
vi.mock("./panels/PassThroughPanel", () => ({ default: () => <div data-testid="panel-pass-through" /> }));
|
||||
vi.mock("./panels/HealthStatusPanel", () => ({ default: () => <div data-testid="panel-health" /> }));
|
||||
|
|
@ -97,4 +98,34 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
expect(queryByRole("tab", { name: "LLM Credentials" })).toBeNull();
|
||||
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
|
||||
});
|
||||
|
||||
// Auto-routers are excluded from the All Models table, so this tab is their home: the only
|
||||
// place in the product to list, create, edit or delete one.
|
||||
describe("Auto-Routers tab", () => {
|
||||
it("sits third, after All Models and Add Model", () => {
|
||||
const { getAllByRole } = renderPage();
|
||||
|
||||
const tabs = getAllByRole("tab").map((tab) => tab.textContent);
|
||||
expect(tabs[0]).toContain("All Models");
|
||||
expect(tabs[1]).toBe("Add Model");
|
||||
expect(tabs[2]).toContain("Auto-Routers");
|
||||
// Badged Beta while the tab settles; BetaBadge renders the label text.
|
||||
expect(tabs[2]).toContain("Beta");
|
||||
});
|
||||
|
||||
it("renders its panel when selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, getByTestId } = renderPage();
|
||||
|
||||
await user.click(getByRole("tab", { name: /Auto-Routers/ }));
|
||||
expect(getByTestId("panel-auto-routers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden from non-admins, who cannot write models", () => {
|
||||
mockUseAuthorized.mockReturnValue(NON_ADMIN);
|
||||
const { queryByRole } = renderPage();
|
||||
|
||||
expect(queryByRole("tab", { name: /Auto-Routers/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,13 +7,16 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
import { canCreateModels } from "@/utils/modelPermissions";
|
||||
import BetaBadge from "@/components/BetaBadge";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel";
|
||||
import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel";
|
||||
import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel";
|
||||
import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel";
|
||||
import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel";
|
||||
|
|
@ -24,6 +27,7 @@ import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceD
|
|||
|
||||
type ModelTabSlug =
|
||||
| "add"
|
||||
| "auto-routers"
|
||||
| "llm-credentials"
|
||||
| "pass-through"
|
||||
| "health"
|
||||
|
|
@ -35,6 +39,7 @@ const BASE_TAB_KEY = "all-models";
|
|||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"auto-routers": "Auto-Routers",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
|
|
@ -47,6 +52,8 @@ const renderPanel = (key: string) => {
|
|||
switch (key) {
|
||||
case BASE_TAB_KEY:
|
||||
return <AllModelsPanel />;
|
||||
case "auto-routers":
|
||||
return <AutoRoutersTabPanel />;
|
||||
case "add":
|
||||
return <AddModelPanel />;
|
||||
case "llm-credentials":
|
||||
|
|
@ -77,31 +84,48 @@ export default function ModelsAndEndpointsPage() {
|
|||
const [activeKey, setActiveKey] = useState<string>(BASE_TAB_KEY);
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
const canCreate = canCreateModels(
|
||||
{ userRole, userID },
|
||||
{
|
||||
teams: teams ?? null,
|
||||
disabledForInternalUsers:
|
||||
isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true,
|
||||
},
|
||||
);
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
|
||||
() => [
|
||||
"",
|
||||
...(shouldHideAddModelTab ? [] : (["add"] as const)),
|
||||
...(canCreate ? (["add"] as const) : []),
|
||||
...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
|
||||
...(isAdmin
|
||||
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
|
||||
: []),
|
||||
],
|
||||
[shouldHideAddModelTab, isAdmin],
|
||||
[canCreate, isAdmin],
|
||||
);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
// Auto-Routers carries a Beta badge; BetaBadge honours the admin setting that hides these.
|
||||
const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => {
|
||||
if (!slug) return allModelsLabel;
|
||||
if (slug === "auto-routers") {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{TAB_LABELS[slug]} <BetaBadge />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return TAB_LABELS[slug];
|
||||
};
|
||||
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
const key = slug || BASE_TAB_KEY;
|
||||
return {
|
||||
key,
|
||||
label: slug ? TAB_LABELS[slug] : allModelsLabel,
|
||||
label: tabLabel(slug),
|
||||
children: key === activeKey ? renderPanel(key) : null,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Form } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import AddModelTab from "@/components/add_model/add_model_tab";
|
||||
import AddModelForm from "@/components/add_model/AddModelForm";
|
||||
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
|
||||
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
|
@ -14,7 +14,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function AddModelPanel() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
|
|
@ -39,7 +39,7 @@ export default function AddModelPanel() {
|
|||
};
|
||||
|
||||
return (
|
||||
<AddModelTab
|
||||
<AddModelForm
|
||||
form={form}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
|
|
@ -52,8 +52,6 @@ export default function AddModelPanel() {
|
|||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams ?? null}
|
||||
credentials={credentialsResponse?.credentials || []}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { internalUserRoles } from "@/utils/roles";
|
||||
import { modelCreationScope } from "@/utils/modelPermissions";
|
||||
|
||||
import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
|
||||
|
||||
/**
|
||||
* Owns the permission decision for the Auto-Routers tab so the panel stays a renderer.
|
||||
* Creating an auto router is a POST /model/new, the same endpoint Add Model posts to, so it
|
||||
* takes the same audience rule: a proxy admin, or a team admin who scopes it to a team.
|
||||
* Viewer roles reach the list without write affordances.
|
||||
*/
|
||||
export default function AutoRoutersTabPanel() {
|
||||
const { accessToken, userRole, userId: userID } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
|
||||
const isInternalUser = userRole != null && internalUserRoles.includes(userRole);
|
||||
const scope = modelCreationScope(
|
||||
{ userRole, userID },
|
||||
{
|
||||
teams: teams ?? null,
|
||||
disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoRoutersPanel
|
||||
accessToken={accessToken}
|
||||
userRole={userRole ?? ""}
|
||||
userID={userID ?? null}
|
||||
teams={teams ?? null}
|
||||
createScope={scope}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ vi.mock("@/components/networking", () => ({
|
|||
vi.mock("@/components/router_settings", () => ({ default: () => null }));
|
||||
vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null }));
|
||||
vi.mock("@/components/routing_groups", () => ({ default: () => null }));
|
||||
|
||||
// Mirrors the /config/list ordering: the two prompt-caching rows sit between the
|
||||
// General-tab rows in the unfiltered response but are filtered out of the General
|
||||
// tab's table, so any index-based lookup into the unfiltered array reads the wrong
|
||||
|
|
@ -99,3 +98,19 @@ describe("GeneralSettings General tab", () => {
|
|||
expect(within(row).getByRole("spinbutton")).toHaveValue("1.00");
|
||||
});
|
||||
});
|
||||
|
||||
// The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints.
|
||||
describe("GeneralSettings tabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders the proxy-wide tabs and no auto-router tab", async () => {
|
||||
renderWithProviders(<GeneralSettings accessToken="token" userRole="proxy_admin" userID="u" />);
|
||||
|
||||
for (const name of ["Loadbalancing", "Routing Groups", "Fallbacks", "Prompt Caching", "General"]) {
|
||||
expect(await screen.findByRole("tab", { name })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByRole("tab", { name: /auto.?router/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
Icon,
|
||||
Switch,
|
||||
} from "@tremor/react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
|
||||
import { InputNumber, Select as AntdSelect } from "antd";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
|
|
@ -232,82 +232,80 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<TabGroup className="h-[75vh] w-full">
|
||||
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
|
||||
<Tab value="1">Loadbalancing</Tab>
|
||||
<Tab value="2">Routing Groups</Tab>
|
||||
<Tab value="3">Fallbacks</Tab>
|
||||
<Tab value="5">Prompt Caching</Tab>
|
||||
<Tab value="4">General</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-8 py-6">
|
||||
<TabPanel>
|
||||
<RouterSettings accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<RoutingGroups />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<PromptCachingPanel accessToken={accessToken} settings={generalSettings} onChange={handleInputChange} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Setting</TableHeaderCell>
|
||||
<TableHeaderCell>Value</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{generalSettings
|
||||
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
|
||||
.map((value, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text>{value.field_name}</Text>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "#808080",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
className="mt-1"
|
||||
>
|
||||
{value.field_description}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SettingValueEditor setting={value} onChange={handleInputChange} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
<StatusBadge tone="success" label="In DB" />
|
||||
) : value.stored_in_db == false ? (
|
||||
<StatusBadge tone="neutral" label="In Config" />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Not Set" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
|
||||
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
|
||||
Reset
|
||||
</Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
<Tabs defaultValue="loadbalancing" className="h-[75vh] w-full">
|
||||
<TabsList variant="line" className="mx-8 mt-4">
|
||||
<TabsTrigger value="loadbalancing">Loadbalancing</TabsTrigger>
|
||||
<TabsTrigger value="routing-groups">Routing Groups</TabsTrigger>
|
||||
<TabsTrigger value="fallbacks">Fallbacks</TabsTrigger>
|
||||
<TabsTrigger value="prompt-caching">Prompt Caching</TabsTrigger>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="loadbalancing" className="px-8 py-6">
|
||||
<RouterSettings accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabsContent>
|
||||
<TabsContent value="routing-groups" className="px-8 py-6">
|
||||
<RoutingGroups />
|
||||
</TabsContent>
|
||||
<TabsContent value="fallbacks" className="px-8 py-6">
|
||||
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabsContent>
|
||||
<TabsContent value="prompt-caching" className="px-8 py-6">
|
||||
<PromptCachingPanel accessToken={accessToken} settings={generalSettings} onChange={handleInputChange} />
|
||||
</TabsContent>
|
||||
<TabsContent value="general" className="px-8 py-6">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Setting</TableHeaderCell>
|
||||
<TableHeaderCell>Value</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{generalSettings
|
||||
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
|
||||
.map((value, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text>{value.field_name}</Text>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "#808080",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
className="mt-1"
|
||||
>
|
||||
{value.field_description}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SettingValueEditor setting={value} onChange={handleInputChange} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
<StatusBadge tone="success" label="In DB" />
|
||||
) : value.stored_in_db == false ? (
|
||||
<StatusBadge tone="neutral" label="In Config" />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Not Set" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
|
||||
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
|
||||
Reset
|
||||
</Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -297,4 +297,17 @@ describe("AddModelForm", () => {
|
|||
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provider field and the Test Connect / Add Model buttons", async () => {
|
||||
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
|
||||
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
|
||||
|
||||
const props = createTestProps();
|
||||
|
||||
renderWithProviders(<AddModelForm {...props} />);
|
||||
|
||||
expect(await screen.findByText("Provider")).toBeInTheDocument();
|
||||
expect((await screen.findAllByRole("button", { name: "Test Connect" })).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProvider
|
|||
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
|
||||
import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
|
||||
import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { modelCreationScope } from "@/utils/modelPermissions";
|
||||
import { Switch, Text } from "@tremor/react";
|
||||
import type { FormInstance } from "antd";
|
||||
import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd";
|
||||
|
|
@ -101,6 +102,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId);
|
||||
// Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about
|
||||
// who has to name a team. This form is only reachable when creation is allowed at all.
|
||||
const createScope = modelCreationScope({ userRole, userID: userId }, { teams, disabledForInternalUsers: false });
|
||||
const requiresTeamScope = createScope === "team-required";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -120,7 +125,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
labelAlign="left"
|
||||
>
|
||||
<>
|
||||
{isTeamAdmin && !isAdmin && (
|
||||
{requiresTeamScope && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Select Team"
|
||||
|
|
@ -307,7 +312,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
)}
|
||||
|
||||
{/* Conditional Team Selection */}
|
||||
{isTeamOnly && (isAdmin || !isTeamAdmin) && (
|
||||
{isTeamOnly && !requiresTeamScope && (
|
||||
<Form.Item
|
||||
label="Select Team"
|
||||
name="team_id"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import { Form } from "antd";
|
||||
import AddAutoRouterTab from "./add_auto_router_tab";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { getMissingTiersError } from "./build_complexity_router_config";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
|
|
@ -21,12 +22,36 @@ vi.mock("../molecules/notifications_manager", () => ({
|
|||
default: { fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
const Harness = () => {
|
||||
const [form] = Form.useForm();
|
||||
return <AddAutoRouterTab form={form} handleOk={vi.fn()} accessToken="token" userRole="Admin" />;
|
||||
};
|
||||
// Kept real by default so the "mandatory field" test still sees genuine tier validation; one
|
||||
// test overrides it to reach the submit path without driving four tier selects.
|
||||
vi.mock("./build_complexity_router_config", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./build_complexity_router_config")>();
|
||||
return { ...actual, getMissingTiersError: vi.fn(actual.getMissingTiersError) };
|
||||
});
|
||||
|
||||
// A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is
|
||||
// whether team_id is registered, validated and forwarded, so a plain control stands in.
|
||||
vi.mock("../common_components/team_dropdown", () => ({
|
||||
default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => (
|
||||
<select
|
||||
data-testid="team-dropdown"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
aria-label="Select Team"
|
||||
>
|
||||
<option value="">none</option>
|
||||
<option value="team-1">team-1</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
const Harness = () => <AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Admin" />;
|
||||
|
||||
describe("AddAutoRouterTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Harness />);
|
||||
|
|
@ -37,4 +62,53 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(screen.getAllByText("This tier is required")).toHaveLength(4);
|
||||
expect(NotificationManager.fromBackend).toHaveBeenCalledWith("Please enter an Auto Router Name");
|
||||
});
|
||||
|
||||
it("offers no team selector to a proxy admin, who may create an unscoped router", () => {
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
expect(screen.queryByTestId("team-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires a team admin to pick a team", async () => {
|
||||
renderWithProviders(
|
||||
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// POST /model/new 403s an unscoped create from a non-proxy-admin, so a selected team that
|
||||
// never reaches the payload is indistinguishable from having no selector at all. The value
|
||||
// has to survive form.validateFields, which only returns the fields it is asked for.
|
||||
it("carries the selected team through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(
|
||||
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
|
||||
await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1");
|
||||
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({ team_id: "team-1" });
|
||||
});
|
||||
|
||||
it("blocks the submit when a team admin has not picked a team", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(
|
||||
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
|
||||
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space, Modal } from "antd";
|
||||
import type { FormInstance } from "antd";
|
||||
import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { modelAvailableCall } from "../networking";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "./RouterConfigBuilder";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
|
|
@ -22,27 +21,34 @@ import {
|
|||
getSemanticConfigError,
|
||||
} from "./build_complexity_router_config";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import { getSemanticRouterError } from "./build_semantic_router_validation";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
|
||||
interface AddAutoRouterTabProps {
|
||||
form: FormInstance;
|
||||
handleOk: () => void;
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
/**
|
||||
* How this caller must scope what they create. A team admin has to name a team, because
|
||||
* POST /model/new rejects an unscoped create from any non-proxy-admin; without the selector
|
||||
* their submit is a guaranteed 403.
|
||||
*/
|
||||
createScope?: ModelWriteScope;
|
||||
}
|
||||
|
||||
type RouterType = "recommended" | "semantic";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, accessToken, userRole }) => {
|
||||
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
||||
handleOk,
|
||||
accessToken,
|
||||
userRole,
|
||||
createScope = "unscoped-ok",
|
||||
}) => {
|
||||
const requiresTeamScope = createScope === "team-required";
|
||||
const [form] = Form.useForm();
|
||||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
|
||||
const [routerType, setRouterType] = useState<RouterType>("recommended");
|
||||
|
||||
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
|
|
@ -56,9 +62,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
|
||||
// Semantic router config (existing)
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
|
||||
const [isTestModalVisible, setIsTestModalVisible] = useState<boolean>(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
|
||||
const [connectionTestId, setConnectionTestId] = useState<number>(0);
|
||||
|
|
@ -133,7 +136,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
});
|
||||
|
||||
form
|
||||
.validateFields(["auto_router_name"])
|
||||
.validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
|
||||
.then((values) => {
|
||||
const complexityRouterConfigParams = {
|
||||
tiers,
|
||||
|
|
@ -169,40 +172,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
});
|
||||
};
|
||||
|
||||
const submitSemanticRouter = (name: string) => {
|
||||
const validationError = getSemanticRouterError({
|
||||
defaultModel: form.getFieldValue("auto_router_default_model"),
|
||||
embeddingModel: form.getFieldValue("auto_router_embedding_model"),
|
||||
routerConfig,
|
||||
});
|
||||
if (validationError) {
|
||||
NotificationManager.fromBackend(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
custom_llm_provider: "auto_router",
|
||||
model: name,
|
||||
api_key: "not_required_for_auto_router",
|
||||
});
|
||||
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const submitValues = {
|
||||
...values,
|
||||
auto_router_name: name,
|
||||
auto_router_config: routerConfig,
|
||||
model_type: "semantic_router",
|
||||
};
|
||||
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
NotificationManager.fromBackend("Please fill in all required fields");
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoRouterSubmit = () => {
|
||||
const name = form.getFieldValue("auto_router_name");
|
||||
if (!name) {
|
||||
|
|
@ -212,11 +181,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
return;
|
||||
}
|
||||
|
||||
if (routerType === "recommended") {
|
||||
submitRecommendedRouter(name);
|
||||
} else {
|
||||
submitSemanticRouter(name);
|
||||
}
|
||||
submitRecommendedRouter(name);
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
|
|
@ -239,53 +204,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>Add Auto Router</Title>
|
||||
<Text className="text-gray-600 mb-6">
|
||||
Create an auto router that automatically selects the best model based on request complexity or semantic
|
||||
matching. Use in place of a single default model.
|
||||
</Text>
|
||||
|
||||
<Card className="mb-4">
|
||||
<div className="mb-4">
|
||||
<Text className="text-sm font-medium mb-2 block">Router Type</Text>
|
||||
<Radio.Group
|
||||
value={routerType}
|
||||
onChange={(e) => {
|
||||
setRouterType(e.target.value);
|
||||
setShowValidationErrors(false);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Space direction="vertical" className="w-full">
|
||||
<Radio value="recommended" className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThunderboltOutlined className="text-yellow-500" />
|
||||
<span className="font-medium">Auto-Router v2</span>
|
||||
<Badge
|
||||
count="Recommended"
|
||||
style={{ backgroundColor: "#52c41a", fontSize: "10px", padding: "0 6px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ml-6 mt-1">
|
||||
Routes by request complexity across four tiers, with optional keyword-to-tier overrides and semantic
|
||||
keyword matching. No training data needed.
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="semantic" className="w-full mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BranchesOutlined className="text-blue-500" />
|
||||
<span className="font-medium">Semantic Router [to be deprecated]</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ml-6 mt-1">
|
||||
Routes based on semantic similarity to example utterances. Requires an embedding model and example
|
||||
utterances.
|
||||
</div>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
|
|
@ -305,74 +223,40 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
<TextInput placeholder="e.g., smart_router, auto_router_1" />
|
||||
</Form.Item>
|
||||
|
||||
{routerType === "recommended" ? (
|
||||
<div className="w-full mb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full mb-4">
|
||||
<RouterConfigBuilder
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
form.setFieldValue("auto_router_config", config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: "Default model is required" }]}
|
||||
label="Default Model"
|
||||
name="auto_router_default_model"
|
||||
tooltip="Fallback model to use when auto routing logic cannot determine the best model"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<AntdSelect
|
||||
placeholder="Select a default model"
|
||||
options={modelGroupOptions}
|
||||
style={{ width: "100%" }}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: "Embedding model is required" }]}
|
||||
label="Embedding Model"
|
||||
name="auto_router_embedding_model"
|
||||
tooltip="Embedding model to use for semantic routing decisions"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<AntdSelect
|
||||
placeholder="Select an embedding model"
|
||||
options={modelGroupOptions}
|
||||
style={{ width: "100%" }}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
{requiresTeamScope && (
|
||||
<Form.Item
|
||||
label="Select Team"
|
||||
name="team_id"
|
||||
rules={[{ required: true, message: "Please select a team to continue" }]}
|
||||
tooltip="Select the team this auto router belongs to. Only keys for this team will be able to call it."
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div className="w-full mb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-gray-200"></div>
|
||||
<span className="px-4 text-gray-500 text-sm">Additional Settings</span>
|
||||
|
|
@ -408,7 +292,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
{routerType === "recommended" && (
|
||||
{
|
||||
<Button
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
|
|
@ -416,7 +300,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
|
|||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, renderHook, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import type { UploadProps } from "antd/es/upload";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import type { CredentialItem } from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import AddModelTab from "./add_model_tab";
|
||||
|
||||
vi.mock("../molecules/models/ProviderLogo", () => ({
|
||||
ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => (
|
||||
<div className={className} data-testid={`provider-logo-${provider}`}>
|
||||
{provider}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", async () => {
|
||||
const actual = await vi.importActual("../networking");
|
||||
return {
|
||||
...actual,
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({
|
||||
guardrails: [{ guardrail_name: "test-guardrail-1" }, { guardrail_name: "test-guardrail-2" }],
|
||||
}),
|
||||
tagListCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({
|
||||
data: [{ id: "model-group-1" }, { id: "model-group-2" }],
|
||||
}),
|
||||
modelHubCall: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ model_group: "gpt-4", mode: "chat" },
|
||||
{ model_group: "gpt-3.5-turbo", mode: "chat" },
|
||||
],
|
||||
}),
|
||||
getProviderCreateMetadata: vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "OpenAI",
|
||||
provider_display_name: "OpenAI",
|
||||
litellm_provider: "openai",
|
||||
default_model_placeholder: "gpt-3.5-turbo",
|
||||
credential_fields: [],
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({
|
||||
useProviderFields: vi.fn().mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
provider: "OpenAI",
|
||||
provider_display_name: "OpenAI",
|
||||
litellm_provider: "openai",
|
||||
default_model_placeholder: "gpt-3.5-turbo",
|
||||
credential_fields: [],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn().mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createTestProps = () => {
|
||||
const { result } = renderHook(() => Form.useForm());
|
||||
const [form] = result.current;
|
||||
|
||||
const handleOk = vi.fn();
|
||||
const setSelectedProvider = vi.fn();
|
||||
const setProviderModelsFn = vi.fn();
|
||||
const getPlaceholder = vi.fn((provider: Providers) => `Enter ${provider} model name`);
|
||||
const setShowAdvancedSettings = vi.fn();
|
||||
|
||||
const selectedProvider = Providers.OpenAI;
|
||||
const providerModels = ["gpt-4", "gpt-3.5-turbo"];
|
||||
const showAdvancedSettings = false;
|
||||
|
||||
const teams: Team[] = [
|
||||
{
|
||||
team_id: "team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "monthly",
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const credentials: CredentialItem[] = [
|
||||
{
|
||||
credential_name: "test-credential",
|
||||
credential_values: {},
|
||||
credential_info: {
|
||||
custom_llm_provider: "openai",
|
||||
description: "Test credential",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: () => false,
|
||||
showUploadList: false,
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
handleOk,
|
||||
setSelectedProvider,
|
||||
setProviderModelsFn,
|
||||
getPlaceholder,
|
||||
setShowAdvancedSettings,
|
||||
selectedProvider,
|
||||
providerModels,
|
||||
showAdvancedSettings,
|
||||
teams,
|
||||
credentials,
|
||||
uploadProps,
|
||||
accessToken: "test-access-token",
|
||||
userRole: "Admin",
|
||||
};
|
||||
};
|
||||
|
||||
describe("Add Model Tab", () => {
|
||||
it("should render", async () => {
|
||||
const props = createTestProps();
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddModelTab
|
||||
form={props.form}
|
||||
handleOk={props.handleOk}
|
||||
selectedProvider={props.selectedProvider}
|
||||
setSelectedProvider={props.setSelectedProvider}
|
||||
providerModels={props.providerModels}
|
||||
setProviderModelsFn={props.setProviderModelsFn}
|
||||
getPlaceholder={props.getPlaceholder}
|
||||
uploadProps={props.uploadProps}
|
||||
showAdvancedSettings={props.showAdvancedSettings}
|
||||
setShowAdvancedSettings={props.setShowAdvancedSettings}
|
||||
teams={props.teams}
|
||||
credentials={props.credentials}
|
||||
accessToken={props.accessToken}
|
||||
userRole={props.userRole}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display both Add Model and Add Auto Router tabs", async () => {
|
||||
const props = createTestProps();
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddModelTab
|
||||
form={props.form}
|
||||
handleOk={props.handleOk}
|
||||
selectedProvider={props.selectedProvider}
|
||||
setSelectedProvider={props.setSelectedProvider}
|
||||
providerModels={props.providerModels}
|
||||
setProviderModelsFn={props.setProviderModelsFn}
|
||||
getPlaceholder={props.getPlaceholder}
|
||||
uploadProps={props.uploadProps}
|
||||
showAdvancedSettings={props.showAdvancedSettings}
|
||||
setShowAdvancedSettings={props.setShowAdvancedSettings}
|
||||
teams={props.teams}
|
||||
credentials={props.credentials}
|
||||
accessToken={props.accessToken}
|
||||
userRole={props.userRole}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("tab", { name: "Add Auto Router" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display provider selection field", async () => {
|
||||
const props = createTestProps();
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddModelTab
|
||||
form={props.form}
|
||||
handleOk={props.handleOk}
|
||||
selectedProvider={props.selectedProvider}
|
||||
setSelectedProvider={props.setSelectedProvider}
|
||||
providerModels={props.providerModels}
|
||||
setProviderModelsFn={props.setProviderModelsFn}
|
||||
getPlaceholder={props.getPlaceholder}
|
||||
uploadProps={props.uploadProps}
|
||||
showAdvancedSettings={props.showAdvancedSettings}
|
||||
setShowAdvancedSettings={props.setShowAdvancedSettings}
|
||||
teams={props.teams}
|
||||
credentials={props.credentials}
|
||||
accessToken={props.accessToken}
|
||||
userRole={props.userRole}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Provider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Test Connect and Add Model buttons", async () => {
|
||||
const props = createTestProps();
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddModelTab
|
||||
form={props.form}
|
||||
handleOk={props.handleOk}
|
||||
selectedProvider={props.selectedProvider}
|
||||
setSelectedProvider={props.setSelectedProvider}
|
||||
providerModels={props.providerModels}
|
||||
setProviderModelsFn={props.setProviderModelsFn}
|
||||
getPlaceholder={props.getPlaceholder}
|
||||
uploadProps={props.uploadProps}
|
||||
showAdvancedSettings={props.showAdvancedSettings}
|
||||
setShowAdvancedSettings={props.setShowAdvancedSettings}
|
||||
teams={props.teams}
|
||||
credentials={props.credentials}
|
||||
accessToken={props.accessToken}
|
||||
userRole={props.userRole}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for async operations to complete and buttons to appear
|
||||
await waitFor(
|
||||
async () => {
|
||||
const testConnectButtons = await screen.findAllByRole("button", { name: "Test Connect" });
|
||||
expect(testConnectButtons.length).toBeGreaterThan(0);
|
||||
const addModelButton = await screen.findByRole("button", { name: "Add Model" });
|
||||
expect(addModelButton).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should show team selection when team-only switch is enabled", async () => {
|
||||
const props = createTestProps();
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddModelTab
|
||||
form={props.form}
|
||||
handleOk={props.handleOk}
|
||||
selectedProvider={props.selectedProvider}
|
||||
setSelectedProvider={props.setSelectedProvider}
|
||||
providerModels={props.providerModels}
|
||||
setProviderModelsFn={props.setProviderModelsFn}
|
||||
getPlaceholder={props.getPlaceholder}
|
||||
uploadProps={props.uploadProps}
|
||||
showAdvancedSettings={props.showAdvancedSettings}
|
||||
setShowAdvancedSettings={props.setShowAdvancedSettings}
|
||||
teams={props.teams}
|
||||
credentials={props.credentials}
|
||||
accessToken={props.accessToken}
|
||||
userRole={props.userRole}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for component to load
|
||||
await screen.findByText("Provider");
|
||||
|
||||
// Scope to the Team-BYOK Model Form.Item: the Add Auto Router tab, mounted alongside
|
||||
// this one, also renders a "Semantic keyword matching" switch, so a bare
|
||||
// getByRole("switch") would match more than one element.
|
||||
const teamByokFormItem = screen.getByText("Team-BYOK Model").closest(".ant-form-item") as HTMLElement;
|
||||
const teamSwitch = within(teamByokFormItem).getByRole("switch");
|
||||
expect(teamSwitch).toBeInTheDocument();
|
||||
|
||||
// Initially, team selection should not be visible
|
||||
expect(screen.queryByText("Select Team")).not.toBeInTheDocument();
|
||||
|
||||
// Click the switch to enable team-only mode
|
||||
await userEvent.click(teamSwitch!);
|
||||
|
||||
// Now team selection should be visible
|
||||
expect(await screen.findByText("Select Team")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { FormInstance } from "antd";
|
||||
import { Form } from "antd";
|
||||
import type { UploadProps } from "antd/es/upload";
|
||||
import React from "react";
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import { type CredentialItem } from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import AddAutoRouterTab from "./add_auto_router_tab";
|
||||
import AddModelForm from "./AddModelForm";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
|
||||
interface AddModelTabProps {
|
||||
form: FormInstance; // For the Add Model tab
|
||||
handleOk: (values?: any) => Promise<void>;
|
||||
selectedProvider: Providers;
|
||||
setSelectedProvider: (provider: Providers) => void;
|
||||
providerModels: string[];
|
||||
setProviderModelsFn: (provider: Providers) => void;
|
||||
getPlaceholder: (provider: Providers) => string;
|
||||
uploadProps: UploadProps;
|
||||
showAdvancedSettings: boolean;
|
||||
setShowAdvancedSettings: (show: boolean) => void;
|
||||
teams: Team[] | null;
|
||||
credentials: CredentialItem[];
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
const AddModelTab: React.FC<AddModelTabProps> = ({
|
||||
form,
|
||||
handleOk,
|
||||
selectedProvider,
|
||||
setSelectedProvider,
|
||||
providerModels,
|
||||
setProviderModelsFn,
|
||||
getPlaceholder,
|
||||
uploadProps,
|
||||
showAdvancedSettings,
|
||||
setShowAdvancedSettings,
|
||||
teams,
|
||||
credentials,
|
||||
accessToken,
|
||||
userRole,
|
||||
}) => {
|
||||
// Create separate form instance for auto router
|
||||
const [autoRouterForm] = Form.useForm();
|
||||
|
||||
const handleAutoRouterOk = () => {
|
||||
autoRouterForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
handleAddAutoRouterSubmit(values, accessToken, autoRouterForm, handleOk);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabGroup className="w-full">
|
||||
<TabList className="mb-4">
|
||||
<Tab>Add Model</Tab>
|
||||
<Tab>Add Auto Router</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<AddModelForm
|
||||
form={form}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
providerModels={providerModels}
|
||||
setProviderModelsFn={setProviderModelsFn}
|
||||
getPlaceholder={getPlaceholder}
|
||||
uploadProps={uploadProps}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams}
|
||||
credentials={credentials}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AddAutoRouterTab
|
||||
form={autoRouterForm}
|
||||
handleOk={handleAutoRouterOk}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModelTab;
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Single owner of "which auto-router strategy is this deployment, and what can we do with it".
|
||||
*
|
||||
* Two independent axes decide whether a row is writable, and both must hold:
|
||||
* 1. STRATEGY - the dashboard only has a form for complexity and semantic routers. Adaptive
|
||||
* and quality store their settings under their own config keys, so opening
|
||||
* one in the complexity/semantic editor would write the wrong shape onto it.
|
||||
* 2. ORIGIN - a deployment defined in config.yaml reports `db_model: false`, and the API
|
||||
* refuses it regardless of strategy (PATCH /model/{id}/update 404s,
|
||||
* POST /model/delete 400s). Only rows created through the API are writable.
|
||||
*
|
||||
* Strategy order mirrors Router._is_auto_router_deployment (router.py:7589-7594): the named
|
||||
* prefixes are matched first, and only a bare `auto_router/<name>` is the semantic router.
|
||||
*/
|
||||
|
||||
export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic";
|
||||
|
||||
export interface AutoRouterParams {
|
||||
model?: string | null;
|
||||
complexity_router_config?: unknown;
|
||||
complexity_router_default_model?: string | null;
|
||||
auto_router_config?: unknown;
|
||||
auto_router_default_model?: string | null;
|
||||
adaptive_router_config?: unknown;
|
||||
adaptive_router_default_model?: string | null;
|
||||
quality_router_config?: unknown;
|
||||
quality_router_default_model?: string | null;
|
||||
}
|
||||
|
||||
export interface AutoRouterStrategy {
|
||||
kind: AutoRouterKind;
|
||||
/** Type-pill label. The complexity router overrides this with its classifier. */
|
||||
label: string;
|
||||
configKey: keyof AutoRouterParams;
|
||||
defaultModelKey: keyof AutoRouterParams;
|
||||
/** Whether the dashboard has a form that understands this strategy's config shape. */
|
||||
hasEditor: boolean;
|
||||
matches: (params: AutoRouterParams) => boolean;
|
||||
}
|
||||
|
||||
const startsWith = (params: AutoRouterParams, prefix: string): boolean => params.model?.startsWith(prefix) === true;
|
||||
|
||||
/** Ordered; the semantic entry matches anything left and must stay last. */
|
||||
export const AUTO_ROUTER_STRATEGIES: readonly AutoRouterStrategy[] = [
|
||||
{
|
||||
kind: "complexity",
|
||||
label: "Complexity",
|
||||
configKey: "complexity_router_config",
|
||||
defaultModelKey: "complexity_router_default_model",
|
||||
hasEditor: true,
|
||||
// Also matched by config presence: rows predating the canonical model string carry the
|
||||
// config without the prefix.
|
||||
matches: (p) => startsWith(p, "auto_router/complexity_router") || p.complexity_router_config != null,
|
||||
},
|
||||
{
|
||||
kind: "adaptive",
|
||||
label: "Adaptive",
|
||||
configKey: "adaptive_router_config",
|
||||
defaultModelKey: "adaptive_router_default_model",
|
||||
hasEditor: false,
|
||||
matches: (p) => startsWith(p, "auto_router/adaptive_router"),
|
||||
},
|
||||
{
|
||||
kind: "quality",
|
||||
label: "Quality",
|
||||
configKey: "quality_router_config",
|
||||
defaultModelKey: "quality_router_default_model",
|
||||
hasEditor: false,
|
||||
matches: (p) => startsWith(p, "auto_router/quality_router"),
|
||||
},
|
||||
{
|
||||
kind: "semantic",
|
||||
label: "Semantic",
|
||||
configKey: "auto_router_config",
|
||||
defaultModelKey: "auto_router_default_model",
|
||||
hasEditor: true,
|
||||
matches: () => true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const autoRouterStrategy = (params: AutoRouterParams | null | undefined): AutoRouterStrategy =>
|
||||
AUTO_ROUTER_STRATEGIES.find((strategy) => strategy.matches(params ?? {}))!;
|
||||
|
||||
export const isComplexityRouter = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
autoRouterStrategy(params).kind === "complexity";
|
||||
|
||||
/** Any `auto_router/*` deployment, whatever its strategy. Use for listing and filtering. */
|
||||
export const isAutoRouterDeployment = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
params?.model?.startsWith("auto_router/") === true ||
|
||||
params?.complexity_router_config != null ||
|
||||
params?.auto_router_config != null;
|
||||
|
||||
/**
|
||||
* Whether EditAutoRouterModal understands this deployment. It only speaks complexity and
|
||||
* semantic, so offering it for an adaptive or quality router lets a save write
|
||||
* `auto_router_config` onto a row that stores its settings elsewhere. Gate every edit
|
||||
* affordance on this, never on `isAutoRouterDeployment`.
|
||||
*/
|
||||
export const hasAutoRouterEditor = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
isAutoRouterDeployment(params) && autoRouterStrategy(params).hasEditor;
|
||||
|
||||
export interface AutoRouterDeploymentInfo {
|
||||
db_model?: boolean | null;
|
||||
}
|
||||
|
||||
/** Why the dashboard cannot offer an edit form, or null when it can. */
|
||||
export type EditBlockedReason = "config-managed" | "no-editor";
|
||||
|
||||
export interface AutoRouterCapabilities {
|
||||
/** Defined in config.yaml; the API refuses both update and delete for it. */
|
||||
isConfigManaged: boolean;
|
||||
canEdit: boolean;
|
||||
/** Deleting removes a row by id and never reads its config, so strategy is irrelevant. */
|
||||
canDelete: boolean;
|
||||
editBlockedReason: EditBlockedReason | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What could be done to this deployment by anyone with permission. Deliberately excludes the
|
||||
* caller's role: the page ANDs that in, so resource capability and actor permission stay
|
||||
* separable. Derive per capability rather than exposing one "editable" boolean, because the
|
||||
* constraints differ (edit needs an editor, delete does not) and the explanation differs again.
|
||||
*/
|
||||
const editBlockedReasonFor = (isConfigManaged: boolean, hasEditor: boolean): EditBlockedReason | null => {
|
||||
if (isConfigManaged) return "config-managed";
|
||||
if (!hasEditor) return "no-editor";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const autoRouterCapabilities = (
|
||||
params: AutoRouterParams | null | undefined,
|
||||
modelInfo: AutoRouterDeploymentInfo | null | undefined,
|
||||
): AutoRouterCapabilities => {
|
||||
const isConfigManaged = modelInfo?.db_model !== true;
|
||||
const hasEditor = autoRouterStrategy(params).hasEditor;
|
||||
|
||||
return {
|
||||
isConfigManaged,
|
||||
canEdit: !isConfigManaged && hasEditor,
|
||||
canDelete: !isConfigManaged,
|
||||
editBlockedReason: editBlockedReasonFor(isConfigManaged, hasEditor),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
import {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
|
|
@ -82,12 +83,7 @@ export const buildComplexityRouterConfig = ({
|
|||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
|
||||
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
|
||||
// "Add keyword rule" seeds a rule with an empty keywords list, so without this an
|
||||
// unfilled row (common in the heuristic flow, where getSemanticConfigError doesn't run)
|
||||
// would ship keyword_tier_rules the backend validator rejects with a 400.
|
||||
const cleanedKeywordTierRules = keywordTierRules
|
||||
.map((rule) => ({ keywords: rule.keywords.map((k) => k.trim()).filter(Boolean), tier: rule.tier }))
|
||||
.filter((rule) => rule.keywords.length > 0);
|
||||
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
|
||||
|
||||
return {
|
||||
tiers,
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { getSemanticRouterError, SemanticRouterConfig } from "./build_semantic_router_validation";
|
||||
|
||||
const validRouterConfig: SemanticRouterConfig = {
|
||||
routes: [{ name: "gpt-4o", description: "general chat", utterances: ["hello there"] }],
|
||||
};
|
||||
|
||||
describe("getSemanticRouterError", () => {
|
||||
it("requires an embedding model once the default model and routes are configured", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: "gpt-4o",
|
||||
embeddingModel: undefined,
|
||||
routerConfig: validRouterConfig,
|
||||
}),
|
||||
).toBe("Please select an Embedding Model");
|
||||
});
|
||||
|
||||
it("treats an empty embedding model string as missing", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: "gpt-4o",
|
||||
embeddingModel: "",
|
||||
routerConfig: validRouterConfig,
|
||||
}),
|
||||
).toBe("Please select an Embedding Model");
|
||||
});
|
||||
|
||||
it("passes when an embedding model is selected", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: "gpt-4o",
|
||||
embeddingModel: "text-embedding-3-large",
|
||||
routerConfig: validRouterConfig,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("flags a missing default model before checking the embedding model", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: undefined,
|
||||
embeddingModel: undefined,
|
||||
routerConfig: validRouterConfig,
|
||||
}),
|
||||
).toBe("Please select a Default Model");
|
||||
});
|
||||
|
||||
it("flags missing routes before checking the embedding model", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: "gpt-4o",
|
||||
embeddingModel: undefined,
|
||||
routerConfig: { routes: [] },
|
||||
}),
|
||||
).toBe("Please configure at least one route for the auto router");
|
||||
});
|
||||
|
||||
it("validates route completeness after the embedding model is set", () => {
|
||||
expect(
|
||||
getSemanticRouterError({
|
||||
defaultModel: "gpt-4o",
|
||||
embeddingModel: "text-embedding-3-large",
|
||||
routerConfig: { routes: [{ name: "gpt-4o", description: "", utterances: [] }] },
|
||||
}),
|
||||
).toBe("Please ensure all routes have a target model, description, and at least one utterance");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
export interface SemanticRouterRoute {
|
||||
name?: string;
|
||||
description?: string;
|
||||
utterances?: unknown[];
|
||||
}
|
||||
|
||||
export interface SemanticRouterConfig {
|
||||
routes?: SemanticRouterRoute[];
|
||||
}
|
||||
|
||||
export interface SemanticRouterValidationParams {
|
||||
defaultModel: string | undefined;
|
||||
embeddingModel: string | undefined;
|
||||
routerConfig: SemanticRouterConfig | null | undefined;
|
||||
}
|
||||
|
||||
export const getSemanticRouterError = ({
|
||||
defaultModel,
|
||||
embeddingModel,
|
||||
routerConfig,
|
||||
}: SemanticRouterValidationParams): string | null => {
|
||||
if (!defaultModel) return "Please select a Default Model";
|
||||
if (!routerConfig?.routes || routerConfig.routes.length === 0)
|
||||
return "Please configure at least one route for the auto router";
|
||||
if (!embeddingModel) return "Please select an Embedding Model";
|
||||
if (routerConfig.routes.some((route) => !route.name || !route.description || (route.utterances?.length ?? 0) === 0))
|
||||
return "Please ensure all routes have a target model, description, and at least one utterance";
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { ComplexityTier, KeywordTierRule } from "./KeywordTierRules";
|
||||
|
||||
/**
|
||||
* Stored shape of a keyword tier rule inside `complexity_router_config`. The UI's
|
||||
* KeywordTierRule carries an extra `id` used only as a React key, so it is stripped on the
|
||||
* way out and synthesized on the way back in. Both the create form and the edit modal go
|
||||
* through here so the two directions cannot drift.
|
||||
*/
|
||||
export interface StoredKeywordTierRule {
|
||||
keywords: string[];
|
||||
tier: ComplexityTier;
|
||||
}
|
||||
|
||||
const TIERS: ReadonlySet<string> = new Set<ComplexityTier>(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
|
||||
|
||||
const asKeywords = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((keyword): keyword is string => typeof keyword === "string").map((keyword) => keyword.trim())
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Drop the React-only id, trim keywords, and discard rules left empty. "Add keyword rule"
|
||||
* seeds a row with no keywords, and the backend validator rejects those with a 400.
|
||||
*/
|
||||
export const serializeKeywordTierRules = (rules: KeywordTierRule[]): StoredKeywordTierRule[] =>
|
||||
rules
|
||||
.map((rule) => ({ keywords: asKeywords(rule.keywords).filter(Boolean), tier: rule.tier }))
|
||||
.filter((rule) => rule.keywords.length > 0);
|
||||
|
||||
export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry, index) => {
|
||||
if (typeof entry !== "object" || entry === null) return [];
|
||||
const record = entry as Record<string, unknown>;
|
||||
const keywords = asKeywords(record.keywords).filter(Boolean);
|
||||
const tier = record.tier;
|
||||
if (keywords.length === 0 || typeof tier !== "string" || !TIERS.has(tier)) return [];
|
||||
return [{ id: `stored-${index}`, keywords, tier: tier as ComplexityTier }];
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeTierModels } from "./complexity_router_tiers";
|
||||
|
||||
// The backend types a tier as `str | list[str]` and widens with
|
||||
// `models if isinstance(models, list) else [models]`
|
||||
// (litellm/router_strategy/complexity_router/config.py:255, :441). These cases assert the
|
||||
// expected verdict per input rather than just agreement between call sites, so the test still
|
||||
// has teeth if every reader were changed at once.
|
||||
describe("normalizeTierModels", () => {
|
||||
it("widens a pinned single model to a one-element pool", () => {
|
||||
expect(normalizeTierModels("gpt-4o-mini")).toEqual(["gpt-4o-mini"]);
|
||||
});
|
||||
|
||||
it("passes a pool through in order", () => {
|
||||
expect(normalizeTierModels(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("treats an empty string as no models, not a pool containing an empty name", () => {
|
||||
expect(normalizeTierModels("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops non-string entries rather than typing them as models", () => {
|
||||
expect(normalizeTierModels(["a", 3, null, "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it.each([[undefined], [null], [{}], [42]])("returns no models for %s", (value) => {
|
||||
expect(normalizeTierModels(value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* A complexity tier maps to `str | list[str]` on the backend
|
||||
* (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"),
|
||||
* and the router widens the bare string with `models if isinstance(models, list) else [models]`.
|
||||
*
|
||||
* Every UI reader of a STORED complexity_router_config must widen the same way, so this is the
|
||||
* single owner of that rule. Readers of in-memory ComplexityTiers state are already string[]
|
||||
* and do not need it.
|
||||
*/
|
||||
export const normalizeTierModels = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value.filter((model): model is string => typeof model === "string");
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildUpdatedComplexityRouterConfig, type KeywordMatchingState } from "./edit_auto_router_modal";
|
||||
|
||||
const STORED = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
|
||||
escalation_keywords: ["urgent", "outage"],
|
||||
semantic_keyword_matching: true,
|
||||
embedding_model: "voyage-4-large",
|
||||
match_threshold: 0.72,
|
||||
// A key no UI control owns; it must survive every save untouched.
|
||||
some_future_backend_key: { nested: true },
|
||||
};
|
||||
|
||||
const FORM_VALUE = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic" as const,
|
||||
};
|
||||
|
||||
const hydratedState: KeywordMatchingState = {
|
||||
keywordTierRules: [{ id: "stored-0", keywords: ["invoice", "refund"], tier: "MEDIUM" }],
|
||||
escalationKeywords: ["urgent", "outage"],
|
||||
semanticMatchingEnabled: true,
|
||||
embeddingModel: "voyage-4-large",
|
||||
matchThreshold: 0.72,
|
||||
};
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
|
||||
it("round-trips an untouched edit without changing any keyword-matching value", () => {
|
||||
// Opening the modal hydrates state from STORED; saving with nothing changed must be a
|
||||
// no-op. These keys are now MANAGED, so a hydration bug silently wipes them.
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
|
||||
|
||||
expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
|
||||
expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
|
||||
expect(result.semantic_keyword_matching).toBe(true);
|
||||
expect(result.embedding_model).toBe("voyage-4-large");
|
||||
expect(result.match_threshold).toBe(0.72);
|
||||
});
|
||||
|
||||
it("preserves keys no control owns", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
|
||||
expect(result.some_future_backend_key).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("persists an edited keyword rule", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
|
||||
...hydratedState,
|
||||
keywordTierRules: [{ id: "stored-0", keywords: ["chargeback"], tier: "COMPLEX" }],
|
||||
});
|
||||
|
||||
expect(result.keyword_tier_rules).toEqual([{ keywords: ["chargeback"], tier: "COMPLEX" }]);
|
||||
});
|
||||
|
||||
it("drops a rule left empty rather than shipping one the backend 400s on", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
|
||||
...hydratedState,
|
||||
keywordTierRules: [{ id: "new-1", keywords: [" "], tier: "SIMPLE" }],
|
||||
});
|
||||
|
||||
expect(result.keyword_tier_rules).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes the semantic trio when the toggle is turned off", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
|
||||
...hydratedState,
|
||||
semanticMatchingEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.semantic_keyword_matching).toBeUndefined();
|
||||
expect(result.embedding_model).toBeUndefined();
|
||||
expect(result.match_threshold).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries stored keyword matching through untouched when the caller owns no such state", () => {
|
||||
// Any caller that does not render these controls must not have its values dropped just
|
||||
// because the keys are listed as managed.
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE);
|
||||
|
||||
expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
|
||||
expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
|
||||
expect(result.semantic_keyword_matching).toBe(true);
|
||||
expect(result.embedding_model).toBe("voyage-4-large");
|
||||
expect(result.match_threshold).toBe(0.72);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
|
||||
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import EditAutoRouterModal from "./edit_auto_router_modal";
|
||||
|
||||
const { modelPatchUpdateCall, modelAvailableCall } = vi.hoisted(() => ({
|
||||
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({ modelPatchUpdateCall, modelAvailableCall }));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "gpt-4o-mini" }]),
|
||||
}));
|
||||
|
||||
const STORED_CONFIG = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o-mini"], COMPLEX: ["gpt-4o-mini"], REASONING: ["gpt-4o-mini"] },
|
||||
classifier_type: "heuristic",
|
||||
keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
|
||||
escalation_keywords: ["urgent", "outage"],
|
||||
semantic_keyword_matching: true,
|
||||
embedding_model: "voyage-4-large",
|
||||
match_threshold: 0.72,
|
||||
};
|
||||
|
||||
const MODEL_DATA = {
|
||||
model_name: "tri-tier-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: STORED_CONFIG,
|
||||
},
|
||||
model_info: { id: "auto-1", access_groups: [] },
|
||||
};
|
||||
|
||||
const renderModal = () =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={MODEL_DATA}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
const savedConfig = () => {
|
||||
const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? [];
|
||||
return payload?.litellm_params?.complexity_router_config;
|
||||
};
|
||||
|
||||
describe("EditAutoRouterModal keyword matching", () => {
|
||||
beforeEach(() => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
||||
it("renders the advanced sections the create form offers", async () => {
|
||||
renderModal();
|
||||
|
||||
expect(await screen.findByText(/Escalation Keywords/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Keyword\/Semantic Matching/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// These keys are rewritten from form state on save, so if the modal renders the controls
|
||||
// without hydrating them, an untouched save silently wipes the stored configuration. This
|
||||
// drives the real component; a test of the payload builder alone cannot see that bug.
|
||||
it("preserves stored keyword matching through an untouched open-and-save", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await screen.findByText(/Escalation Keywords/i);
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
const config = savedConfig();
|
||||
expect(config.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
|
||||
expect(config.escalation_keywords).toEqual(["urgent", "outage"]);
|
||||
expect(config.semantic_keyword_matching).toBe(true);
|
||||
expect(config.embedding_model).toBe("voyage-4-large");
|
||||
expect(config.match_threshold).toBe(0.72);
|
||||
});
|
||||
|
||||
// The create form blocks this; the edit modal renders the same controls, so it must block it
|
||||
// too. The backend raises on semantic_keyword_matching without an embedding model or keyword
|
||||
// rules, so skipping the guard turns a friendly inline message into a raw 400.
|
||||
it("blocks a save that enables semantic matching with no embedding model", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: {
|
||||
...STORED_CONFIG,
|
||||
semantic_keyword_matching: true,
|
||||
embedding_model: undefined,
|
||||
keyword_tier_rules: [{ keywords: ["invoice"], tier: "MEDIUM" }],
|
||||
},
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(/Escalation Keywords/i);
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(NotificationsManager.fromBackend).toHaveBeenCalled());
|
||||
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,12 @@ import { Text, TextInput } from "@tremor/react";
|
|||
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import { normalizeTierModels } from "../add_model/complexity_router_tiers";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import { getSemanticConfigError } from "../add_model/build_complexity_router_config";
|
||||
import { KeywordTierRule } from "../add_model/KeywordTierRules";
|
||||
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
|
||||
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
|
|
@ -11,16 +17,6 @@ import ComplexityRouterConfig, {
|
|||
} from "../add_model/ComplexityRouterConfig";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
const isComplexityRouterModel = (modelData: any): boolean =>
|
||||
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router") ||
|
||||
modelData?.litellm_params?.complexity_router_config != null;
|
||||
|
||||
const normalizeTierModels = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
};
|
||||
|
||||
interface EditAutoRouterModalProps {
|
||||
isVisible: boolean;
|
||||
onCancel: () => void;
|
||||
|
|
@ -30,6 +26,9 @@ interface EditAutoRouterModalProps {
|
|||
userRole: string;
|
||||
}
|
||||
|
||||
// Keys this modal rewrites from its own form state on save. Anything absent from this set is
|
||||
// carried through untouched from the stored config, so a key only belongs here once the modal
|
||||
// actually renders a control that can set it.
|
||||
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"classifier_type",
|
||||
|
|
@ -41,6 +40,16 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"return_raw_model_name",
|
||||
]);
|
||||
|
||||
// Managed only when the caller passes the corresponding state. A caller that does not render
|
||||
// these controls must carry the stored values through untouched instead of dropping them.
|
||||
const KEYWORD_MATCHING_KEYS = new Set([
|
||||
"keyword_tier_rules",
|
||||
"escalation_keywords",
|
||||
"semantic_keyword_matching",
|
||||
"embedding_model",
|
||||
"match_threshold",
|
||||
]);
|
||||
|
||||
const toRecord = (value: unknown): Record<string, unknown> => {
|
||||
const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
|
|
@ -48,19 +57,29 @@ const toRecord = (value: unknown): Record<string, unknown> => {
|
|||
: {};
|
||||
};
|
||||
|
||||
export interface KeywordMatchingState {
|
||||
keywordTierRules: KeywordTierRule[];
|
||||
escalationKeywords: string[];
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
matchThreshold: number;
|
||||
}
|
||||
|
||||
export const buildUpdatedComplexityRouterConfig = (
|
||||
storedConfig: unknown,
|
||||
value: ComplexityRouterConfigValue,
|
||||
customTechnicalKeywords?: string[],
|
||||
keywordMatching?: KeywordMatchingState,
|
||||
): Record<string, unknown> => {
|
||||
const preservedConfig = Object.fromEntries(
|
||||
Object.entries(toRecord(storedConfig)).filter(
|
||||
([key]) =>
|
||||
!MANAGED_COMPLEXITY_ROUTER_KEYS.has(key) &&
|
||||
(customTechnicalKeywords === undefined || key !== "custom_technical_keywords"),
|
||||
),
|
||||
);
|
||||
const isManaged = (key: string): boolean => {
|
||||
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
|
||||
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 adaptiveEligible = value.adaptive_eligible ?? "all";
|
||||
const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
|
||||
|
||||
return {
|
||||
...preservedConfig,
|
||||
|
|
@ -80,6 +99,17 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
adaptive_eligible: adaptiveEligible,
|
||||
}),
|
||||
...(value.return_raw_model_name && { return_raw_model_name: true }),
|
||||
...(keywordMatching && {
|
||||
// Mirrors buildComplexityRouterConfig: rules only when non-empty (the backend rejects
|
||||
// an empty rule with a 400), escalation keywords always, semantic trio only when on.
|
||||
...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
|
||||
escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean),
|
||||
...(keywordMatching.semanticMatchingEnabled && {
|
||||
semantic_keyword_matching: true,
|
||||
embedding_model: keywordMatching.embeddingModel,
|
||||
match_threshold: keywordMatching.matchThreshold,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -99,11 +129,16 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
|
||||
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
|
||||
const [escalationKeywords, setEscalationKeywords] = useState<string[]>([]);
|
||||
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState<boolean>(false);
|
||||
const [embeddingModel, setEmbeddingModel] = useState<string | undefined>(undefined);
|
||||
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
|
||||
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
});
|
||||
const isComplexityRouter = isComplexityRouterModel(modelData);
|
||||
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modelData) {
|
||||
|
|
@ -140,7 +175,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
const initializeForm = () => {
|
||||
try {
|
||||
if (isComplexityRouterModel(modelData)) {
|
||||
if (isComplexityRouterModel) {
|
||||
// Parse the complexity_router_config if it exists and is a string
|
||||
let parsedConfig = modelData.litellm_params?.complexity_router_config || {};
|
||||
if (typeof parsedConfig === "string") {
|
||||
|
|
@ -165,6 +200,20 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
setCustomTechnicalKeywords(
|
||||
Array.isArray(parsedConfig.custom_technical_keywords) ? parsedConfig.custom_technical_keywords : [],
|
||||
);
|
||||
// Hydrated from the stored config, never from create-form defaults: these keys are now
|
||||
// rewritten on save, so seeding a default here would inject it into a config that never
|
||||
// had it.
|
||||
setKeywordTierRules(hydrateKeywordTierRules(parsedConfig.keyword_tier_rules));
|
||||
setEscalationKeywords(
|
||||
Array.isArray(parsedConfig.escalation_keywords)
|
||||
? parsedConfig.escalation_keywords.filter((k: unknown): k is string => typeof k === "string")
|
||||
: [],
|
||||
);
|
||||
setSemanticMatchingEnabled(parsedConfig.semantic_keyword_matching === true);
|
||||
setEmbeddingModel(typeof parsedConfig.embedding_model === "string" ? parsedConfig.embedding_model : undefined);
|
||||
setMatchThreshold(
|
||||
typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD,
|
||||
);
|
||||
|
||||
form.setFieldsValue({
|
||||
auto_router_name: modelData.model_name,
|
||||
|
|
@ -208,7 +257,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (isComplexityRouter) {
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
NotificationsManager.fromBackend("Please select at least one model for a complexity tier");
|
||||
|
|
@ -218,6 +267,20 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
NotificationsManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
|
||||
return;
|
||||
}
|
||||
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
|
||||
// semantic_keyword_matching without an embedding model or keyword rules
|
||||
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
|
||||
// an inline message.
|
||||
|
||||
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
|
||||
// semantic_keyword_matching without an embedding model or keyword rules
|
||||
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
|
||||
// an inline message.
|
||||
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
|
||||
if (semanticError) {
|
||||
NotificationsManager.fromBackend(semanticError);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];
|
||||
const updatedLitellmParams = {
|
||||
|
|
@ -226,6 +289,13 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
customTechnicalKeywords,
|
||||
{
|
||||
keywordTierRules,
|
||||
escalationKeywords,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
matchThreshold,
|
||||
},
|
||||
),
|
||||
complexity_router_default_model: defaultModel,
|
||||
};
|
||||
|
|
@ -327,7 +397,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
<TextInput placeholder="e.g., auto_router_1, smart_routing" />
|
||||
</Form.Item>
|
||||
|
||||
{isComplexityRouter ? (
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
|
|
@ -338,6 +408,16 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({
|
|||
const collectNavKeys = (): string[] =>
|
||||
menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)]));
|
||||
|
||||
// Every place a page id appears in the nav, as "GROUP" for a top-level item or
|
||||
// "GROUP > parentKey" for a child.
|
||||
const placementsOf = (page: string): string[] =>
|
||||
menuGroups.flatMap((group) => [
|
||||
...group.items.filter((item) => item.page === page).map(() => group.groupLabel),
|
||||
...group.items.flatMap((item) =>
|
||||
(item.children ?? []).filter((child) => child.page === page).map(() => `${group.groupLabel} > ${item.key}`),
|
||||
),
|
||||
]);
|
||||
|
||||
describe("Sidebar (leftnav)", () => {
|
||||
const defaultProps = {
|
||||
setPage: vi.fn(),
|
||||
|
|
@ -129,6 +139,13 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("keeps Router Settings as a single Settings child", () => {
|
||||
// Router Settings is admin-only, so getAvailablePages() filters it out entirely and the
|
||||
// page_utils duplicate-key guard cannot see it. Walk menuGroups directly, otherwise a
|
||||
// stray duplicate placement ships silently.
|
||||
expect(placementsOf("router-settings")).toEqual(["SETTINGS > settings"]);
|
||||
});
|
||||
|
||||
it("has no duplicate keys among all menu items and their children", () => {
|
||||
// React keys must be unique across the whole nav config, otherwise the
|
||||
// active-item highlight and group expansion collide.
|
||||
|
|
@ -273,6 +290,10 @@ describe("getBreadcrumb", () => {
|
|||
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
|
||||
});
|
||||
|
||||
it("resolves router-settings under the Settings section", () => {
|
||||
expect(getBreadcrumb("router-settings")).toEqual({ section: "Settings", title: "Router Settings" });
|
||||
});
|
||||
|
||||
it("falls back to a prettified title with no section for unknown pages", () => {
|
||||
expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -949,4 +949,89 @@ describe("ModelInfoView", () => {
|
|||
expect(screen.queryByAltText("zzz-internal logo")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("z")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or
|
||||
// quality router lets a save write auto_router_config onto a row that stores its settings
|
||||
// elsewhere. These rows stay reachable from Health Status and direct ?model= links even
|
||||
// though the Models table now excludes auto-routers, so the button itself has to be gated.
|
||||
describe("Edit Auto Router affordance", () => {
|
||||
const withRouter = (litellmParams: Record<string, unknown>) => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
["auto_router/adaptive_router", "adaptive"],
|
||||
["auto_router/quality_router", "quality"],
|
||||
])("is absent for a %s router", async (model) => {
|
||||
withRouter({ model });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByText("GPT-4")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /edit auto router/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is present for a complexity router, which the modal does understand", async () => {
|
||||
withRouter({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("button", { name: /edit auto router/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// An auto router has no upstream credential, so the credential actions are meaningless for
|
||||
// every strategy, and the destructive action should name what it actually removes.
|
||||
describe("auto-router header actions", () => {
|
||||
const withParams = (litellmParams: Record<string, unknown>) => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
["auto_router/complexity_router"],
|
||||
["auto_router/adaptive_router"],
|
||||
["auto_router/quality_router"],
|
||||
["auto_router/my-semantic"],
|
||||
])("hides the credential actions and renames delete for %s", async (model) => {
|
||||
withParams({ model });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Auto-Router");
|
||||
expect(screen.queryByTestId("update-api-key-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("reuse-credentials-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps both credential actions and the Delete Model label for an ordinary model", async () => {
|
||||
withParams({ model: "gpt-4", api_base: "https://api.openai.com/v1" });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Model");
|
||||
expect(screen.getByTestId("update-api-key-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("reuse-credentials-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["auto_router/adaptive_router"], ["auto_router/quality_router"]])(
|
||||
"offers no Test Connection for %s, whose targets it cannot build",
|
||||
async (model) => {
|
||||
withParams({ model });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
await screen.findByTestId("delete-model-button");
|
||||
expect(screen.queryByTestId("test-connection-button")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps Test Connection for a complexity router", async () => {
|
||||
withParams({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByTestId("test-connection-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ import { isMaskedSecret, stripMaskedSecrets } from "../utils/maskedSecretUtils";
|
|||
import { formItemValidateJSON, truncateString } from "../utils/textUtils";
|
||||
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
|
||||
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
|
||||
import { normalizeTierModels } from "./add_model/complexity_router_tiers";
|
||||
import {
|
||||
hasAutoRouterEditor,
|
||||
isAutoRouterDeployment,
|
||||
isComplexityRouter as isComplexityRouterParams,
|
||||
} from "./add_model/auto_router_strategies";
|
||||
import { canModifyModel } from "@/utils/modelPermissions";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import CacheControlSettings from "./add_model/cache_control_settings";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
|
||||
|
|
@ -59,12 +67,6 @@ interface ModelInfoViewProps {
|
|||
modelAccessGroups: string[] | null;
|
||||
}
|
||||
|
||||
const normalizeTierModels = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
};
|
||||
|
||||
interface ComplexityRouterTierConfig {
|
||||
tiers?: {
|
||||
SIMPLE?: unknown;
|
||||
|
|
@ -153,6 +155,7 @@ export default function ModelInfoView({
|
|||
const { data: rawModelDataResponse, isLoading: isLoadingModel } = useModelsInfo(1, 50, undefined, modelId);
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const { data: modelHubData } = useModelHub();
|
||||
const { data: teams } = useTeams();
|
||||
|
||||
// Transform the model data
|
||||
const getProviderFromModel = (model: string) => {
|
||||
|
|
@ -175,16 +178,18 @@ export default function ModelInfoView({
|
|||
// Keep modelData variable name for backwards compatibility
|
||||
const modelData = transformedModelData;
|
||||
|
||||
const canEditModel =
|
||||
(userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
|
||||
const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, {
|
||||
teamId: modelData?.model_info?.team_id,
|
||||
isDbModel: modelData?.model_info?.db_model === true,
|
||||
});
|
||||
const isAdmin = userRole === "Admin";
|
||||
const isAutoRouter =
|
||||
modelData?.litellm_params?.auto_router_config != null ||
|
||||
modelData?.litellm_params?.complexity_router_config != null ||
|
||||
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
|
||||
const isComplexityRouter =
|
||||
modelData?.litellm_params?.complexity_router_config != null ||
|
||||
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
|
||||
// Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router.
|
||||
const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params);
|
||||
// Broader than the editor check: adaptive and quality routers equally have no upstream
|
||||
// credential, so the credential actions are meaningless for every auto-router strategy.
|
||||
const isAnyAutoRouter = isAutoRouterDeployment(modelData?.litellm_params);
|
||||
const deleteLabel = isAnyAutoRouter ? "Delete Auto-Router" : "Delete Model";
|
||||
const isComplexityRouterModel = isComplexityRouterParams(modelData?.litellm_params);
|
||||
|
||||
const usingExistingCredential =
|
||||
modelData?.litellm_params?.litellm_credential_name != null &&
|
||||
|
|
@ -492,7 +497,7 @@ export default function ModelInfoView({
|
|||
|
||||
const handleTestConnection = async () => {
|
||||
if (!accessToken) return;
|
||||
if (isComplexityRouter) {
|
||||
if (isComplexityRouterModel) {
|
||||
const targets = buildComplexityRouterTestTargets(localModelData ?? modelData);
|
||||
if (targets.length === 0) {
|
||||
NotificationsManager.warning("No complexity tiers are configured yet, so there is nothing to test.");
|
||||
|
|
@ -604,7 +609,7 @@ export default function ModelInfoView({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(!isAutoRouter || isComplexityRouter) && (
|
||||
{(!isAnyAutoRouter || isComplexityRouterModel) && (
|
||||
<Button
|
||||
icon={<RefreshIcon className="h-4 w-4" />}
|
||||
onClick={handleTestConnection}
|
||||
|
|
@ -615,25 +620,29 @@ export default function ModelInfoView({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<KeyIcon className="h-4 w-4" />}
|
||||
onClick={() => setIsUpdateCredentialsModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!canEditModel}
|
||||
data-testid="update-api-key-button"
|
||||
>
|
||||
Update API Key
|
||||
</Button>
|
||||
{!isAnyAutoRouter && (
|
||||
<>
|
||||
<Button
|
||||
icon={<KeyIcon className="h-4 w-4" />}
|
||||
onClick={() => setIsUpdateCredentialsModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!canEditModel}
|
||||
data-testid="update-api-key-button"
|
||||
>
|
||||
Update API Key
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<KeyIcon className="h-4 w-4" />}
|
||||
onClick={() => setIsCredentialModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!isAdmin}
|
||||
data-testid="reuse-credentials-button"
|
||||
>
|
||||
Re-use Credentials
|
||||
</Button>
|
||||
<Button
|
||||
icon={<KeyIcon className="h-4 w-4" />}
|
||||
onClick={() => setIsCredentialModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!isAdmin}
|
||||
data-testid="reuse-credentials-button"
|
||||
>
|
||||
Re-use Credentials
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
icon={<TrashIcon className="h-4 w-4" />}
|
||||
|
|
@ -642,7 +651,7 @@ export default function ModelInfoView({
|
|||
disabled={!canEditModel}
|
||||
data-testid="delete-model-button"
|
||||
>
|
||||
Delete Model
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -721,7 +730,7 @@ export default function ModelInfoView({
|
|||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Model Settings</Title>
|
||||
<div className="flex gap-2">
|
||||
{isAutoRouter && canEditModel && !isEditing && (
|
||||
{isAutoRouterModel && canEditModel && !isEditing && (
|
||||
<TremorButton onClick={() => setIsAutoRouterModalOpen(true)} className="flex items-center">
|
||||
Edit Auto Router
|
||||
</TremorButton>
|
||||
|
|
@ -1414,9 +1423,9 @@ export default function ModelInfoView({
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Model"
|
||||
title={deleteLabel}
|
||||
alertMessage="This action cannot be undone."
|
||||
message="Are you sure you want to delete this model?"
|
||||
message={`Are you sure you want to delete this ${isAnyAutoRouter ? "auto-router" : "model"}?`}
|
||||
resourceInformationTitle="Model Information"
|
||||
resourceInformation={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1570,6 +1570,7 @@ export const modelInfoCall = async (
|
|||
teamId?: string,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
excludeAutoRouters?: boolean,
|
||||
) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
|
@ -1595,6 +1596,9 @@ export const modelInfoCall = async (
|
|||
if (sortOrder && sortOrder.trim()) {
|
||||
params.append("sortOrder", sortOrder.trim());
|
||||
}
|
||||
if (excludeAutoRouters) {
|
||||
params.append("exclude_auto_routers", "true");
|
||||
}
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -58089,6 +58089,8 @@ export interface operations {
|
|||
sortBy?: string | null;
|
||||
/** @description Sort order. Options: asc, desc */
|
||||
sortOrder?: string | null;
|
||||
/** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */
|
||||
exclude_auto_routers?: boolean | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
85
ui/litellm-dashboard/src/utils/modelPermissions.test.ts
Normal file
85
ui/litellm-dashboard/src/utils/modelPermissions.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Team } from "@/components/networking";
|
||||
import { canModifyModel, modelCreationScope } from "./modelPermissions";
|
||||
|
||||
const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] =>
|
||||
[{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[];
|
||||
|
||||
const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin" };
|
||||
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
|
||||
const MEMBER = { userRole: "Internal User", userID: "u-member" };
|
||||
|
||||
const noLimits = { disabledForInternalUsers: false };
|
||||
|
||||
describe("modelCreationScope", () => {
|
||||
it("lets a proxy admin create without naming a team", () => {
|
||||
expect(modelCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok");
|
||||
});
|
||||
|
||||
// Live-verified: POST /model/new from a team admin 403s without model_info.team_id and
|
||||
// returns 200 with it, so the form must make the team mandatory rather than optional.
|
||||
it("requires a team admin to name a team", () => {
|
||||
expect(modelCreationScope(TEAM_ADMIN, { teams: teamWhere("u-team-admin", "admin"), ...noLimits })).toBe(
|
||||
"team-required",
|
||||
);
|
||||
});
|
||||
|
||||
it("forbids a plain team member", () => {
|
||||
expect(modelCreationScope(MEMBER, { teams: teamWhere("u-member", "user"), ...noLimits })).toBe("forbidden");
|
||||
});
|
||||
|
||||
// The admin setting is scoped to internal users and must never lock out a proxy admin.
|
||||
it("honours the internal-user kill switch without touching proxy admins", () => {
|
||||
const limits = { teams: teamWhere("u-team-admin", "admin"), disabledForInternalUsers: true };
|
||||
expect(modelCreationScope(TEAM_ADMIN, limits)).toBe("forbidden");
|
||||
expect(modelCreationScope(PROXY_ADMIN, limits)).toBe("unscoped-ok");
|
||||
});
|
||||
|
||||
// org_admin and Admin Viewer are in all_admin_roles but are not PROXY_ADMIN to the API, so
|
||||
// an unscoped create from them 403s. Treating them as admins here is what let a form submit
|
||||
// a payload the backend always rejected.
|
||||
it("does not treat an org admin as able to create unscoped", () => {
|
||||
const orgAdmin = { userRole: "org_admin", userID: "u-org" };
|
||||
expect(modelCreationScope(orgAdmin, { teams: teamWhere("u-org", "admin"), ...noLimits })).toBe("team-required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canModifyModel", () => {
|
||||
const teamRow = { teamId: "team-1", isDbModel: true };
|
||||
|
||||
// config.yaml rows: PATCH /model/{id}/update 404s and POST /model/delete 400s for everyone.
|
||||
it("refuses a config-defined row even to a proxy admin", () => {
|
||||
expect(canModifyModel(PROXY_ADMIN, null, { teamId: "team-1", isDbModel: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a proxy admin act on any DB row", () => {
|
||||
expect(canModifyModel(PROXY_ADMIN, null, teamRow)).toBe(true);
|
||||
});
|
||||
|
||||
// The regression this whole owner exists for. Live-verified: a model created by the proxy
|
||||
// admin (created_by=default_user_id) was PATCHed and DELETEd 200 by a team admin who did
|
||||
// not create it. Authorizing on created_by hid controls the API accepts.
|
||||
it("lets a team admin act on their team's row they did not create", () => {
|
||||
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), teamRow)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a plain member of the owning team", () => {
|
||||
expect(canModifyModel(MEMBER, teamWhere("u-member", "user"), teamRow)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a team admin of a different team", () => {
|
||||
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin", "other-team"), teamRow)).toBe(false);
|
||||
});
|
||||
|
||||
// Unscoped rows can only have been created by a proxy admin, and only one can edit them.
|
||||
it("refuses a team admin on an unscoped row", () => {
|
||||
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), { teamId: null, isDbModel: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat two absent identities as a match", () => {
|
||||
expect(canModifyModel({ userRole: "Internal User", userID: null }, null, teamRow)).toBe(false);
|
||||
});
|
||||
});
|
||||
80
ui/litellm-dashboard/src/utils/modelPermissions.ts
Normal file
80
ui/litellm-dashboard/src/utils/modelPermissions.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Team } from "@/components/networking";
|
||||
|
||||
import { isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam } from "./roles";
|
||||
|
||||
/**
|
||||
* The dashboard's mirror of ModelManagementAuthChecks in
|
||||
* litellm/proxy/management_endpoints/model_management_endpoints.py.
|
||||
*
|
||||
* Both questions below are answered there by exactly two inputs: the caller's role, and
|
||||
* whether the caller admins the team named in `model_info.team_id`. `created_by` is written
|
||||
* at creation and never read by an auth check, so it is deliberately absent here; gating on
|
||||
* it hid controls from team admins the API accepts, and showed controls to former team admins
|
||||
* the API rejects.
|
||||
*/
|
||||
export interface ModelActor {
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
}
|
||||
|
||||
/** How this actor must scope a deployment they create, or that they may not create one. */
|
||||
export type ModelWriteScope = "forbidden" | "unscoped-ok" | "team-required";
|
||||
|
||||
export interface ModelCreationLimits {
|
||||
teams: Team[] | null;
|
||||
/** The admin setting that withdraws model creation from internal users. */
|
||||
disabledForInternalUsers: boolean;
|
||||
}
|
||||
|
||||
const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): boolean => {
|
||||
const team = teams?.find((candidate) => candidate.team_id === teamId);
|
||||
return team != null && isUserTeamAdminForSingleTeam(team.members_with_roles, userID);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a
|
||||
* team; an unscoped create from anyone else is a 403. Returning the requirement rather than a
|
||||
* pair of booleans keeps "may not create" and "may create unscoped" from being confused.
|
||||
*/
|
||||
export const modelCreationScope = (
|
||||
{ userRole, userID }: ModelActor,
|
||||
{ teams, disabledForInternalUsers }: ModelCreationLimits,
|
||||
): ModelWriteScope => {
|
||||
if (userRole != null && isProxyAdminRole(userRole)) {
|
||||
return "unscoped-ok";
|
||||
}
|
||||
if (disabledForInternalUsers) {
|
||||
return "forbidden";
|
||||
}
|
||||
if (userID != null && isUserTeamAdminForAnyTeam(teams, userID)) {
|
||||
return "team-required";
|
||||
}
|
||||
return "forbidden";
|
||||
};
|
||||
|
||||
export const canCreateModels = (actor: ModelActor, limits: ModelCreationLimits): boolean =>
|
||||
modelCreationScope(actor, limits) !== "forbidden";
|
||||
|
||||
export interface ModelRowOrigin {
|
||||
teamId: string | null | undefined;
|
||||
/** False for config.yaml rows, which update and delete both refuse whoever asks. */
|
||||
isDbModel: boolean;
|
||||
}
|
||||
|
||||
/** May this actor edit or delete this specific deployment? */
|
||||
export const canModifyModel = (
|
||||
{ userRole, userID }: ModelActor,
|
||||
teams: Team[] | null,
|
||||
{ teamId, isDbModel }: ModelRowOrigin,
|
||||
): boolean => {
|
||||
if (!isDbModel) {
|
||||
return false;
|
||||
}
|
||||
if (userRole != null && isProxyAdminRole(userRole)) {
|
||||
return true;
|
||||
}
|
||||
if (userID == null || teamId == null) {
|
||||
return false;
|
||||
}
|
||||
return isTeamAdminOf(teams, userID, teamId);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue