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.
This commit is contained in:
Tin Chi Lo 2026-08-11 21:55:56 -07:00
parent 50ff555d79
commit b6f9169c47
11 changed files with 397 additions and 23 deletions

View file

@ -49,8 +49,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> = ({
@ -60,8 +60,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;
@ -227,10 +228,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 in the Default Model row above the advanced sections."
: "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>

View file

@ -631,3 +631,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();
});
});

View file

@ -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";
@ -50,6 +51,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;
@ -135,11 +138,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
@ -156,6 +156,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,
@ -242,6 +248,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 &quot;Route to the
default model&quot; selected.
</Text>
</div>
</Card>
<Divider />
@ -265,7 +301,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
showValidationErrors={showValidationErrors}
hasDefaultModel={hasDefaultModel}
defaultModel={defaultModel}
/>
),
},

View file

@ -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) => ({

View file

@ -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",
@ -621,7 +621,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}
/>

View file

@ -22,6 +22,7 @@ export const normalizeClassifierLlmConfig = (config: ClassifierLLMConfig): Class
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
defaultModel: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
@ -46,6 +47,7 @@ export interface BuildComplexityRouterConfigParams {
export interface ComplexityRouterConfigPayload {
tiers: ComplexityTiers;
default_model?: string;
tier_labels?: ComplexityTierLabels;
classifier_type: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
@ -131,6 +133,7 @@ export const getSemanticConfigError = ({
export const buildComplexityRouterConfig = ({
tiers,
defaultModel,
tierLabels,
classifierType,
classifierLlmConfig,
@ -158,6 +161,7 @@ export const buildComplexityRouterConfig = ({
return {
tiers,
...(defaultModel?.trim() && { default_model: defaultModel }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(classifierType === "llm" &&

View file

@ -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();
});
});

View file

@ -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];

View file

@ -548,3 +548,111 @@ 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"
/>,
);
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] });
});
// 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");
});
});

View file

@ -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,
@ -48,6 +48,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 +82,9 @@ const toRecord = (value: unknown): Record<string, unknown> => {
: {};
};
export const hydratePinnedDefaultModel = (stored: unknown): string | undefined =>
typeof stored === "string" && stored.trim() ? stored : undefined;
export interface KeywordMatchingState {
keywordTierRules: KeywordTierRule[];
escalationKeywords: string[];
@ -109,6 +113,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
@ -243,6 +248,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
},
default_model: hydratePinnedDefaultModel(parsedConfig.default_model),
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
classifier_llm_config: parsedConfig.classifier_llm_config,
@ -362,7 +368,19 @@ 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. 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;
}
const updatedLitellmParams = {
...modelData.litellm_params,
complexity_router_config: buildUpdatedComplexityRouterConfig(

View file

@ -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,
},