onChange({ ...value, custom_dimensions: rows })}
+ onWeight={(id, weight) =>
+ changeWeights({ type: "set", target: { kind: "custom", id }, weight })
+ }
+ onAdd={() =>
+ changeWeights({
+ type: "add",
+ row: { id: crypto.randomUUID(), name: "", weight: 0.1, scoring_mode: "match_count" },
+ })
+ }
+ onRemove={(id) => changeWeights({ type: "remove", id })}
+ />
+ )}
+ {scoringError && (
+
+ {scoringError}
+
+ )}
{problem && (
{problem}
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 2d0d6bc2fd8..cf65a093dfb 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -25,12 +25,14 @@ import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
effectiveClassifierType,
usesLlmClassifier,
+ heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
} from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
+import { customDimensionsError } from "./custom_dimensions";
import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
import {
type AutoRouterCompressionState,
@@ -139,6 +141,7 @@ export const getSubmitBlockedReason = (
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
+ (heuristicScoringRole(config) === "decides" ? customDimensionsError(config.custom_dimensions) : null) ??
getClassifierReasoningEffortError(config, modelInfo) ??
getReferencedModelsError(referencedModelsParams, availability)
);
@@ -411,6 +414,7 @@ const AddAutoRouterTab: React.FC = ({
tierBoundaries: complexityRouterConfig.tier_boundaries,
tokenThresholds: complexityRouterConfig.token_thresholds,
dimensionWeights: complexityRouterConfig.dimension_weights,
+ customDimensions: complexityRouterConfig.custom_dimensions,
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index baf04822e4f..9973aec7616 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -704,6 +704,49 @@ describe("buildComplexityRouterConfig scorer knobs", () => {
expect(buildComplexityRouterConfig(tuned).tier_boundaries).toEqual(BOUNDARIES);
});
+ it("serializes custom rows without changing weights, matcher order, or optional-field absence", () => {
+ const weights = { codePresence: 0.12345678901234568, unknownStoredWeight: 9 };
+ const dimension = { name: "internalFrameworks", weight: 0.3765432109876543, keywords: ["ORBITMESH", "fluxgate"] };
+ const graded = { name: "sqlDdl", weight: 0.5, patterns: ["create table"], scoring_mode: "match_count" as const };
+ const payload = buildComplexityRouterConfig({
+ ...baseParams,
+ dimensionWeights: weights,
+ customDimensions: [
+ { id: "row-1", ...dimension },
+ { id: "row-2", ...graded },
+ ],
+ });
+ expect(payload.dimension_weights).toEqual(weights);
+ expect(payload.custom_dimensions).toEqual([dimension, graded]);
+ expect(buildComplexityRouterConfig(baseParams)).not.toHaveProperty("custom_dimensions");
+ expect(buildComplexityRouterConfig({ ...baseParams, customDimensions: [] }).custom_dimensions).toEqual([]);
+ });
+
+ it.each([
+ ["heuristic", undefined, true],
+ ["heuristic_first", "heuristic", true],
+ ["hybrid", "heuristic", true],
+ ["llm", "heuristic", false],
+ ["custom", "heuristic", false],
+ ["llm", "default_model", false],
+ ["custom", "default_model", false],
+ ["heuristic_v2", undefined, false],
+ ] as const)(
+ "%s with fallback %s only emits custom dimensions when its scorer decides",
+ (classifierType, classifierFallback, emits) => {
+ const dimension = { name: "d", weight: 0.4, keywords: ["orbitmesh"] };
+ const params = {
+ ...baseParams,
+ classifierType,
+ classifierFallback,
+ customDimensions: [{ id: "row", ...dimension }],
+ };
+ const payload = buildComplexityRouterConfig(params);
+ if (emits) expect(payload.custom_dimensions).toEqual([dimension]);
+ else expect(payload).not.toHaveProperty("custom_dimensions");
+ },
+ );
+
it("drops them when the classifier falls back to the default model and nothing is scored", () => {
expect(buildComplexityRouterConfig(llmWithDefaultFallback)).not.toHaveProperty("tier_boundaries");
});
@@ -1060,6 +1103,7 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
tierBoundaries: { simple_medium: 0.1, medium_complex: 0.25, complex_reasoning: 0.5 },
tokenThresholds: { short: 1, long: 2 },
dimensionWeights: { length: 1 },
+ customDimensions: [{ id: "row-1", name: "sqlDdl", weight: 0.4, keywords: ["orbitmesh"] }],
reasoningOverrideMinScore: 0.5,
heuristicFirstMaxTier: "SIMPLE",
hybridBoundaryMargin: 0.03,
@@ -1068,7 +1112,8 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
stallEscalationWindow: 6,
stallEscalationRepeatThreshold: 3,
};
- const emittingType = key === "heuristic_first_max_tier" ? "heuristic_first" : "llm";
+ // custom_dimensions only ever ship when the scorer decides, so "llm" cannot prove it emits.
+ const emittingType = key === "heuristic_first_max_tier" || key === "custom_dimensions" ? "heuristic_first" : "llm";
const typeForKey = key === "hybrid_boundary_margin" ? "hybrid" : emittingType;
expect(buildComplexityRouterConfig({ ...baseParams, ...loaded, classifierType: typeForKey })).toHaveProperty(key);
expect(build(loaded)).not.toHaveProperty(key);
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 7769fb832fe..d04d48198a8 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -11,6 +11,7 @@ import {
tierRowByName,
} from "./tier_rows";
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
+import { type CustomDimension, type CustomDimensionRow, serializeCustomDimensions } from "./custom_dimensions";
import {
TierModelParams,
TierModelParamsByTier,
@@ -92,6 +93,7 @@ interface ScorerKnobInputs {
tierBoundaries: TierBoundaries | undefined;
tokenThresholds: TokenThresholds | undefined;
dimensionWeights: DimensionWeights | undefined;
+ customDimensions: CustomDimensionRow[] | undefined;
reasoningOverrideMinScore: number | undefined;
}
@@ -106,16 +108,23 @@ const scorerKnobPayload = ({
tierBoundaries,
tokenThresholds,
dimensionWeights,
+ customDimensions,
reasoningOverrideMinScore,
-}: ScorerKnobInputs) =>
- heuristicScoringRoleFor(classifierType, classifierFallback) === "never"
+}: ScorerKnobInputs) => {
+ const role = heuristicScoringRoleFor(classifierType, classifierFallback);
+ return role === "never"
? {}
: {
...(tierBoundaries && { tier_boundaries: tierBoundaries }),
...(tokenThresholds && { token_thresholds: tokenThresholds }),
...(dimensionWeights && { dimension_weights: dimensionWeights }),
+ // Only a scorer that decides accepts these; the backend rejects them on every other
+ // classifier, so a fallback-only router must not carry rows a switch left behind.
+ ...(role === "decides" &&
+ customDimensions !== undefined && { custom_dimensions: serializeCustomDimensions(customDimensions) }),
...(reasoningOverrideMinScore !== undefined && { reasoning_override_min_score: reasoningOverrideMinScore }),
};
+};
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
@@ -155,6 +164,7 @@ export interface BuildComplexityRouterConfigParams {
tierBoundaries?: TierBoundaries;
tokenThresholds?: TokenThresholds;
dimensionWeights?: DimensionWeights;
+ customDimensions?: CustomDimensionRow[];
reasoningOverrideMinScore?: number;
tierModelParams?: TierModelParamsByTier;
enableContextWindowEscalation?: boolean;
@@ -219,6 +229,7 @@ export interface ComplexityRouterConfigPayload {
tier_boundaries?: TierBoundaries;
token_thresholds?: TokenThresholds;
dimension_weights?: DimensionWeights;
+ custom_dimensions?: CustomDimension[];
reasoning_override_min_score?: number;
enable_context_window_escalation?: boolean;
context_window_escalation_buffer?: number;
@@ -496,6 +507,7 @@ export const buildComplexityRouterConfig = ({
tierBoundaries,
tokenThresholds,
dimensionWeights,
+ customDimensions,
reasoningOverrideMinScore,
tierModelParams,
enableContextWindowEscalation,
@@ -517,6 +529,7 @@ export const buildComplexityRouterConfig = ({
tierBoundaries,
tokenThresholds,
dimensionWeights,
+ customDimensions,
reasoningOverrideMinScore,
};
const scorerKnobs = scorerKnobPayload(scorerInputs);
diff --git a/ui/litellm-dashboard/src/components/add_model/custom_dimensions.test.ts b/ui/litellm-dashboard/src/components/add_model/custom_dimensions.test.ts
new file mode 100644
index 00000000000..3c6060fd083
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/custom_dimensions.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { customDimensionsError, hydrateCustomDimensions, serializeCustomDimensions } from "./custom_dimensions";
+
+describe("custom dimension drafts", () => {
+ it.each([
+ [[{ name: "domain", weight: 0.2, keywords: [" orbitmesh "] }]],
+ [[{ name: "domain", weight: 0.2, patterns: ["abc"], keywords: [], scoring_mode: "binary" }]],
+ [[{ name: "domain", weight: 0.2, keywords: ["a", "b"], scoring_mode: "match_count" }]],
+ [[]],
+ ])("round-trips stored optional fields and matcher text without normalization: %j", (raw) => {
+ const rows = hydrateCustomDimensions(raw);
+ expect(rows).toBeDefined();
+ expect(serializeCustomDimensions(rows!)).toEqual(raw);
+ });
+
+ it("does not insert a default mode or custom list on load", () => {
+ expect(hydrateCustomDimensions(undefined)).toBeUndefined();
+ const rows = hydrateCustomDimensions([{ name: "domain", weight: 0.2, keywords: ["a"] }])!;
+ expect(rows[0].scoring_mode).toBeUndefined();
+ expect(customDimensionsError(rows)).toBeNull();
+ });
+
+ it.each([
+ { name: "", weight: 0.2, keywords: ["a"] },
+ { name: "codePresence", weight: 0.2, keywords: ["a"] },
+ { name: "bad name", weight: 0.2, keywords: ["a"] },
+ { name: "domain", weight: 0, keywords: ["a"] },
+ { name: "domain", weight: 0.2, keywords: [] },
+ { name: "domain", weight: 0.2, keywords: [" "] },
+ { name: "domain", weight: 0.2, keywords: ["a".repeat(257)] },
+ ])("rejects an invalid draft %j", (row) => {
+ expect(customDimensionsError([{ ...row, id: "draft" }])).not.toBeNull();
+ });
+
+ it("rejects duplicate names and aggregate matcher limits", () => {
+ const row = { id: "a", name: "domain", weight: 0.2, keywords: ["a"] };
+ expect(customDimensionsError([row, { ...row, id: "b", name: "DOMAIN" }])).not.toBeNull();
+ expect(customDimensionsError([{ ...row, keywords: Array(33).fill("a") }])).not.toBeNull();
+ expect(customDimensionsError([{ ...row, keywords: Array(32).fill("a".repeat(256)) }])).not.toBeNull();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/custom_dimensions.ts b/ui/litellm-dashboard/src/components/add_model/custom_dimensions.ts
new file mode 100644
index 00000000000..8cf5a16c303
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/custom_dimensions.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+import { DIMENSION_LABELS } from "./heuristic_scoring_knobs";
+
+const customDimensionShape = {
+ name: z.string(),
+ weight: z.number(),
+ keywords: z.array(z.string()).optional(),
+ patterns: z.array(z.string()).optional(),
+ scoring_mode: z.enum(["binary", "match_count"]).optional(),
+};
+const customDimensionSchema = z.object(customDimensionShape);
+
+export type CustomDimension = z.infer;
+export type CustomDimensionRow = CustomDimension & { id: string };
+
+export const hydrateCustomDimensions = (raw: unknown): CustomDimensionRow[] | undefined => {
+ if (raw === undefined) return undefined;
+ const parsed = z.array(customDimensionSchema).safeParse(raw);
+ return parsed.success ? parsed.data.map((row, index) => ({ ...row, id: `stored-${index}` })) : undefined;
+};
+
+export const serializeCustomDimensions = (rows: CustomDimensionRow[]): CustomDimension[] =>
+ rows.map(({ id: _id, ...dimension }) => dimension);
+
+export const customDimensionsError = (
+ rows: CustomDimensionRow[] | undefined,
+ builtinNames: string[] = Object.keys(DIMENSION_LABELS),
+): string | null => {
+ if (!rows) return null;
+ if (rows.length > 16) return "A router can have at most 16 custom dimensions";
+ const names = rows.map((row) => row.name.toLowerCase());
+ for (const [index, row] of rows.entries()) {
+ const prefix = `Custom dimension ${index + 1}: `;
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(row.name))
+ return (
+ prefix + "use a name starting with a letter, followed by letters, numbers or underscores (64 characters max)"
+ );
+ if (builtinNames.some((name) => name.toLowerCase() === row.name.toLowerCase()))
+ return prefix + "choose a name that is not already a built-in weight";
+ if (names.indexOf(row.name.toLowerCase()) !== index) return prefix + "names must be unique";
+ if (!Number.isFinite(row.weight) || row.weight <= 0 || row.weight > 1)
+ return prefix + "weight must be greater than 0 and at most 1";
+ const matchers = [...(row.keywords ?? []), ...(row.patterns ?? [])];
+ if (!matchers.length || matchers.some((matcher) => !matcher.trim()))
+ return prefix + "add at least one nonblank keyword or pattern";
+ if (
+ matchers.length > 32 ||
+ matchers.some((matcher) => [...matcher].length > 256) ||
+ matchers.reduce((total, matcher) => total + [...matcher].length, 0) > 4096
+ )
+ return prefix + "use at most 32 matchers, 256 characters each and 4096 characters combined";
+ }
+ return null;
+};
diff --git a/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.test.ts b/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.test.ts
index ee1d819872b..ea785ddb594 100644
--- a/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import { heuristicScoringRoleFor } from "./ComplexityRouterConfig";
import {
dimensionLabel,
+ effectiveDimensionWeights,
+ rebalanceDimensionWeights,
hydrateDimensionWeights,
hydrateReasoningOverrideMinScore,
hydrateTierBoundaries,
@@ -68,6 +70,123 @@ describe("hydrating the scorer knobs", () => {
});
});
+describe("rebalancing the complete weight vector", () => {
+ const defaults = {
+ codePresence: 0.3,
+ reasoningMarkers: 0.25,
+ technicalTerms: 0.25,
+ tokenCount: 0.1,
+ simpleIndicators: 0.05,
+ multiStepPatterns: 0.03,
+ questionComplexity: 0.02,
+ };
+ const row = { id: "domain", name: "domainMarkers", weight: 0.2, keywords: ["orbitmesh"] };
+ const success = (result: ReturnType) => {
+ if (!result.ok) throw new Error(result.error);
+ const total =
+ Object.keys(defaults).reduce((sum, name) => sum + result.dimension_weights[name], 0) +
+ (result.custom_dimensions ?? []).reduce((sum, dimension) => sum + dimension.weight, 0);
+ expect(total).toBeCloseTo(1, 12);
+ return result;
+ };
+
+ it("adds a dimension, edits either kind, and redistributes its share on removal", () => {
+ const added = success(rebalanceDimensionWeights(defaults, undefined, undefined, { type: "add", row }));
+ expect(added.dimension_weights.codePresence).toBeCloseTo(0.24, 12);
+ expect(added.custom_dimensions?.[0].weight).toBe(0.2);
+ const edited = success(
+ rebalanceDimensionWeights(defaults, added.dimension_weights, added.custom_dimensions, {
+ type: "set",
+ target: { kind: "custom", id: row.id },
+ weight: 0.4,
+ }),
+ );
+ expect(edited.dimension_weights.codePresence).toBeCloseTo(0.18, 12);
+ const builtin = success(
+ rebalanceDimensionWeights(defaults, edited.dimension_weights, edited.custom_dimensions, {
+ type: "set",
+ target: { kind: "builtin", id: "codePresence" },
+ weight: 0.5,
+ }),
+ );
+ expect(builtin.dimension_weights.codePresence).toBe(0.5);
+ expect(builtin.custom_dimensions?.[0].weight).toBeCloseTo((0.4 * 0.5) / 0.82, 12);
+ const removed = success(
+ rebalanceDimensionWeights(defaults, added.dimension_weights, added.custom_dimensions, {
+ type: "remove",
+ id: row.id,
+ }),
+ );
+ expect(removed.custom_dimensions).toBeUndefined();
+ expect(removed.dimension_weights.codePresence).toBeCloseTo(defaults.codePresence, 12);
+ });
+
+ it("uses zero for omitted keys of an explicit map and preserves ignored keys", () => {
+ expect(effectiveDimensionWeights(defaults, { codePresence: 0.2 }).technicalTerms).toBe(0);
+ expect(effectiveDimensionWeights(defaults, undefined)).toEqual(defaults);
+ const result = success(
+ rebalanceDimensionWeights(defaults, { codePresence: 0.2, unknown: 7 }, undefined, { type: "add", row }),
+ );
+ expect(result.dimension_weights.unknown).toBe(7);
+ expect(result.dimension_weights.codePresence).toBeCloseTo(0.8, 12);
+ expect(result.dimension_weights.technicalTerms).toBe(0);
+ });
+
+ it("distributes an all-zero vector without inventing custom matchers", () => {
+ const result = success(rebalanceDimensionWeights(defaults, {}, undefined, { type: "add", row }));
+ expect(result.dimension_weights.codePresence).toBeCloseTo(0.8 / 7, 12);
+ expect(result.custom_dimensions).toEqual([row]);
+ });
+
+ it("keeps small positive custom weights and full precision", () => {
+ const tiny = { ...row, weight: 1e-8 };
+ const result = success(
+ rebalanceDimensionWeights(defaults, defaults, [tiny], {
+ type: "set",
+ target: { kind: "builtin", id: "codePresence" },
+ weight: 0.123456789,
+ }),
+ );
+ expect(result.dimension_weights.codePresence).toBe(0.123456789);
+ expect(result.custom_dimensions?.[0].weight).toBeGreaterThan(0);
+ expect(result.custom_dimensions?.[0].weight).toBeLessThan(0.01);
+ });
+
+ it.each([0, 1, Number.NaN, -0.1, 1.1])(
+ "rejects invalid or impossible custom weight %s without changing the input",
+ (weight) => {
+ const original = structuredClone(row);
+ const result = rebalanceDimensionWeights(defaults, defaults, [row, { ...row, id: "other" }], {
+ type: "set",
+ target: { kind: "custom", id: row.id },
+ weight,
+ });
+ expect(result.ok).toBe(false);
+ expect(row).toEqual(original);
+ },
+ );
+
+ it("allows one dimension to take the whole budget when no custom sibling needs a share", () => {
+ const result = success(
+ rebalanceDimensionWeights(defaults, defaults, [row], {
+ type: "set",
+ target: { kind: "custom", id: row.id },
+ weight: 1,
+ }),
+ );
+ expect(Object.values(result.dimension_weights).every((weight) => weight === 0)).toBe(true);
+ expect(result.custom_dimensions?.[0].weight).toBe(1);
+ });
+
+ it("refuses missing defaults and invalid stored weights, preserving absence on built-in edits", () => {
+ const edit = { type: "set", target: { kind: "builtin", id: "codePresence" }, weight: 0.2 } as const;
+ expect(rebalanceDimensionWeights(undefined, defaults, undefined, edit).ok).toBe(false);
+ expect(rebalanceDimensionWeights(defaults, { codePresence: -1 }, undefined, edit).ok).toBe(false);
+ expect(success(rebalanceDimensionWeights(defaults, undefined, undefined, edit)).custom_dimensions).toBeUndefined();
+ expect(success(rebalanceDimensionWeights(defaults, undefined, [], edit)).custom_dimensions).toEqual([]);
+ });
+});
+
describe("heuristicScoringRoleFor", () => {
it.each([
["heuristic", undefined, "decides"],
diff --git a/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.ts b/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.ts
index 1f44a479c60..3de4af92b9c 100644
--- a/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.ts
+++ b/ui/litellm-dashboard/src/components/add_model/heuristic_scoring_knobs.ts
@@ -1,3 +1,5 @@
+import type { CustomDimensionRow } from "./custom_dimensions";
+
export type TierBoundaries = Record;
export type TokenThresholds = Record;
@@ -27,8 +29,8 @@ const asRecord = (raw: unknown): Record | undefined =>
/**
* Absent means the router is tracking the shipped defaults, so it must hydrate to undefined rather than to
* a copy of them: hydrating defaults would make an untouched save write them out and pin the router to
- * whatever they were the day the modal was opened. A stored dict is kept exactly as stored, since the
- * backend fills in any key it omits at scoring time.
+ * whatever they were the day the modal was opened. A stored weight map replaces the defaults;
+ * missing dimension names score zero.
*/
const hydrateNumericMap = (raw: unknown): Record | undefined => {
const stored = asRecord(raw);
@@ -53,3 +55,94 @@ export const hydrateReasoningOverrideMinScore = (raw: unknown): number | undefin
export const weightTotal = (weights: DimensionWeights): number =>
Math.round(Object.values(weights).reduce((total, weight) => total + weight, 0) * 100) / 100;
+
+export const effectiveDimensionWeights = (
+ defaults: DimensionWeights,
+ stored: DimensionWeights | undefined,
+): DimensionWeights =>
+ Object.fromEntries(
+ Object.keys(defaults).map((name) => [name, stored === undefined ? defaults[name] : stored[name] ?? 0]),
+ );
+
+type WeightTarget = { kind: "builtin" | "custom"; id: string };
+const weightValid = ({ kind, weight }: { kind: WeightTarget["kind"]; weight: number }): boolean => {
+ const inRange = Number.isFinite(weight) && weight >= 0 && weight <= 1;
+ return inRange && (kind === "builtin" || weight > 0);
+};
+export type WeightEdit =
+ | { type: "set"; target: WeightTarget; weight: number }
+ | { type: "add"; row: CustomDimensionRow }
+ | { type: "remove"; id: string };
+type WeightResult =
+ | { ok: true; dimension_weights: DimensionWeights; custom_dimensions: CustomDimensionRow[] | undefined }
+ | { ok: false; error: string };
+
+export const rebalanceDimensionWeights = (
+ defaults: DimensionWeights | undefined,
+ stored: DimensionWeights | undefined,
+ custom: CustomDimensionRow[] | undefined,
+ edit: WeightEdit,
+): WeightResult => {
+ if (!defaults || !Object.keys(defaults).length)
+ return { ok: false, error: "Load the shipped defaults before changing weights" };
+ const builtin = effectiveDimensionWeights(defaults, stored);
+ const rows =
+ edit.type === "add"
+ ? [...(custom ?? []), edit.row]
+ : (custom ?? []).filter((row) => edit.type !== "remove" || row.id !== edit.id);
+ const vector = [
+ ...Object.entries(builtin).map(([id, weight]) => ({ kind: "builtin" as const, id, weight })),
+ ...rows.map(({ id, weight }) => ({ kind: "custom" as const, id, weight })),
+ ];
+ if (!vector.every(weightValid))
+ return {
+ ok: false,
+ error: "Existing weights must be finite and nonnegative; custom weights must be greater than 0 and at most 1",
+ };
+ const target = edit.type === "set" ? edit.target : undefined;
+ const pinned = (entry: WeightTarget) =>
+ edit.type === "add"
+ ? entry.kind === "custom" && entry.id === edit.row.id
+ : entry.kind === target?.kind && entry.id === target.id;
+ if (edit.type === "set" && !vector.some(pinned)) return { ok: false, error: "The dimension is no longer available" };
+ const requestedWeight = (): number => {
+ if (edit.type === "set") return edit.weight;
+ return edit.type === "add" ? edit.row.weight : 0;
+ };
+ const weight = requestedWeight();
+ if (!weightValid({ kind: target?.kind ?? "builtin", weight }))
+ return { ok: false, error: "Use a weight from 0 to 1; custom dimensions must stay greater than 0" };
+ const others = vector.filter((entry) => !pinned(entry));
+ const total = others.reduce((sum, entry) => sum + entry.weight, 0);
+ if (!Number.isFinite(total)) return { ok: false, error: "Existing weights are too large to rebalance" };
+ const remainder = 1 - weight;
+ const builtinCount = others.filter((entry) => entry.kind === "builtin").length;
+ const redistributed = (entry: WeightTarget & { weight: number }): number => {
+ if (pinned(entry)) return weight;
+ if (total > 0) return remainder * (entry.weight / total);
+ return entry.kind === "builtin" ? remainder / builtinCount : 0;
+ };
+ const balanced = vector.map((entry) => ({ ...entry, weight: redistributed(entry) }));
+ const residual = 1 - balanced.reduce((sum, entry) => sum + entry.weight, 0);
+ const receiver = balanced
+ .filter((entry) => entry.kind === "builtin" && !pinned(entry) && entry.weight > 0)
+ .sort((a, b) => b.weight - a.weight)[0];
+ const corrected = balanced.map((entry) =>
+ entry === receiver ? { ...entry, weight: entry.weight + residual } : entry,
+ );
+ const offBudget = Math.abs(corrected.reduce((sum, entry) => sum + entry.weight, 0) - 1) > 1e-12;
+ if (!corrected.every(weightValid) || offBudget)
+ return { ok: false, error: "Leave a positive share for every custom dimension, or remove it first" };
+ const weights = Object.fromEntries(
+ corrected.filter((entry) => entry.kind === "builtin").map(({ id, weight }) => [id, weight]),
+ );
+ const customWeights = new Map(
+ corrected.filter((entry) => entry.kind === "custom").map(({ id, weight }) => [id, weight]),
+ );
+ const emptyRows = edit.type === "remove" ? undefined : custom;
+ return {
+ ok: true,
+ dimension_weights: { ...stored, ...weights },
+ custom_dimensions: rows.length ? rows.map((row) => ({ ...row, weight: customWeights.get(row.id)! })) : emptyRows,
+ };
+};
diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
index 5e2a32addee..d2cec15d75b 100644
--- a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
+++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
@@ -136,6 +136,7 @@ export const CUSTOM_TIER_RESTRICTIONS = {
"tier_boundaries",
"token_thresholds",
"dimension_weights",
+ "custom_dimensions",
"reasoning_override_min_score",
"custom_technical_keywords",
],
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 0144dff3498..4a57e9d5e13 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -63,6 +63,54 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
expect(result.some_future_backend_key).toEqual({ nested: true });
});
+ describe("custom dimensions", () => {
+ const stored = [
+ { name: "internalFrameworks", weight: 0.7, keywords: ["orbitmesh"] },
+ { name: "sqlDdl", weight: 0.3, patterns: ["create table"], scoring_mode: "match_count" },
+ ];
+ const withDimensions = { ...STORED, custom_dimensions: stored };
+
+ it("round-trips stored rows through hydration and save without dropping or reshaping one", () => {
+ const hydrated = hydrateComplexityRouterConfig(withDimensions, null);
+ expect(hydrated.custom_dimensions).toEqual([
+ { id: "stored-0", ...stored[0] },
+ { id: "stored-1", ...stored[1] },
+ ]);
+ const saved = buildUpdatedComplexityRouterConfig(withDimensions, {
+ ...FORM_VALUE,
+ custom_dimensions: hydrated.custom_dimensions,
+ });
+ expect(saved.custom_dimensions).toEqual(stored);
+ });
+
+ it("is a managed key, so removing the last row does not resurrect the stored dimensions", () => {
+ expect(MANAGED_COMPLEXITY_ROUTER_KEYS.has("custom_dimensions")).toBe(true);
+ const saved = buildUpdatedComplexityRouterConfig(withDimensions, { ...FORM_VALUE, custom_dimensions: [] });
+ expect(saved.custom_dimensions).toEqual([]);
+ });
+
+ it("omits the key entirely when the editor never held rows, so an untouched router gains nothing", () => {
+ expect(hydrateComplexityRouterConfig(STORED, null).custom_dimensions).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE)).not.toHaveProperty("custom_dimensions");
+ });
+
+ it("drops rows a custom tier set forbids rather than sending them to a scorer that never runs", () => {
+ const customTierStored = storedCustomConfig({ custom_dimensions: stored });
+ const saved = buildUpdatedComplexityRouterConfig(customTierStored, {
+ tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
+ classifier_type: "llm" as const,
+ custom_tier_set: {
+ tiers: [
+ { id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
+ { id: "b", name: "AUDIT", definition: "security review", models: ["o1"] },
+ ],
+ fallback_tier_id: "a",
+ },
+ });
+ expect(saved).not.toHaveProperty("custom_dimensions");
+ });
+ });
+
it("persists an edited keyword rule", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
...hydratedState,
@@ -580,6 +628,7 @@ describe("managed keys survive an untouched open-and-save", () => {
tier_boundaries: { simple_medium: 0.2, medium_complex: 0.4, complex_reasoning: 0.7 },
token_thresholds: { simple: 20, complex: 500 },
dimension_weights: { tokenCount: 0.1 },
+ custom_dimensions: [{ name: "domain", weight: 0.9, keywords: ["orbitmesh"] }],
reasoning_override_min_score: 0.3,
enable_context_window_escalation: false,
context_window_escalation_buffer: 0.9,
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 3c0013e267e..b23040ab80c 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -48,6 +48,7 @@ import {
hydrateAutoRouterCompression,
} from "../add_model/buildAutoRouterCompression";
import { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords";
+import { customDimensionsError, hydrateCustomDimensions } from "../add_model/custom_dimensions";
import {
hydrateDimensionWeights,
hydrateReasoningOverrideMinScore,
@@ -61,6 +62,7 @@ import ComplexityRouterConfig, {
ClassifierType,
ComplexityRouterConfigValue,
ComplexityTiers,
+ heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
@@ -109,6 +111,7 @@ export interface StoredComplexityRouterConfig {
tier_boundaries?: unknown;
token_thresholds?: unknown;
dimension_weights?: unknown;
+ custom_dimensions?: unknown;
reasoning_override_min_score?: unknown;
session_affinity?: unknown;
session_affinity_ttl_seconds?: unknown;
@@ -194,6 +197,7 @@ export const hydrateComplexityRouterConfig = (
tier_boundaries: hydrateTierBoundaries(parsedConfig.tier_boundaries),
token_thresholds: hydrateTokenThresholds(parsedConfig.token_thresholds),
dimension_weights: hydrateDimensionWeights(parsedConfig.dimension_weights),
+ custom_dimensions: hydrateCustomDimensions(parsedConfig.custom_dimensions),
reasoning_override_min_score: hydrateReasoningOverrideMinScore(parsedConfig.reasoning_override_min_score),
session_affinity:
typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY,
@@ -264,6 +268,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tier_boundaries",
"token_thresholds",
"dimension_weights",
+ "custom_dimensions",
"reasoning_override_min_score",
"enable_context_window_escalation",
"context_window_escalation_buffer",
@@ -373,6 +378,7 @@ export const buildUpdatedComplexityRouterConfig = (
tierBoundaries: value.tier_boundaries,
tokenThresholds: value.token_thresholds,
dimensionWeights: value.dimension_weights,
+ customDimensions: value.custom_dimensions,
reasoningOverrideMinScore: value.reasoning_override_min_score,
tierModelParams: value.tier_model_params,
enableContextWindowEscalation: value.enable_context_window_escalation,
@@ -481,7 +487,10 @@ const EditAutoRouterModal: React.FC = ({
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
- getClassifierModelError(complexityRouterConfig);
+ getClassifierModelError(complexityRouterConfig) ??
+ (heuristicScoringRole(complexityRouterConfig) === "decides"
+ ? customDimensionsError(complexityRouterConfig.custom_dimensions)
+ : null);
useEffect(() => {
if (isVisible && modelData) {
@@ -601,7 +610,11 @@ const EditAutoRouterModal: React.FC = ({
toast.fromError(tierSetError);
return;
}
- const classifierError = getClassifierModelError(complexityRouterConfig);
+ const classifierError =
+ getClassifierModelError(complexityRouterConfig) ??
+ (heuristicScoringRole(complexityRouterConfig) === "decides"
+ ? customDimensionsError(complexityRouterConfig.custom_dimensions)
+ : null);
if (classifierError) {
setShowValidationErrors(true);
toast.fromError(classifierError);
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index 344964c4434..5d1c7f73c71 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -87,6 +87,19 @@ describe("autorouter_presets", () => {
}
});
+ it("keeps every preset free of custom dimensions, so applying one never adds scoring rows", () => {
+ for (const { complexity_router_config: config } of getAllPresets()) {
+ expect(config.custom_dimensions).toBeUndefined();
+ }
+ const config = getPresetByKey("anthropic_family")!.complexity_router_config;
+ expect(buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig.custom_dimensions).toBeUndefined();
+ });
+
+ it("resets both scoring overrides when the form falls back to an empty prefill", () => {
+ expect(buildEmptyPrefill().complexityRouterConfig.custom_dimensions).toBeUndefined();
+ expect(buildEmptyPrefill().complexityRouterConfig.dimension_weights).toBeUndefined();
+ });
+
it("carries a preset's session affinity idle window into the prefilled form state", () => {
const config = getPresetByKey("anthropic_family")!.complexity_router_config;
const prefill = buildPresetPrefill({ ...config, session_affinity_ttl_seconds: 300 }, groupsOnly([]));
@@ -96,6 +109,21 @@ describe("autorouter_presets", () => {
).toBeUndefined();
});
+ it("carries a preset's stored weights and custom dimensions into the prefill without rebalancing them", () => {
+ const config = getPresetByKey("anthropic_family")!.complexity_router_config;
+ const weights = { codePresence: 0.4 };
+ const dimension = { name: "domain", weight: 0.9, keywords: ["orbitmesh"] };
+ const prefill = buildPresetPrefill(
+ { ...config, dimension_weights: weights, custom_dimensions: [dimension] },
+ groupsOnly([]),
+ ).complexityRouterConfig;
+ expect(prefill.dimension_weights).toEqual(weights);
+ expect(prefill.custom_dimensions).toEqual([{ ...dimension, id: "stored-0" }]);
+ const plain = buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig;
+ expect(plain.dimension_weights).toBeUndefined();
+ expect(plain.custom_dimensions).toBeUndefined();
+ });
+
it("keeps the model-family presets on the heuristic classifier", () => {
for (const key of ["anthropic_family", "gemini_family", "openai_family"]) {
expect(getPresetByKey(key)!.complexity_router_config.classifier_type).toBe("heuristic");
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index 2aafbfcfcd9..f2df55fc310 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -13,6 +13,13 @@ import {
} from "@/components/add_model/ComplexityRouterConfig";
import { KeywordTierRule } from "@/components/add_model/KeywordTierRules";
import { hydrateKeywordTierRules } from "@/components/add_model/complexity_router_keywords";
+import { hydrateCustomDimensions } from "@/components/add_model/custom_dimensions";
+import {
+ hydrateDimensionWeights,
+ hydrateTierBoundaries,
+ hydrateTokenThresholds,
+ hydrateReasoningOverrideMinScore,
+} from "@/components/add_model/heuristic_scoring_knobs";
import {
TierModelParams,
TierModelParamsByTier,
@@ -293,6 +300,11 @@ export const buildPresetPrefill = (
tier_distance_penalty: config.tier_distance_penalty,
adaptive_eligible: config.adaptive_eligible,
return_raw_model_name: config.return_raw_model_name,
+ dimension_weights: hydrateDimensionWeights(config.dimension_weights),
+ custom_dimensions: hydrateCustomDimensions(config.custom_dimensions),
+ tier_boundaries: hydrateTierBoundaries(config.tier_boundaries),
+ token_thresholds: hydrateTokenThresholds(config.token_thresholds),
+ reasoning_override_min_score: hydrateReasoningOverrideMinScore(config.reasoning_override_min_score),
enable_context_window_escalation: config.enable_context_window_escalation,
context_window_escalation_buffer: config.context_window_escalation_buffer,
},
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 232b366d8ce..d1431d8956f 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -26591,6 +26591,13 @@ export interface components {
* @default []
*/
patterns: string[];
+ /**
+ * Scoring Mode
+ * @description 'binary' scores 1 when any matcher hits. 'match_count' scores 0.5 when one distinct matcher hits and 1 when two or more do; repeated occurrences of one matcher never raise it. Keywords are distinct case-insensitively, patterns by source, and a keyword and a pattern are always distinct from each other.
+ * @default binary
+ * @enum {string}
+ */
+ scoring_mode: "binary" | "match_count";
/** Weight */
weight: number;
};
@@ -34909,7 +34916,7 @@ export interface components {
context_window_escalation_buffer: number;
/**
* Custom Dimensions
- * @description Named binary dimensions added to the heuristic-v1 score. Each contributes its inline weight once when any keyword matches the current ask or a case-insensitive regex matches its first 2048 characters. Regex quantifiers repeat one character or class at most 64 times. Unbounded quantifiers, repeated groups, backreferences and lookarounds are rejected. Conservative work limits include alternation paths, repeat lengths and subsequent matching: 2048 units per pattern, 8192 across the router. Only heuristic, heuristic_first and hybrid accept this field. Uses the existing heuristic tuning quota.
+ * @description Named dimensions added to the heuristic-v1 score. Each contributes its inline weight once when any keyword matches the current ask or a case-insensitive regex matches its first 2048 characters; scoring_mode 'match_count' instead grades half weight for one distinct matcher and full for two or more. Regex quantifiers repeat one character or class at most 64 times. Unbounded quantifiers, repeated groups, backreferences and lookarounds are rejected. Conservative work limits include alternation paths, repeat lengths and subsequent matching: 2048 units per pattern, 8192 across the router. Only heuristic, heuristic_first and hybrid accept this field. Uses the existing heuristic tuning quota.
* @default []
*/
custom_dimensions: components["schemas"]["CustomDimension"][];