mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
4148cf7d7d
commit
f0a122d35f
13 changed files with 260 additions and 219 deletions
|
|
@ -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} · {tier}
|
||||
Tier {index + 1} of {tierRows.length} · {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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }[] =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
40
ui/litellm-dashboard/src/components/add_model/tier_rows.ts
Normal file
40
ui/litellm-dashboard/src/components/add_model/tier_rows.ts
Normal 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");
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue