mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(UI): add default model pin to complexity router UI (#36615)
* Add default model pin to complexity router UI A complexity router's default model was only ever derived from the tiers, so operators had no way to point the fallback at a model that is not first in the Simple or Medium tier. Adds a Default Model select that records an explicit pin. The pin is stored in complexity_router_config.default_model, which the backend already reads, and mirrored onto complexity_router_default_model on save. Both paths resolve through one helper that mirrors init_complexity_router_deployment: a pin wins, otherwise MEDIUM or SIMPLE. Recording the pin in the config keeps it distinguishable from a derived value, so a pin that happens to match the tiers survives a round trip instead of being read back as tier tracking. The edit modal only requires one non-empty tier, so a router with models in COMPLEX alone can reach save with nothing the backend would pick. That now blocks with an inline message rather than saving a router that raises at init. * fix(UI): probe the pinned default model in the auto router connection test The connection test built its targets from the tiers and the embedding model only, so a Default Model pin outside every tier was never reached and a green result could hide an unreachable default. model_info_view had already hand rolled the dedupe and append locally, so the rule moved into buildAutoRouterTestTargets and both call sites now share it. * fix(ui): mirror backend precedence when resolving a complexity router default The edit modal only recognized a pin stored in complexity_router_config.default_model, so a router whose default lived solely in litellm_params.complexity_router_default_model lost it on the next save. That field cannot be trusted outright either: before this PR every save wrote a tier-derived value into it, so treating any value as a pin would freeze legacy routers away from their tiers. Hydration now takes the config marker as authoritative and falls back to litellm_params only when it diverges from what the tiers alone derive, which is only reachable through an external API or config write. Test Connection had the mirror-image bug: it fell back to complexity_router_config.default_model, a UI-only marker init_complexity_router_deployment never reads, so it could probe a model the router would never call. It now follows router.py exactly: litellm_params, else pure tier-derivation. Also reword a tooltip that hardcoded the Default Model select's position on the page, and document the dual write and the create-vs-edit validation asymmetry.
This commit is contained in:
parent
2bc87ec3cc
commit
5ecc6af541
15 changed files with 643 additions and 50 deletions
|
|
@ -54,8 +54,8 @@ interface ClassificationMethodConfigProps {
|
|||
customTechnicalKeywords?: string[];
|
||||
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
|
||||
showValidationErrors?: boolean;
|
||||
/** Enables the default-model fallback, which the backend rejects without a default model. */
|
||||
hasDefaultModel?: boolean;
|
||||
/** The resolved default model - see resolveComplexityDefaultModel. Names and gates the radio. */
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
||||
|
|
@ -65,8 +65,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
customTechnicalKeywords,
|
||||
onCustomTechnicalKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
hasDefaultModel = false,
|
||||
defaultModel,
|
||||
}) => {
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const classifierModelMissing =
|
||||
showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
|
|
@ -277,10 +278,14 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</Radio>
|
||||
<Radio value="default_model" disabled={!hasDefaultModel}>
|
||||
<Tooltip
|
||||
title={hasDefaultModel ? undefined : "Set a default model on this router to use this option"}
|
||||
title={
|
||||
hasDefaultModel
|
||||
? "Change it from the Default Model select."
|
||||
: "Set a default model on this router to use this option"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Text>Route to the default model</Text>{" "}
|
||||
<Text>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</Text>{" "}
|
||||
<Text type="secondary">— right when your prompt grades something other than complexity</Text>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -706,3 +706,76 @@ describe("ComplexityRouterConfig affinity panel", () => {
|
|||
expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig default model", () => {
|
||||
const getDefaultModelSelect = () => screen.getByRole("combobox", { name: "Default model" });
|
||||
|
||||
it("shows what the tiers currently imply, so an untouched router still names its default", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
expect(screen.getByText("Derived from tiers: gpt-3.5-turbo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for a model rather than naming a derived one when no tier holds one", () => {
|
||||
const noTiers: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={noTiers} />);
|
||||
expect(screen.getByText("Add a model to the Simple or Medium tier")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("records a pinned model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
|
||||
|
||||
await user.click(getDefaultModelSelect());
|
||||
await user.click((await screen.findAllByTitle("claude-3-opus")).slice(-1)[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: "claude-3-opus" }));
|
||||
});
|
||||
|
||||
it("drops the key when the pin is cleared, so an emptied select reads as tier-tracking", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" };
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinned} onChange={onChange} />);
|
||||
|
||||
await user.click(document.querySelector(".ant-select-clear") as HTMLElement);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: undefined }));
|
||||
});
|
||||
|
||||
it("shows a pinned model as the selection instead of the tier-derived one", () => {
|
||||
const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" };
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinned} />);
|
||||
expect(
|
||||
within(getDefaultModelSelect().closest(".ant-select") as HTMLElement).getByTitle("claude-3-opus"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unlocks the default model fallback on a pin alone, with no tier to derive from", () => {
|
||||
const pinnedNoTiers: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
default_model: "claude-3-opus",
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinnedNoTiers} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("names the resolved default on the fallback option, so the destination is not a guess", () => {
|
||||
const pinned: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
default_model: "claude-3-opus",
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinned} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /Route to the default model \(claude-3-opus\)/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React from "react";
|
|||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import EscalationKeywords from "./EscalationKeywords";
|
||||
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
|
||||
import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
||||
|
|
@ -89,6 +90,8 @@ export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>
|
|||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
classifier_type: ClassifierType;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: number;
|
||||
|
|
@ -174,11 +177,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onEscalationKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
// The deployment's default model is derived from the tiers on submit, mirroring the order
|
||||
// add_auto_router_tab uses, so the fallback option is offered exactly when one will exist.
|
||||
const hasDefaultModel = Boolean(
|
||||
value.tiers.MEDIUM[0] || value.tiers.SIMPLE[0] || value.tiers.COMPLEX[0] || value.tiers.REASONING[0],
|
||||
);
|
||||
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
|
||||
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
|
||||
|
||||
// Embedding models can't serve a chat-completion role, so they're excluded here.
|
||||
const modelOptions = modelInfo
|
||||
|
|
@ -195,6 +195,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
|
||||
// "track the tiers" everywhere downstream instead of as a blank model name.
|
||||
const handleDefaultModelChange = (model: string | undefined) => {
|
||||
onChange({ ...value, default_model: model || undefined });
|
||||
};
|
||||
|
||||
const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
|
|
@ -281,6 +287,36 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
Default Model
|
||||
</Text>
|
||||
<Tooltip title="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<AntdSelect
|
||||
value={value.default_model || undefined}
|
||||
onChange={handleDefaultModelChange}
|
||||
placeholder={
|
||||
derivedDefaultModel
|
||||
? `Derived from tiers: ${derivedDefaultModel}`
|
||||
: "Add a model to the Simple or Medium tier"
|
||||
}
|
||||
aria-label="Default model"
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
options={modelOptions}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 4, fontSize: 12 }}>
|
||||
Used when the tier the request lands in has no model, and when the classifier fails with "Route to the
|
||||
default model" selected.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
|
@ -304,7 +340,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
|
||||
showValidationErrors={showValidationErrors}
|
||||
hasDefaultModel={hasDefaultModel}
|
||||
defaultModel={defaultModel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -600,6 +600,71 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("default model pin", () => {
|
||||
const PINNED_MODEL = "pinned-default-model";
|
||||
|
||||
const waitForPresetEnabled = async (label: string) => {
|
||||
openTemplateDropdown();
|
||||
await waitFor(() => {
|
||||
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
|
||||
});
|
||||
};
|
||||
|
||||
const applyPresetAndPin = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitForPresetEnabled("Anthropic Family");
|
||||
fireEvent.click(optionByLabel("Anthropic Family")!);
|
||||
|
||||
// Applying a preset collapses Detailed Configuration, so the default model row is behind it.
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByRole("combobox", { name: "Default model" }));
|
||||
await user.click((await screen.findAllByTitle(PINNED_MODEL)).slice(-1)[0]);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchAvailableModels.mockResolvedValue([...ALL_FAMILY_MODELS, { model_group: PINNED_MODEL, mode: "chat" }]);
|
||||
});
|
||||
|
||||
it("submits the pinned model in place of the one the tiers derive", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await applyPresetAndPin(user);
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "pinned-router");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0];
|
||||
// The pin rides on litellm_params for the backend and is recorded in the config so the edit
|
||||
// modal can read it back as a pin rather than guessing from the tiers.
|
||||
expect(submitted).toMatchObject({
|
||||
auto_router_default_model: PINNED_MODEL,
|
||||
complexity_router_config: { tiers: ANTHROPIC_TIERS, default_model: PINNED_MODEL },
|
||||
});
|
||||
expect(PINNED_MODEL).not.toBe(ANTHROPIC_TIERS.MEDIUM[0]);
|
||||
});
|
||||
|
||||
it("blocks a submit whose pinned model is no longer available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderWithProviders(<Harness />);
|
||||
|
||||
await applyPresetAndPin(user);
|
||||
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "stale-pin-router" } });
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled();
|
||||
|
||||
// Only the pinned model disappears - the tier models all survive, so nothing but the pin can
|
||||
// be what blocks the submit.
|
||||
testQueryClient.setQueryData(["availableModels", "autoRouter", "token"], ALL_FAMILY_MODELS);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled());
|
||||
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(NotificationManager.fromBackend).toHaveBeenCalledWith(expect.stringContaining(PINNED_MODEL)),
|
||||
);
|
||||
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployment-matched presets", () => {
|
||||
const renamedDeploymentsFor = (presetKey: string) =>
|
||||
[...getRequiredModelsInPreset(getPresetByKey(presetKey)!)].map((model, index) => ({
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
getSemanticConfigError,
|
||||
getTierLabelsError,
|
||||
} from "./build_complexity_router_config";
|
||||
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
|
|
@ -89,9 +90,6 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai
|
|||
// this is resolved once at import time rather than re-called from inside the component every render.
|
||||
const presets = getAllPresets();
|
||||
|
||||
const resolveDefaultModel = (tiers: ComplexityTiers): string | undefined =>
|
||||
tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];
|
||||
|
||||
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
|
||||
// caller can see the shape of the config without opening it.
|
||||
const tierConfigSummary = (tiers: ComplexityTiers): string => {
|
||||
|
|
@ -272,6 +270,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
};
|
||||
|
||||
const submitBlockedReason = getSubmitBlockedReason(
|
||||
|
|
@ -283,6 +282,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
tierLabels: complexityRouterConfig.tier_labels,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
|
|
@ -353,7 +353,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const defaultModel = resolveDefaultModel(tiers);
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
|
||||
form.setFieldsValue({
|
||||
custom_llm_provider: "auto_router",
|
||||
|
|
@ -365,6 +365,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
form
|
||||
.validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
|
||||
.then((values) => {
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const submitValues = {
|
||||
...values,
|
||||
auto_router_name: name,
|
||||
|
|
@ -395,11 +399,13 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
const testTargetParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
});
|
||||
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
|
||||
};
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams);
|
||||
|
||||
if (targets.length === 0) {
|
||||
NotificationManager.fromBackend("Please select at least one model for a complexity tier");
|
||||
|
|
@ -621,7 +627,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<AutoRouterRoutingTest
|
||||
accessToken={accessToken}
|
||||
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
|
||||
defaultModel={resolveDefaultModel(complexityRouterConfig.tiers)}
|
||||
defaultModel={resolveComplexityDefaultModel(
|
||||
complexityRouterConfig.tiers,
|
||||
complexityRouterConfig.default_model,
|
||||
)}
|
||||
routerName={form.getFieldValue("auto_router_name")}
|
||||
teamId={requiresTeamScope ? form.getFieldValue("team_id") : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -77,4 +77,46 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
});
|
||||
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
|
||||
});
|
||||
|
||||
// A pin outside every tier is still a live fallback destination (empty-tier landings, and a
|
||||
// failed LLM classifier routing to it), so a passing test must reach it too or it is only
|
||||
// proving the tiers are reachable, not the router.
|
||||
it("appends the default model as its own target when it is not already in a tier", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers,
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
defaultModel: "claude-3-opus",
|
||||
});
|
||||
expect(targets).toEqual([
|
||||
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
|
||||
{ labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" },
|
||||
{ labels: ["REASONING"], modelGroup: "o3", mode: "chat" },
|
||||
{ labels: ["Default"], modelGroup: "claude-3-opus", mode: "chat" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a default model that a tier already covers", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers,
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
defaultModel: "claude-sonnet-4",
|
||||
});
|
||||
expect(targets).toEqual([
|
||||
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
|
||||
{ labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" },
|
||||
{ labels: ["REASONING"], modelGroup: "o3", mode: "chat" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
defaultModel,
|
||||
});
|
||||
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export interface BuildAutoRouterTestTargetsParams {
|
|||
tiers: ComplexityTiers;
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
|
||||
* so it is probed even when no tier lists it. */
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
// Keys drive iteration order; `satisfies Record<keyof ComplexityTiers, null>` makes it a
|
||||
|
|
@ -27,8 +30,9 @@ export const buildAutoRouterTestTargets = ({
|
|||
tiers,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel,
|
||||
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
|
||||
const groupedByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
|
||||
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
|
||||
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
|
||||
const modelGroup = rawModel?.trim();
|
||||
if (!modelGroup) return tierAcc;
|
||||
|
|
@ -36,6 +40,15 @@ export const buildAutoRouterTestTargets = ({
|
|||
}, acc);
|
||||
}, {});
|
||||
|
||||
// The default is a live destination whenever the chosen tier has no model, and when an LLM
|
||||
// classifier fails with "Route to the default model", so a green test that skipped it would be
|
||||
// reporting on a router it had not fully reached.
|
||||
const resolvedDefault = defaultModel?.trim();
|
||||
const groupedByModel =
|
||||
resolvedDefault && !(resolvedDefault in tieredByModel)
|
||||
? { ...tieredByModel, [resolvedDefault]: ["Default"] }
|
||||
: tieredByModel;
|
||||
|
||||
const tierTargets: AutoRouterTestTarget[] = Object.entries(groupedByModel).map(([modelGroup, labels]) => ({
|
||||
labels,
|
||||
modelGroup,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const normalizeClassifierLlmConfig = ({
|
|||
|
||||
export interface BuildComplexityRouterConfigParams {
|
||||
tiers: ComplexityTiers;
|
||||
defaultModel: string | undefined;
|
||||
tierLabels: ComplexityTierLabels | undefined;
|
||||
classifierType: ClassifierType;
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
|
|
@ -62,6 +63,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
|
||||
export interface ComplexityRouterConfigPayload {
|
||||
tiers: ComplexityTiers;
|
||||
default_model?: string;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
classifier_type: ClassifierType;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
|
|
@ -120,6 +122,12 @@ export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined)
|
|||
return null;
|
||||
};
|
||||
|
||||
// Requires all 4 tiers non-empty, so the create form can never reach the
|
||||
// resolveComplexityDefaultModel(tiers, ...) === undefined case — MEDIUM (or SIMPLE) is always
|
||||
// populated. The edit modal has no equivalent of this check (it allows saving with only some
|
||||
// tiers filled), which is why it needs its own explicit `!defaultModel` guard after deriving —
|
||||
// see edit_auto_router_modal.tsx's save handler. A future contributor copying this form's submit
|
||||
// handler elsewhere should not assume the same guarantee holds without this check.
|
||||
export const getMissingTiersError = (tiers: ComplexityTiers): string | null => {
|
||||
const missing = TIER_KEYS.filter((tier) => tiers[tier].length === 0);
|
||||
if (missing.length === 0) return null;
|
||||
|
|
@ -147,6 +155,7 @@ export const getSemanticConfigError = ({
|
|||
|
||||
export const buildComplexityRouterConfig = ({
|
||||
tiers,
|
||||
defaultModel,
|
||||
tierLabels,
|
||||
classifierType,
|
||||
classifierLlmConfig,
|
||||
|
|
@ -174,6 +183,7 @@ export const buildComplexityRouterConfig = ({
|
|||
|
||||
return {
|
||||
tiers,
|
||||
...(defaultModel?.trim() && { default_model: defaultModel }),
|
||||
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
|
||||
classifier_type: classifierType,
|
||||
...(classifierType === "llm" &&
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeTierModels } from "./complexity_router_tiers";
|
||||
import { normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
|
||||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
// The backend types a tier as `str | list[str]` and widens with
|
||||
// `models if isinstance(models, list) else [models]`
|
||||
|
|
@ -28,3 +30,43 @@ describe("normalizeTierModels", () => {
|
|||
expect(normalizeTierModels(value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// router.py derives the default as `MEDIUM or SIMPLE` and raises when neither holds a model, so
|
||||
// the resolver must not invent a COMPLEX/REASONING fallthrough the backend would never take.
|
||||
describe("resolveComplexityDefaultModel", () => {
|
||||
const tiers: ComplexityTiers = {
|
||||
SIMPLE: ["simple-model"],
|
||||
MEDIUM: ["medium-model"],
|
||||
COMPLEX: ["complex-model"],
|
||||
REASONING: ["reasoning-model"],
|
||||
};
|
||||
const noTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] };
|
||||
|
||||
it("derives from MEDIUM first when nothing is pinned", () => {
|
||||
expect(resolveComplexityDefaultModel(tiers)).toBe("medium-model");
|
||||
});
|
||||
|
||||
it("falls back to SIMPLE when MEDIUM is empty", () => {
|
||||
expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [] })).toBe("simple-model");
|
||||
});
|
||||
|
||||
it("derives nothing from COMPLEX or REASONING, which the backend never falls through to", () => {
|
||||
expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [], SIMPLE: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a pin beat the tiers rather than merely filling in for them", () => {
|
||||
expect(resolveComplexityDefaultModel(tiers, "pinned-model")).toBe("pinned-model");
|
||||
});
|
||||
|
||||
it("stands alone as the default when no tier holds a model", () => {
|
||||
expect(resolveComplexityDefaultModel(noTiers, "pinned-model")).toBe("pinned-model");
|
||||
});
|
||||
|
||||
it.each([[""], [" "], [undefined]])("reads %o as no pin and goes back to the tiers", (pinned) => {
|
||||
expect(resolveComplexityDefaultModel(tiers, pinned)).toBe("medium-model");
|
||||
});
|
||||
|
||||
it("resolves to nothing when neither a pin nor a tier offers a model", () => {
|
||||
expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
/**
|
||||
* A complexity tier maps to `str | list[str]` on the backend
|
||||
* (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"),
|
||||
|
|
@ -12,3 +14,11 @@ export const normalizeTierModels = (value: unknown): string[] => {
|
|||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors `init_complexity_router_deployment` (litellm/router.py): an explicit pin wins, otherwise
|
||||
* the default is `MEDIUM or SIMPLE`. Deriving past SIMPLE would name a model the backend never
|
||||
* picks, and it raises rather than falling through to COMPLEX/REASONING.
|
||||
*/
|
||||
export const resolveComplexityDefaultModel = (tiers: ComplexityTiers, pinned?: string): string | undefined =>
|
||||
pinned?.trim() || tiers.MEDIUM[0] || tiers.SIMPLE[0];
|
||||
|
|
|
|||
|
|
@ -548,3 +548,197 @@ describe("EditAutoRouterModal custom classifier prompt and fallback", () => {
|
|||
expect(savedConfig().classifier_llm_config).not.toHaveProperty("system_prompt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal default model", () => {
|
||||
beforeEach(() => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
||||
const savedDefaultModel = () => {
|
||||
const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? [];
|
||||
return payload?.litellm_params?.complexity_router_default_model;
|
||||
};
|
||||
|
||||
const renderWithStoredPin = (default_model?: string) =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: { ...STORED_CONFIG, ...(default_model && { default_model }) },
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// No config blob marker — only litellm_params.complexity_router_default_model, as an untouched
|
||||
// router looked before this PR's marker existed, or one an external API call wrote directly to.
|
||||
const renderWithLitellmParamsDefaultOnly = (complexityRouterDefaultModel: string) =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: STORED_CONFIG,
|
||||
complexity_router_default_model: complexityRouterDefaultModel,
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
it("preserves a stored pin through an untouched open-and-save", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredPin("out-of-band-default");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedDefaultModel()).toBe("out-of-band-default");
|
||||
expect(savedConfig()).toMatchObject({ default_model: "out-of-band-default" });
|
||||
});
|
||||
|
||||
it("shows a stored pin as the selection, so the saved value is not a hidden one", async () => {
|
||||
renderWithStoredPin("out-of-band-default");
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("out-of-band-default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The pin is recorded in the config rather than inferred by comparing the stored default to a
|
||||
// re-derivation, so pinning the model the tiers already imply still reads back as a pin.
|
||||
it("keeps a pin that matches what the tiers derive", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredPin(STORED_CONFIG.tiers.MEDIUM[0]);
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(
|
||||
within(select.closest(".ant-select") as HTMLElement).getByTitle(STORED_CONFIG.tiers.MEDIUM[0]),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig()).toMatchObject({ default_model: STORED_CONFIG.tiers.MEDIUM[0] });
|
||||
});
|
||||
|
||||
// Greptile P1 on #36615: with no config blob marker, a litellm_params default that merely
|
||||
// matches what the tiers derive is indistinguishable from the pre-PR auto-derive-and-write
|
||||
// behavior (main always wrote a tier-derived value there on every save). Treating it as a pin
|
||||
// would freeze every pre-existing router's default away from its tiers, so it stays unpinned.
|
||||
it("treats a litellm_params default matching tier-derivation as unpinned, not a frozen-in pin", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithLitellmParamsDefaultOnly(STORED_CONFIG.tiers.MEDIUM[0]);
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(select.closest(".ant-select")?.querySelector(".ant-select-selection-item")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig()).not.toHaveProperty("default_model");
|
||||
expect(savedDefaultModel()).toBe(STORED_CONFIG.tiers.MEDIUM[0]);
|
||||
});
|
||||
|
||||
// Greptile P1 on #36615: a litellm_params default that diverges from tier-derivation could only
|
||||
// have gotten there via an explicit override — set by the API directly, since this UI's own
|
||||
// save path keeps it in sync with tiers whenever there's no pin. That divergence must survive
|
||||
// the next save instead of being silently recomputed away.
|
||||
it("treats a diverging litellm_params default as an external pin and preserves it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithLitellmParamsDefaultOnly("claude-sonnet-4");
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("claude-sonnet-4")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig()).toMatchObject({ default_model: "claude-sonnet-4" });
|
||||
expect(savedDefaultModel()).toBe("claude-sonnet-4");
|
||||
});
|
||||
|
||||
// The config blob marker is this UI's own authoritative record of intent (see
|
||||
// hydratePinnedDefaultModel), so it wins even over a litellm_params value that disagrees —
|
||||
// e.g. a stale value from before the operator most recently changed the pin.
|
||||
it("prefers the config blob marker over a diverging litellm_params value", 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, default_model: "blob-pin" },
|
||||
complexity_router_default_model: "stale-litellm-params-value",
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("blob-pin")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig()).toMatchObject({ default_model: "blob-pin" });
|
||||
});
|
||||
|
||||
// This modal only requires one non-empty tier, so a COMPLEX-only router is reachable here even
|
||||
// though the backend raises on it. The block keeps that failure at save time instead of init.
|
||||
it("blocks a save when neither the tiers nor a pin give the backend a default", 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,
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: ["complex-model"], REASONING: [] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(expect.stringContaining("Simple or Medium tier")),
|
||||
);
|
||||
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a router with no stored pin tracking its tiers", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredPin();
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Default model" });
|
||||
expect(select.closest(".ant-select")?.querySelector(".ant-select-selection-item")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedDefaultModel()).toBe(STORED_CONFIG.tiers.MEDIUM[0]);
|
||||
expect(savedConfig()).not.toHaveProperty("default_model");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { 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 { normalizeTierModels, resolveComplexityDefaultModel } from "../add_model/complexity_router_tiers";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import {
|
||||
getKeywordTierRulesError,
|
||||
|
|
@ -19,6 +19,7 @@ import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
|
|||
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
|
|
@ -48,6 +49,7 @@ interface EditAutoRouterModalProps {
|
|||
// actually renders a control that can set it.
|
||||
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"default_model",
|
||||
"tier_labels",
|
||||
"classifier_type",
|
||||
"classifier_llm_config",
|
||||
|
|
@ -81,6 +83,24 @@ const toRecord = (value: unknown): Record<string, unknown> => {
|
|||
: {};
|
||||
};
|
||||
|
||||
// A pin lives in two places: complexity_router_config.default_model (this UI's own marker, added
|
||||
// by PR #36615) and litellm_params.complexity_router_default_model (what the backend reads). Only
|
||||
// the marker proves an operator picked it, because before #36615 every save wrote a tier-derived
|
||||
// value into litellm_params. So with no marker, a litellm_params value counts as a pin only when
|
||||
// it diverges from what the tiers alone derive; a match stays unpinned and keeps tracking tiers.
|
||||
export const hydratePinnedDefaultModel = (
|
||||
storedConfigDefaultModel: unknown,
|
||||
litellmParamsDefaultModel: string | null | undefined,
|
||||
tiers: ComplexityTiers,
|
||||
): string | undefined => {
|
||||
if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) {
|
||||
return storedConfigDefaultModel;
|
||||
}
|
||||
const tierDerived = resolveComplexityDefaultModel(tiers);
|
||||
const externalOverride = litellmParamsDefaultModel?.trim();
|
||||
return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined;
|
||||
};
|
||||
|
||||
export interface KeywordMatchingState {
|
||||
keywordTierRules: KeywordTierRule[];
|
||||
escalationKeywords: string[];
|
||||
|
|
@ -109,6 +129,7 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
return {
|
||||
...preservedConfig,
|
||||
tiers: value.tiers,
|
||||
...(value.default_model?.trim() && { default_model: value.default_model }),
|
||||
...(serializedTierLabels && { tier_labels: serializedTierLabels }),
|
||||
classifier_type: value.classifier_type,
|
||||
...(value.classifier_type === "llm" && value.classifier_llm_config
|
||||
|
|
@ -236,13 +257,20 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
parsedConfig = JSON.parse(parsedConfig);
|
||||
}
|
||||
|
||||
const hydratedTiers: ComplexityTiers = {
|
||||
SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
};
|
||||
|
||||
const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = {
|
||||
tiers: {
|
||||
SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
},
|
||||
tiers: hydratedTiers,
|
||||
default_model: hydratePinnedDefaultModel(
|
||||
parsedConfig.default_model,
|
||||
modelData.litellm_params?.complexity_router_default_model,
|
||||
hydratedTiers,
|
||||
),
|
||||
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
|
||||
classifier_type: parsedConfig.classifier_type || "heuristic",
|
||||
classifier_llm_config: parsedConfig.classifier_llm_config,
|
||||
|
|
@ -362,7 +390,23 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];
|
||||
// Unlike the create form, this modal only requires one non-empty tier, so a router can reach
|
||||
// here with nothing the backend would pick as a default (see getMissingTiersError in
|
||||
// build_complexity_router_config.ts for why create never can). init_complexity_router_deployment
|
||||
// raises in that case (litellm/router.py), so block it rather than saving a router that
|
||||
// fails at init.
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
if (!defaultModel) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationsManager.fromBackend(
|
||||
"Add a model to the Simple or Medium tier, or pin a default model, so requests have somewhere to route.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
complexity_router_config: buildUpdatedComplexityRouterConfig(
|
||||
|
|
|
|||
|
|
@ -1056,6 +1056,44 @@ describe("ModelInfoView", () => {
|
|||
expect(mockTestModelGroupConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Bugbot finding on #36615: complexity_router_config.default_model is a UI-only bookkeeping
|
||||
// marker — init_complexity_router_deployment (litellm/router.py) never reads it, falling back
|
||||
// to tier-derivation instead when litellm_params.complexity_router_default_model is absent.
|
||||
// Probing the blob field here would test a model the running router never calls.
|
||||
it("ignores an unused config blob pin when litellm_params has no default, matching the backend's own tier-derivation fallback", async () => {
|
||||
const complexityRouterModelData = {
|
||||
...defaultModelData,
|
||||
litellm_params: {
|
||||
...defaultModelData.litellm_params,
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
default_model: "unused-blob-pin",
|
||||
},
|
||||
// no complexity_router_default_model
|
||||
},
|
||||
};
|
||||
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: {
|
||||
data: [complexityRouterModelData],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
mockTestModelGroupConnection.mockResolvedValue({ status: "success" });
|
||||
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
const testConnectionButton = await screen.findByTestId("test-connection-button");
|
||||
await userEvent.click(testConnectionButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestModelGroupConnection).toHaveBeenCalledWith("test-token", "gpt-4o-mini", "chat");
|
||||
});
|
||||
expect(mockTestModelGroupConnection).not.toHaveBeenCalledWith("test-token", "unused-blob-pin", "chat");
|
||||
expect(mockTestModelGroupConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display model access groups field", async () => {
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ 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 { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers";
|
||||
import {
|
||||
hasAutoRouterEditor,
|
||||
isAutoRouterDeployment,
|
||||
|
|
@ -153,6 +153,7 @@ interface ComplexityRouterTierConfig {
|
|||
};
|
||||
semantic_keyword_matching?: boolean;
|
||||
embedding_model?: string;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
interface ComplexityRouterModelData {
|
||||
|
|
@ -177,25 +178,26 @@ const buildComplexityRouterTestTargets = (
|
|||
config = rawConfig;
|
||||
}
|
||||
|
||||
const tierTargets = buildAutoRouterTestTargets({
|
||||
tiers: {
|
||||
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(config.tiers?.REASONING),
|
||||
},
|
||||
const tiers = {
|
||||
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(config.tiers?.REASONING),
|
||||
};
|
||||
|
||||
// Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise
|
||||
// pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend
|
||||
// never reads — folding it in here could point Test Connection at a model the router never
|
||||
// calls (see PR #36615 discussion).
|
||||
const effectiveDefaultModel = modelData?.litellm_params?.complexity_router_default_model || undefined;
|
||||
|
||||
const testTargetParams = {
|
||||
tiers,
|
||||
semanticMatchingEnabled: Boolean(config.semantic_keyword_matching),
|
||||
embeddingModel: config.embedding_model,
|
||||
});
|
||||
|
||||
const defaultModel = modelData?.litellm_params?.complexity_router_default_model?.trim();
|
||||
if (!defaultModel || tierTargets.some((target) => target.modelGroup === defaultModel)) {
|
||||
return tierTargets;
|
||||
}
|
||||
return [
|
||||
...tierTargets,
|
||||
{ labels: ["Default (unconfigured tiers)"], modelGroup: defaultModel, mode: "chat" as const },
|
||||
];
|
||||
defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel),
|
||||
};
|
||||
return buildAutoRouterTestTargets(testTargetParams);
|
||||
};
|
||||
|
||||
export default function ModelInfoView({
|
||||
|
|
|
|||
|
|
@ -40,10 +40,18 @@ export const getPresetByKey = (key: string): AutoRouterPreset | undefined => PRE
|
|||
// bundled config or a caller's actually-built config - the two need to agree, since a preset only
|
||||
// prefills once and the config is edited freely after (see AddAutoRouterTab.submitBlockedReason).
|
||||
export const getRequiredModels = (
|
||||
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model">,
|
||||
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model" | "default_model">,
|
||||
): Set<string> => {
|
||||
const { tiers, classifier_llm_config: classifier, embedding_model: embedding } = config;
|
||||
const models = [...tiers.SIMPLE, ...tiers.MEDIUM, ...tiers.COMPLEX, ...tiers.REASONING, classifier?.model, embedding];
|
||||
const { tiers, classifier_llm_config: classifier, embedding_model: embedding, default_model: pinned } = config;
|
||||
const models = [
|
||||
...tiers.SIMPLE,
|
||||
...tiers.MEDIUM,
|
||||
...tiers.COMPLEX,
|
||||
...tiers.REASONING,
|
||||
classifier?.model,
|
||||
embedding,
|
||||
pinned,
|
||||
];
|
||||
// Boolean(), not != null: an empty-string placeholder (e.g. classifier_llm_config seeded before a
|
||||
// model is chosen) is never a real model reference either.
|
||||
return new Set(models.filter((model): model is string => Boolean(model)));
|
||||
|
|
@ -164,7 +172,7 @@ const resolveAvailableModel = (requiredModel: string, availability: ModelAvailab
|
|||
};
|
||||
|
||||
export const getMissingModels = (
|
||||
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model">,
|
||||
config: Parameters<typeof getRequiredModels>[0],
|
||||
availability: ModelAvailability,
|
||||
): string[] =>
|
||||
[...getRequiredModels(config)].filter((model) => resolveAvailableModel(model, availability) === undefined).sort();
|
||||
|
|
@ -188,12 +196,14 @@ export const getReferencedModelsError = (
|
|||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
defaultModel?: string;
|
||||
},
|
||||
availability: ModelAvailability,
|
||||
): string | null => {
|
||||
const missing = getMissingModels(
|
||||
{
|
||||
tiers: params.tiers,
|
||||
default_model: params.defaultModel,
|
||||
classifier_llm_config: params.classifierType === "llm" ? params.classifierLlmConfig : undefined,
|
||||
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue