refactor(ui): read the auto-router tier set through one row list (#38408)

The dashboard resolved the complexity-router tier set three different ways: a
private TIER_KEYS in build_complexity_router_config.ts, TIER_ORDER in
complexity_router_tiers.ts, and TIER_KEYS in ComplexityRouterConfig.tsx. The
edit modal went further and re-implemented the whole create payload builder,
kept in sync only by a comment reading "Mirrors buildComplexityRouterConfig".

tier_rows.ts now owns the tier set. Every consumer reads activeTierRows(value)
and a row carries its own id, so the plan-mode floor and per-model params point
at a row rather than at a position, and the leaves that already wanted entries
(buildAutoRouterTestTargets, getRequiredModels, model_info_view) take them.
buildUpdatedComplexityRouterConfig becomes preserve-unmanaged-keys around the
shared builder instead of a second copy of it.

Also drops the literal ", ]" that renders as visible text in two DialogFooter
blocks on the auto-router routing-test and connection-test dialogs, left over
from a JSX array-to-fragment conversion.

No behaviour change: all 566 tests over the touched modules pass with fixture
shape changes only, no assertion edited.
This commit is contained in:
tin-berri 2026-08-26 14:53:06 -07:00 committed by GitHub
parent 4148cf7d7d
commit f0a122d35f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 260 additions and 219 deletions

View file

@ -17,15 +17,14 @@ import {
ReasoningEffort,
TierModelParamsByTier,
pruneTierModelParams,
resolveComplexityDefaultModel,
setTierModelReasoningEffort,
tierOptions,
} from "./complexity_router_tiers";
import TierModelEffortRows from "./TierModelEffortRows";
import EscalationKeywords from "./EscalationKeywords";
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
import SemanticKeywordMatching from "./SemanticKeywordMatching";
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
import { type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
export type { DimensionWeights, TierBoundaries, TokenThresholds };
@ -37,12 +36,12 @@ export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120;
export const DEFAULT_SESSION_AFFINITY = false;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
export interface ComplexityTiers {
export type ComplexityTiers = {
SIMPLE: string[];
MEDIUM: string[];
COMPLEX: string[];
REASONING: string[];
}
};
export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business";
@ -224,10 +223,6 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array<keyof Complexit
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label;
/** Tiers the plan-mode floor may name: the backend rejects a floor whose tier has no models. */
export const planModeEligibleTiers = (tiers: ComplexityTiers): Array<keyof ComplexityTiers> =>
TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
@ -246,12 +241,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
const planModeTiers = planModeEligibleTiers(value.tiers);
const planModeTierOptions = tierOptions(value.tier_labels).filter((option) =>
(planModeTiers as string[]).includes(option.value),
);
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
const tierRows = activeTierRows(value);
const planModeTierOptions = tierRows
.filter((row) => row.models.length > 0)
.map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) }));
const derivedDefaultModel = resolveComplexityDefaultModel(value);
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
// An absent list means the proxy does not send the field yet, so every level is offered as before.
// An empty list is the group's own answer that its deployments share no level, and is left empty.
@ -325,12 +320,13 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Card>
<CardContent>
{TIER_KEYS.map((tier, index) => {
{tierRows.map((row: TierRow, index) => {
const tier = row.id as keyof ComplexityTiers;
const tierInfo = TIER_DESCRIPTIONS[tier];
const label = effectiveTierLabel(tier, value.tier_labels);
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
const tierMissing = showValidationErrors && row.models.length === 0;
return (
<div key={tier}>
<div key={row.id}>
{index > 0 && <Separator className="my-4" />}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
@ -339,7 +335,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
<span className="text-xs text-muted-foreground">
Tier {index + 1} of {TIER_KEYS.length} &middot; {tier}
Tier {index + 1} of {tierRows.length} &middot; {row.id}
</span>
</div>
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
@ -364,7 +360,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</InputGroup>
<MultiSelect
options={modelOptions}
value={value.tiers[tier]}
value={row.models}
onValueChange={(models: string[]) => handleTierChange(tier, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
@ -372,12 +368,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
/>
<TierModelEffortRows
tierLabel={label}
models={value.tiers[tier]}
models={row.models}
effortOptionsByModel={effortOptionsByModel}
paramsByModel={value.tier_model_params?.[tier]}
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
/>
{value.tiers[tier].length > 1 && (
{row.models.length > 1 && (
<span className="text-xs text-muted-foreground">
Multiple models selected the router randomly picks among them per request (or Thompson-samples
within the pool when adaptive routing is on).
@ -483,9 +479,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTiers.length === 0}
disabled={planModeTierOptions.length === 0}
onCheckedChange={(enabled) =>
onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
onChange({
...value,
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
})
}
aria-label="Route plan-mode requests to a minimum tier"
/>
@ -494,7 +493,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<span className="block text-xs mb-3 text-muted-foreground">
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier.
The classifier still wins when it picks higher, and the override only lasts while plan mode is active.
{planModeTiers.length === 0 && " Add models to a tier to enable this."}
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
</span>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>

View file

@ -22,7 +22,6 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
ComplexityTiers,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
@ -40,7 +39,9 @@ import {
getSemanticConfigError,
getTierLabelsError,
} from "./build_complexity_router_config";
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers";
import type { ComplexityTier } from "./KeywordTierRules";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
@ -104,17 +105,10 @@ const presets = getAllPresets();
// 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 => {
const parts = (
[
["Simple", tiers.SIMPLE],
["Medium", tiers.MEDIUM],
["Complex", tiers.COMPLEX],
["Reasoning", tiers.REASONING],
] as const
)
.filter(([, models]) => models.length > 0)
.map(([label, models]) => `${label}: ${models.join(", ")}`);
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
const parts = activeTierRows(config)
.filter((row) => row.models.length > 0)
.map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`);
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
};
@ -128,9 +122,9 @@ const getSubmitBlockedReason = (
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
availability: ModelAvailability,
): string | null =>
getMissingTiersError(config.tiers) ??
getMissingTiersError(activeTierRows(config)) ??
getTierLabelsError(config.tier_labels) ??
getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules) ??
getReferencedModelsError(referencedModelsParams, availability);
@ -378,7 +372,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const submitRecommendedRouter = async (name: string) => {
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
const missingTiersError = getMissingTiersError(tiers);
const missingTiersError = getMissingTiersError(activeTierRows(complexityRouterConfig));
if (missingTiersError) {
setShowValidationErrors(true);
toast.fromError(missingTiersError);
@ -423,7 +417,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model);
const validatedFields = requiresTeamScope
? (["auto_router_name", "team_id"] as const)
: (["auto_router_name"] as const);
@ -463,10 +457,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const handleTestConnection = () => {
const testTargetParams = {
tiers: complexityRouterConfig.tiers,
tiers: activeTierRows(complexityRouterConfig).map(
(row) => [activeTierName(row), row.models] as [string, string[]],
),
semanticMatchingEnabled,
embeddingModel,
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model),
};
const targets = buildAutoRouterTestTargets(testTargetParams);
@ -581,7 +577,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</span>
{!detailsExpanded && (
<span className="text-xs text-muted-foreground line-clamp-2">
{tierConfigSummary(complexityRouterConfig.tiers)}
{tierConfigSummary(complexityRouterConfig)}
</span>
)}
</button>
@ -694,10 +690,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<AutoRouterRoutingTest
accessToken={accessToken}
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
defaultModel={resolveComplexityDefaultModel(
complexityRouterConfig.tiers,
complexityRouterConfig.default_model,
)}
defaultModel={resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model)}
routerName={watchedName}
teamId={requiresTeamScope ? watchedTeamId : undefined}
/>
@ -707,7 +700,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<Button variant="outline" onClick={() => setIsRoutingTestVisible(false)}>
Close
</Button>
, ]
</DialogFooter>
</DialogContent>
</Dialog>
@ -744,7 +736,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
>
Close
</Button>
, ]
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,11 +1,18 @@
import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
const tiers = {
SIMPLE: ["gpt-4o-mini"],
MEDIUM: ["claude-sonnet-4"],
COMPLEX: ["claude-sonnet-4"],
REASONING: ["o3"],
};
const tierEntries = (
SIMPLE: string[],
MEDIUM: string[] = [],
COMPLEX: string[] = [],
REASONING: string[] = [],
): [string, string[]][] => [
["SIMPLE", SIMPLE],
["MEDIUM", MEDIUM],
["COMPLEX", COMPLEX],
["REASONING", REASONING],
];
const tiers = tierEntries(["gpt-4o-mini"], ["claude-sonnet-4"], ["claude-sonnet-4"], ["o3"]);
describe("buildAutoRouterTestTargets", () => {
it("dedups tiers that share a model group into one chat target carrying both labels", () => {
@ -19,7 +26,7 @@ describe("buildAutoRouterTestTargets", () => {
it("emits a target per model when a tier has more than one, and dedups across tiers", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini", "claude-sonnet-4"], ["claude-sonnet-4"]),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
@ -31,7 +38,7 @@ describe("buildAutoRouterTestTargets", () => {
it("drops empty/whitespace tiers", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini"], [], [" "]),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
@ -41,7 +48,7 @@ describe("buildAutoRouterTestTargets", () => {
it("returns [] when no tier is configured", () => {
expect(
buildAutoRouterTestTargets({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: tierEntries([]),
semanticMatchingEnabled: false,
embeddingModel: undefined,
}),
@ -50,7 +57,7 @@ describe("buildAutoRouterTestTargets", () => {
it("appends an embedding target only when semantic matching is on and a model is set", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini"]),
semanticMatchingEnabled: true,
embeddingModel: "voyage-3-5",
});
@ -62,7 +69,7 @@ describe("buildAutoRouterTestTargets", () => {
it("omits the embedding target when semantic matching is on but no model is chosen", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini"]),
semanticMatchingEnabled: true,
embeddingModel: undefined,
});
@ -71,7 +78,7 @@ describe("buildAutoRouterTestTargets", () => {
it("omits the embedding target when a model is set but semantic matching is off", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini"]),
semanticMatchingEnabled: false,
embeddingModel: "voyage-3-5",
});
@ -112,7 +119,7 @@ describe("buildAutoRouterTestTargets", () => {
it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: tierEntries(["gpt-4o-mini"]),
semanticMatchingEnabled: false,
embeddingModel: undefined,
defaultModel,

View file

@ -1,5 +1,3 @@
import { ComplexityTiers } from "./ComplexityRouterConfig";
export type AutoRouterTestMode = "chat" | "embedding";
export interface AutoRouterTestTarget {
@ -9,7 +7,8 @@ export interface AutoRouterTestTarget {
}
export interface BuildAutoRouterTestTargetsParams {
tiers: ComplexityTiers;
/** Ordered [tier name, model groups] entries of the active tier set. */
tiers: readonly (readonly [string, string[]])[];
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
@ -17,23 +16,14 @@ export interface BuildAutoRouterTestTargetsParams {
defaultModel?: string;
}
// Keys drive iteration order; `satisfies Record<keyof ComplexityTiers, null>` makes it a
// compile error to add a tier to ComplexityTiers without listing it here (and vice versa).
const TIER_ORDER = Object.keys({
SIMPLE: null,
MEDIUM: null,
COMPLEX: null,
REASONING: null,
} satisfies Record<keyof ComplexityTiers, null>) as (keyof ComplexityTiers)[];
export const buildAutoRouterTestTargets = ({
tiers,
semanticMatchingEnabled,
embeddingModel,
defaultModel,
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
const tieredByModel = tiers.reduce<Record<string, string[]>>((acc, [tier, models]) => {
return models.reduce((tierAcc, rawModel) => {
const modelGroup = rawModel?.trim();
if (!modelGroup) return tierAcc;
return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] };

View file

@ -9,6 +9,7 @@ import {
hydrateTierLabels,
BuildComplexityRouterConfigParams,
} from "./build_complexity_router_config";
import { activeTierRows } from "./tier_rows";
const tiers = {
SIMPLE: ["gpt-4o-mini"],
@ -275,30 +276,30 @@ describe("buildComplexityRouterConfig", () => {
describe("getMissingTiersError", () => {
it("returns null when all four tiers have a model", () => {
expect(getMissingTiersError(tiers)).toBeNull();
expect(getMissingTiersError(activeTierRows({ tiers: tiers }))).toBeNull();
});
it("names the specific missing tier when only one is blank", () => {
expect(getMissingTiersError({ ...tiers, REASONING: [] })).toBe(
expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, REASONING: [] } }))).toBe(
"Select a model for the following tier(s): REASONING",
);
});
it("names multiple missing tiers in SIMPLE/MEDIUM/COMPLEX/REASONING order", () => {
expect(getMissingTiersError({ ...tiers, SIMPLE: [], REASONING: [] })).toBe(
expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: [], REASONING: [] } }))).toBe(
"Select a model for the following tier(s): SIMPLE, REASONING",
);
});
it("names all four tiers when none are filled", () => {
const noTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] };
expect(getMissingTiersError(noTiers)).toBe(
expect(getMissingTiersError(activeTierRows({ tiers: noTiers }))).toBe(
"Select a model for the following tier(s): SIMPLE, MEDIUM, COMPLEX, REASONING",
);
});
it("treats a tier with more than one model as filled", () => {
expect(getMissingTiersError({ ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] })).toBeNull();
expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] } }))).toBeNull();
});
});
@ -645,15 +646,15 @@ describe("getPlanModeTierError", () => {
const tiersWithEmptyComplex = { SIMPLE: ["m1"], MEDIUM: ["m1"], COMPLEX: [], REASONING: [] };
it("passes when the override is off", () => {
expect(getPlanModeTierError(undefined, tiersWithEmptyComplex)).toBeNull();
expect(getPlanModeTierError(undefined, activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull();
});
it("passes when the named tier has models", () => {
expect(getPlanModeTierError("MEDIUM", tiersWithEmptyComplex)).toBeNull();
expect(getPlanModeTierError("MEDIUM", activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull();
});
it("blocks a tier whose models were removed, which the backend would reject with a 400", () => {
expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX");
expect(getPlanModeTierError("COMPLEX", activeTierRows({ tiers: tiersWithEmptyComplex }))).toContain("COMPLEX");
});
});

View file

@ -1,4 +1,5 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { type TierRow, activeTierName, tierRowById } from "./tier_rows";
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers";
import {
@ -10,6 +11,7 @@ import {
ComplexityTierLabels,
ComplexityTiers,
DimensionWeights,
TIER_KEYS,
TIER_DESCRIPTIONS,
TierBoundaries,
TokenThresholds,
@ -135,8 +137,6 @@ export interface ComplexityRouterConfigPayload {
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
}
const TIER_KEYS: Array<keyof ComplexityTiers> = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => {
const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter(
([tier, label]) => label !== "" && label !== TIER_DESCRIPTIONS[tier].label,
@ -171,26 +171,20 @@ 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);
// Requires every active tier non-empty, so the create form can never reach the
// resolveComplexityDefaultModel === undefined case. The edit modal allows a partially filled
// set, which is why it keeps its own !defaultModel guard after deriving.
export const getMissingTiersError = (rows: readonly TierRow[]): string | null => {
const missing = rows.filter((row) => row.models.length === 0).map(activeTierName);
if (missing.length === 0) return null;
return `Select a model for the following tier(s): ${missing.join(", ")}`;
};
// The backend rejects a plan-mode floor naming a tier with no models. The create form's
// getMissingTiersError makes this unreachable there; the edit modal allows partially filled
// tiers, so both gates call this to keep the two forms symmetric.
export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers: ComplexityTiers): string | null => {
export const getPlanModeTierError = (planModeMinTier: string | undefined, rows: readonly TierRow[]): string | null => {
if (!planModeMinTier) return null;
const models = tiers[planModeMinTier as keyof ComplexityTiers] ?? [];
if (models.length > 0) return null;
return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`;
const floor = tierRowById(rows, planModeMinTier);
if (floor && floor.models.length > 0) return null;
return `The plan-mode minimum tier (${floor ? activeTierName(floor) : planModeMinTier}) has no models. Add one or turn the override off.`;
};
export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => {

View file

@ -4,10 +4,10 @@ import {
hydrateTierModelParams,
normalizeTierModels,
pruneTierModelParams,
resolveComplexityDefaultModel,
serializeTierModelConfigs,
setTierModelReasoningEffort,
} from "./complexity_router_tiers";
import { resolveComplexityDefaultModel } from "./tier_rows";
import type { ComplexityTiers } from "./ComplexityRouterConfig";
@ -50,31 +50,31 @@ describe("resolveComplexityDefaultModel", () => {
const noTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] };
it("derives from MEDIUM first when nothing is pinned", () => {
expect(resolveComplexityDefaultModel(tiers)).toBe("medium-model");
expect(resolveComplexityDefaultModel({ tiers: tiers })).toBe("medium-model");
});
it("falls back to SIMPLE when MEDIUM is empty", () => {
expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [] })).toBe("simple-model");
expect(resolveComplexityDefaultModel({ tiers: { ...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();
expect(resolveComplexityDefaultModel({ tiers: { ...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");
expect(resolveComplexityDefaultModel({ tiers: 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");
expect(resolveComplexityDefaultModel({ tiers: 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");
expect(resolveComplexityDefaultModel({ tiers: tiers }, pinned)).toBe("medium-model");
});
it("resolves to nothing when neither a pin nor a tier offers a model", () => {
expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined();
expect(resolveComplexityDefaultModel({ tiers: noTiers })).toBeUndefined();
});
});

View file

@ -1,5 +1,5 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
import { TIER_ORDER } from "./tier_rows";
export type TierModelParams = Record<string, unknown>;
@ -80,13 +80,13 @@ export const hydrateTierModelParams = (
* tiers this editor does not render pass through rather than being dropped now the key is managed.
*/
export const serializeTierModelConfigs = (
tiers: ComplexityTiers,
tiers: Record<string, string[]>,
tierModelParams: TierModelParamsByTier | undefined,
): Record<string, { model_name: string; litellm_params: TierModelParams }[]> | undefined => {
if (tierModelParams === undefined) return undefined;
const serialized = Object.entries(tierModelParams)
.map(([tier, byModel]) => {
const selected = (TIER_ORDER as string[]).includes(tier) ? new Set(tiers[tier as ComplexityTier]) : undefined;
const selected = tier in tiers ? new Set(tiers[tier]) : undefined;
const entries = Object.entries(byModel)
.filter(([model, params]) => (selected === undefined || selected.has(model)) && Object.keys(params).length > 0)
.map(([model_name, litellm_params]) => ({ model_name, litellm_params }));
@ -126,14 +126,6 @@ export const pruneTierModelParams = (
return Object.keys(next).length > 0 ? next : undefined;
};
/**
* 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];
export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
SIMPLE: "Simple",
MEDIUM: "Medium",
@ -141,8 +133,6 @@ export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
REASONING: "Reasoning",
};
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export const tierOptions = (
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
): { value: ComplexityTier; label: string }[] =>

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
activeTierName,
activeTierRows,
isBuiltInTierName,
resolveComplexityDefaultModel,
sameTierIdentity,
tierRowById,
tierRowByName,
} from "./tier_rows";
const tiers = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"] };
describe("activeTierRows", () => {
it("reads the tier set as rows whose id is the canonical tier key, in severity order", () => {
expect(activeTierRows({ tiers })).toEqual([
{ id: "SIMPLE", name: "SIMPLE", models: ["a"] },
{ id: "MEDIUM", name: "MEDIUM", models: ["b"] },
{ id: "COMPLEX", name: "COMPLEX", models: ["c"] },
{ id: "REASONING", name: "REASONING", models: ["d"] },
]);
});
it("gives a tier with no models an empty pool rather than dropping the row", () => {
expect(activeTierRows({ tiers: { ...tiers, COMPLEX: [] } })[2]).toEqual({
id: "COMPLEX",
name: "COMPLEX",
models: [],
});
});
it("finds a row by id and by name", () => {
const rows = activeTierRows({ tiers });
expect(tierRowById(rows, "MEDIUM")?.models).toEqual(["b"]);
expect(tierRowById(rows, undefined)).toBeUndefined();
expect(tierRowByName(rows, " medium ")?.id).toBe("MEDIUM");
});
});
describe("sameTierIdentity", () => {
it.each([
["AUDIT", "audit", true],
["AUDIT", " audit ", true],
["AUDIT", "AUDITS", false],
])("compares %s and %s casefold, matching the backend's uniqueness rule", (left, right, expected) => {
expect(sameTierIdentity(left, right)).toBe(expected);
});
it("recognises the four built-in names regardless of case", () => {
expect(["SIMPLE", "medium", "Complex", "REASONING"].every(isBuiltInTierName)).toBe(true);
expect(isBuiltInTierName("SECURITY_REVIEW")).toBe(false);
});
it("trims a row name, since the backend matches fallback_tier and keyword rules exactly", () => {
expect(activeTierName({ id: "1", name: " AUDIT ", models: [] })).toBe("AUDIT");
});
});
describe("resolveComplexityDefaultModel", () => {
it("mirrors init_complexity_router_deployment: a pin wins, then MEDIUM, then SIMPLE", () => {
expect(resolveComplexityDefaultModel({ tiers }, "pinned")).toBe("pinned");
expect(resolveComplexityDefaultModel({ tiers })).toBe("b");
expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [] } })).toBe("a");
});
it("resolves to nothing rather than falling through to COMPLEX, which the backend never picks", () => {
expect(resolveComplexityDefaultModel({ tiers: { ...tiers, SIMPLE: [], MEDIUM: [] } })).toBeUndefined();
});
});

View file

@ -0,0 +1,40 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export interface TierRow {
id: string;
name: string;
models: string[];
}
export interface ActiveTierSet {
tiers: ComplexityTiers;
}
export const activeTierName = (row: TierRow): string => row.name.trim();
export const sameTierIdentity = (left: string, right: string): boolean =>
left.trim().toLowerCase() === right.trim().toLowerCase();
export const isBuiltInTierName = (name: string): boolean => TIER_ORDER.some((tier) => sameTierIdentity(tier, name));
// The only reader of the tier set. A row's id is the canonical tier key, so anything pointing into
// the set (the plan-mode floor, per-model params) points at a row rather than at a position.
export const activeTierRows = (value: ActiveTierSet): TierRow[] =>
TIER_ORDER.map((tier) => ({ id: tier, name: tier, models: value.tiers[tier] ?? [] }));
export const tierRowById = (rows: readonly TierRow[], id: string | undefined): TierRow | undefined =>
id === undefined ? undefined : rows.find((row) => row.id === id);
export const tierRowByName = (rows: readonly TierRow[], name: string): TierRow | undefined =>
rows.find((row) => sameTierIdentity(row.name, name));
// Mirrors init_complexity_router_deployment (litellm/router.py): a pin wins, then MEDIUM or SIMPLE
// looked up by exact name.
export const resolveComplexityDefaultModel = (value: ActiveTierSet, pinned?: string): string | undefined => {
const rows = activeTierRows(value);
const named = (name: string) => rows.find((row) => activeTierName(row) === name)?.models[0];
return pinned?.trim() || named("MEDIUM") || named("SIMPLE");
};

View file

@ -14,25 +14,21 @@ import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceC
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import {
hydrateTierModelParams,
normalizeTierModels,
resolveComplexityDefaultModel,
serializeTierModelConfigs,
} from "../add_model/complexity_router_tiers";
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows";
import { isComplexityRouter } from "../add_model/auto_router_strategies";
import {
type BuildComplexityRouterConfigParams,
buildComplexityRouterConfig,
getKeywordTierRulesError,
getSemanticConfigError,
getPlanModeTierError,
getTierLabelsError,
hydrateTierLabels,
normalizeClassifierLlmConfig,
serializeTierLabels,
} 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 { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords";
import {
hydrateDimensionWeights,
hydrateReasoningOverrideMinScore,
@ -46,7 +42,6 @@ import ComplexityRouterConfig, {
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
heuristicScoringRole,
} from "../add_model/ComplexityRouterConfig";
import {
Dialog,
@ -119,12 +114,12 @@ const toRecord = (value: unknown): Record<string, unknown> => {
export const hydratePinnedDefaultModel = (
storedConfigDefaultModel: unknown,
litellmParamsDefaultModel: string | null | undefined,
tiers: ComplexityTiers,
activeTiers: ActiveTierSet,
): string | undefined => {
if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) {
return storedConfigDefaultModel;
}
const tierDerived = resolveComplexityDefaultModel(tiers);
const tierDerived = resolveComplexityDefaultModel(activeTiers);
const externalOverride = litellmParamsDefaultModel?.trim();
return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined;
};
@ -148,73 +143,48 @@ export const buildUpdatedComplexityRouterConfig = (
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
};
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
const adaptiveEligible = value.adaptive_eligible ?? "all";
const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
const serializedTierLabels = serializeTierLabels(value.tier_labels);
const scorerRuns = heuristicScoringRole(value) !== "never";
const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params);
const builderParams: BuildComplexityRouterConfigParams = {
tiers: value.tiers,
defaultModel: value.default_model,
planModeMinTier: value.plan_mode_min_tier,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
classifierLlmConfig: value.classifier_llm_config,
classifierContextWindowSize: value.classifier_context_window_size,
classifierContextBudgetChars: value.classifier_context_budget_chars,
classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns,
classifierFallback: value.classifier_fallback,
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
customTechnicalKeywords: customTechnicalKeywords ?? [],
keywordTierRules: keywordMatching?.keywordTierRules ?? [],
semanticMatchingEnabled: keywordMatching?.semanticMatchingEnabled ?? false,
embeddingModel: keywordMatching?.embeddingModel,
matchThreshold: keywordMatching?.matchThreshold ?? DEFAULT_MATCH_THRESHOLD,
escalationKeywords: keywordMatching?.escalationKeywords ?? [],
adaptive: value.adaptive ?? false,
adaptiveWeights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS,
tierDistancePenalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY,
adaptiveEligible: value.adaptive_eligible ?? "all",
returnRawModelName: value.return_raw_model_name ?? false,
tierBoundaries: value.tier_boundaries,
tokenThresholds: value.token_thresholds,
dimensionWeights: value.dimension_weights,
reasoningOverrideMinScore: value.reasoning_override_min_score,
tierModelParams: value.tier_model_params,
};
const built = buildComplexityRouterConfig(builderParams);
// Keys this call does not own stay as the stored config left them.
const unowned: readonly string[] = [
...(keywordMatching === undefined ? KEYWORD_MATCHING_KEYS : []),
...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []),
];
return {
...preservedConfig,
tiers: value.tiers,
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
...(value.default_model?.trim() && { default_model: value.default_model }),
...(value.plan_mode_min_tier?.trim() && { plan_mode_min_tier: value.plan_mode_min_tier }),
...(serializedTierLabels && { tier_labels: serializedTierLabels }),
classifier_type: value.classifier_type,
...(value.classifier_type === "llm" && value.classifier_llm_config
? { classifier_llm_config: normalizeClassifierLlmConfig(value.classifier_llm_config) }
: {}),
...(value.classifier_type === "llm" &&
value.classifier_fallback !== undefined && { classifier_fallback: value.classifier_fallback }),
...(value.classifier_type === "llm" &&
value.classifier_context_window_size !== undefined && {
classifier_context_window_size: value.classifier_context_window_size,
}),
...(value.classifier_type === "llm" &&
value.classifier_context_budget_chars !== undefined && {
classifier_context_budget_chars: value.classifier_context_budget_chars,
}),
...(value.classifier_type === "llm" &&
value.classifier_context_include_assistant_turns !== undefined && {
classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns,
}),
session_affinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
...(customTechnicalKeywords &&
customTechnicalKeywords.length > 0 && {
custom_technical_keywords: customTechnicalKeywords,
}),
...(value.adaptive && {
adaptive: true,
adaptive_weights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS,
...(adaptiveEligible === "all" && {
tier_distance_penalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY,
}),
adaptive_eligible: adaptiveEligible,
}),
...(value.return_raw_model_name && { return_raw_model_name: true }),
...(keywordMatching && {
// Mirrors buildComplexityRouterConfig: the key only when there is a rule to write,
// 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,
}),
}),
...(scorerRuns && value.tier_boundaries !== undefined && { tier_boundaries: value.tier_boundaries }),
...(scorerRuns && value.token_thresholds !== undefined && { token_thresholds: value.token_thresholds }),
...(scorerRuns && value.dimension_weights !== undefined && { dimension_weights: value.dimension_weights }),
...(scorerRuns &&
value.reasoning_override_min_score !== undefined && {
reasoning_override_min_score: value.reasoning_override_min_score,
}),
...Object.fromEntries(Object.entries(built).filter(([key]) => !unowned.includes(key))),
};
};
@ -297,7 +267,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
? "Please select at least one model for a complexity tier"
: null) ??
getTierLabelsError(complexityRouterConfig.tier_labels) ??
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, complexityRouterConfig.tiers) ??
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
getKeywordTierRulesError(keywordTierRules);
useEffect(() => {
@ -355,7 +325,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
default_model: hydratePinnedDefaultModel(
parsedConfig.default_model,
modelData.litellm_params?.complexity_router_default_model,
hydratedTiers,
{ tiers: hydratedTiers },
),
plan_mode_min_tier:
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
@ -486,7 +456,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// 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);
const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model);
if (!defaultModel) {
setShowValidationErrors(true);
toast.fromError(

View file

@ -16,7 +16,7 @@ import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { 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, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers";
import { normalizeTierModels } from "./add_model/complexity_router_tiers";
import {
hasAutoRouterEditor,
isAutoRouterDeployment,
@ -91,12 +91,10 @@ const buildComplexityRouterTestTargets = (
config = rawConfig;
}
const tiers = {
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
REASONING: normalizeTierModels(config.tiers?.REASONING),
};
const tiers: [string, string[]][] =
config.tiers && typeof config.tiers === "object"
? Object.entries(config.tiers).map(([tier, models]) => [tier, normalizeTierModels(models)])
: [];
// 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
@ -108,7 +106,7 @@ const buildComplexityRouterTestTargets = (
tiers,
semanticMatchingEnabled: Boolean(config.semantic_keyword_matching),
embeddingModel: config.embedding_model,
defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel),
defaultModel: effectiveDefaultModel,
};
return buildAutoRouterTestTargets(testTargetParams);
};

View file

@ -4,7 +4,6 @@ import {
} from "@/components/add_model/build_complexity_router_config";
import {
ComplexityRouterConfigValue,
ComplexityTiers,
ClassifierType,
ClassifierLLMConfig,
DEFAULT_SESSION_AFFINITY,
@ -43,15 +42,7 @@ export const getRequiredModels = (
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model" | "default_model">,
): Set<string> => {
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,
];
const models = [...Object.values(tiers).flat(), 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)));
@ -191,7 +182,7 @@ export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability:
// effect would block submit for a model that was never going to be submitted.
export const getReferencedModelsError = (
params: {
tiers: ComplexityTiers;
tiers: ComplexityRouterConfigPayload["tiers"];
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
semanticMatchingEnabled: boolean;