feat(ui): per-model reasoning effort in the complexity tier editor (#37673)

* feat(ui): per-model reasoning effort in the complexity tier editor

* feat(ui): gate the effort control on model group reasoning support
This commit is contained in:
tin-berri 2026-08-20 15:10:35 -07:00 committed by GitHub
parent 2dcd453860
commit cb4eb82249
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 555 additions and 12 deletions

View file

@ -8,9 +8,9 @@ vi.mock(
);
const mockModelInfo = [
{ model_group: "gpt-4", mode: "chat" },
{ model_group: "gpt-4", mode: "chat", supports_reasoning: true },
{ model_group: "gpt-3.5-turbo", mode: "chat" },
{ model_group: "claude-3-opus", mode: "chat" },
{ model_group: "claude-3-opus", mode: "chat", supports_reasoning: true },
{ model_group: "text-embedding-3-small", mode: "embedding" },
] as any[];
@ -403,7 +403,7 @@ describe("ComplexityRouterConfig", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
const simpleTierSection = screen.getByText("Simple Tier").closest(".mb-4") as HTMLElement;
const combobox = within(simpleTierSection).getByRole("combobox");
const combobox = within(simpleTierSection).getByRole("combobox", { name: "Select model(s) for simple queries" });
await user.click(combobox);
expect((await screen.findAllByText("gpt-3.5-turbo")).length).toBeGreaterThan(0);
@ -874,3 +874,72 @@ describe("plan-mode override", () => {
expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true");
});
});
describe("ComplexityRouterConfig per-model reasoning effort", () => {
it("renders one effort select per selected model, defaulting to Default", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
const select = screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" });
expect(select).toHaveTextContent("Default");
});
it("shows the hydrated effort for a model that has one stored", () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, tier_model_params: { COMPLEX: { "gpt-4": { reasoning_effort: "high" } } } }}
/>,
);
const select = screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" });
expect(select).toHaveTextContent("high");
});
it("emits tier_model_params scoped to the tier and model when an effort is picked", async () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" }));
await user.click(await screen.findByRole("option", { name: "high" }));
expect(onChange).toHaveBeenCalledWith({
...defaultValue,
tier_model_params: { COMPLEX: { "gpt-4": { reasoning_effort: "high" } } },
});
});
it("picking Default removes the stored effort", async () => {
const onChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, tier_model_params: { COMPLEX: { "gpt-4": { reasoning_effort: "high" } } } }}
onChange={onChange}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" }));
await user.click(await screen.findByRole("option", { name: "Default" }));
expect(onChange).toHaveBeenCalledWith({ ...defaultValue, tier_model_params: undefined });
});
});
describe("ComplexityRouterConfig reasoning effort gating", () => {
it("offers no effort select for a model group without reasoning support", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(
screen.queryByRole("combobox", { name: "Reasoning effort for gpt-3.5-turbo in the Simple tier" }),
).not.toBeInTheDocument();
});
// A stored effort on a model the group info calls non-reasoning must stay visible, or the
// operator has no way to clear it.
it("keeps the select for a non-reasoning model that already has a stored effort", () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, tier_model_params: { SIMPLE: { "gpt-3.5-turbo": { reasoning_effort: "low" } } } }}
/>,
);
expect(
screen.getByRole("combobox", { name: "Reasoning effort for gpt-3.5-turbo in the Simple tier" }),
).toHaveTextContent("low");
});
});

View file

@ -12,7 +12,15 @@ import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import { resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers";
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";
@ -153,6 +161,12 @@ export interface ComplexityRouterConfigValue {
* floor tracks tier_boundaries.simple_medium; an explicit 0 is a real floor that promotes on the markers alone.
*/
reasoning_override_min_score?: number;
/**
* Per-(tier, model) litellm_params, serialized to the sibling tier_model_configs key. The full
* params object is held, not just reasoning_effort, so keys authored in config.yaml survive an
* edit round-trip.
*/
tier_model_params?: TierModelParamsByTier;
}
interface ComplexityRouterConfigProps {
@ -237,6 +251,10 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
// Embedding models can't serve a chat-completion role, so they're excluded here.
const reasoningModels = new Set(
modelInfo.filter((model) => model.supports_reasoning).map((model) => model.model_group),
);
const modelOptions = modelInfo
.filter((model) => model.mode !== "embedding")
.map((model) => ({
@ -248,6 +266,18 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onChange({
...value,
tiers: { ...value.tiers, [tier]: models },
tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models),
});
};
const handleTierModelEffortChange = (
tier: keyof ComplexityTiers,
model: string,
effort: ReasoningEffort | undefined,
) => {
onChange({
...value,
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
});
};
@ -332,6 +362,13 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
/>
<TierModelEffortRows
tierLabel={label}
models={value.tiers[tier]}
reasoningModels={reasoningModels}
paramsByModel={value.tier_model_params?.[tier]}
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
/>
{value.tiers[tier].length > 1 && (
<span className="text-xs text-muted-foreground">
Multiple models selected the router randomly picks among them per request (or Thompson-samples

View file

@ -0,0 +1,80 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Info } from "lucide-react";
import React from "react";
import { REASONING_EFFORT_OPTIONS, ReasoningEffort, TierModelParams } from "./complexity_router_tiers";
const PROVIDER_DEFAULT = "__provider_default__";
const asEffort = (params: TierModelParams | undefined): ReasoningEffort | undefined => {
const stored = params?.reasoning_effort;
if (typeof stored !== "string") return undefined;
return REASONING_EFFORT_OPTIONS.find((option) => option === stored);
};
interface TierModelEffortRowsProps {
tierLabel: string;
models: string[];
reasoningModels: ReadonlySet<string>;
paramsByModel: Record<string, TierModelParams> | undefined;
onEffortChange: (model: string, effort: ReasoningEffort | undefined) => void;
}
const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = ({
tierLabel,
models,
reasoningModels,
paramsByModel,
onEffortChange,
}) => {
const shown = models.filter(
(model) => reasoningModels.has(model) || Object.keys(paramsByModel?.[model] ?? {}).length > 0,
);
if (shown.length === 0) return null;
return (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground">Reasoning effort</span>
<SimpleTooltip
content={`Sent as reasoning_effort on requests this tier routes to the model, overriding the caller's value. Default leaves the request untouched.`}
>
<Info className="size-3 text-muted-foreground/70" />
</SimpleTooltip>
</div>
{shown.map((model) => (
<div key={model} className="flex items-center justify-between gap-2">
<span className="truncate text-xs">{model}</span>
<Select
items={[
{ value: PROVIDER_DEFAULT, label: "Default" },
...REASONING_EFFORT_OPTIONS.map((option) => ({ value: option, label: option })),
]}
value={asEffort(paramsByModel?.[model]) ?? PROVIDER_DEFAULT}
onValueChange={(selected: string | null) =>
selected !== null &&
onEffortChange(model, selected === PROVIDER_DEFAULT ? undefined : (selected as ReasoningEffort))
}
>
<SelectTrigger
size="sm"
className="w-36"
aria-label={`Reasoning effort for ${model} in the ${tierLabel} tier`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROVIDER_DEFAULT}>Default</SelectItem>
{REASONING_EFFORT_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
);
};
export default TierModelEffortRows;

View file

@ -368,6 +368,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
tierDistancePenalty: complexityRouterConfig.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY,
adaptiveEligible: complexityRouterConfig.adaptive_eligible ?? "all",
returnRawModelName: complexityRouterConfig.return_raw_model_name ?? false,
tierModelParams: complexityRouterConfig.tier_model_params,
tierBoundaries: complexityRouterConfig.tier_boundaries,
tokenThresholds: complexityRouterConfig.token_thresholds,
dimensionWeights: complexityRouterConfig.dimension_weights,

View file

@ -656,3 +656,20 @@ describe("getPlanModeTierError", () => {
expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX");
});
});
describe("buildComplexityRouterConfig tier model params", () => {
it("keeps tier_model_configs out of the payload when nothing is set", () => {
expect(buildComplexityRouterConfig(baseParams)).not.toHaveProperty("tier_model_configs");
});
it("emits tier_model_configs beside string tiers when efforts are set", () => {
const config = buildComplexityRouterConfig({
...baseParams,
tierModelParams: { COMPLEX: { "claude-sonnet-4": { reasoning_effort: "high" } } },
});
expect(config.tiers).toEqual(tiers);
expect(config.tier_model_configs).toEqual({
COMPLEX: [{ model_name: "claude-sonnet-4", litellm_params: { reasoning_effort: "high" } }],
});
});
});

View file

@ -1,5 +1,6 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers";
import {
AdaptiveEligible,
AdaptiveRouterWeights,
@ -99,6 +100,7 @@ export interface BuildComplexityRouterConfigParams {
tokenThresholds?: TokenThresholds;
dimensionWeights?: DimensionWeights;
reasoningOverrideMinScore?: number;
tierModelParams?: TierModelParamsByTier;
}
export interface ComplexityRouterConfigPayload {
@ -129,6 +131,7 @@ export interface ComplexityRouterConfigPayload {
token_thresholds?: TokenThresholds;
dimension_weights?: DimensionWeights;
reasoning_override_min_score?: number;
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
}
const TIER_KEYS: Array<keyof ComplexityTiers> = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
@ -236,7 +239,9 @@ export const buildComplexityRouterConfig = ({
tokenThresholds,
dimensionWeights,
reasoningOverrideMinScore,
tierModelParams,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams);
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
const cleanedTierLabels = serializeTierLabels(tierLabels);
@ -252,6 +257,7 @@ export const buildComplexityRouterConfig = ({
return {
tiers,
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
...(defaultModel?.trim() && { default_model: defaultModel }),
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),

View file

@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers";
import {
hydrateTierModelParams,
normalizeTierModels,
pruneTierModelParams,
resolveComplexityDefaultModel,
serializeTierModelConfigs,
setTierModelReasoningEffort,
} from "./complexity_router_tiers";
import type { ComplexityTiers } from "./ComplexityRouterConfig";
@ -70,3 +77,160 @@ describe("resolveComplexityDefaultModel", () => {
expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined();
});
});
// The backend also accepts `{model_name, litellm_params}` entries and splits them into the
// sibling tier_model_configs key at validation (config.py `_normalize_tier_model_configs`).
// Before this widening, an object entry was silently dropped here, so opening the edit modal on
// a yaml-authored config rendered the tier empty and the next save destroyed it.
describe("normalizeTierModels object entries", () => {
it("reads model_name from an object entry the way the backend does", () => {
expect(normalizeTierModels([{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }, "mini"])).toEqual(
["opus", "mini"],
);
});
it("widens a single object entry to a one-element pool", () => {
expect(normalizeTierModels({ model_name: "opus" })).toEqual(["opus"]);
});
it("drops an object without a model_name", () => {
expect(normalizeTierModels([{ litellm_params: { reasoning_effort: "high" } }])).toEqual([]);
});
});
describe("hydrateTierModelParams", () => {
it("reads the sibling tier_model_configs key", () => {
expect(
hydrateTierModelParams(
{ MEDIUM: ["opus"] },
{ MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] },
),
).toEqual({ MEDIUM: { opus: { reasoning_effort: "medium" } } });
});
it("reads inline object entries out of tiers", () => {
expect(
hydrateTierModelParams(
{ COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }] },
undefined,
),
).toEqual({ COMPLEX: { opus: { reasoning_effort: "high" } } });
});
// config.py merges the two sources with tier_model_configs winning per (tier, model); hydrating
// the other way round would show the operator a value the router never uses.
it("lets tier_model_configs beat an inline entry for the same tier and model", () => {
expect(
hydrateTierModelParams(
{ MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "low" } }] },
{ MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] },
),
).toEqual({ MEDIUM: { opus: { reasoning_effort: "medium" } } });
});
it("hydrates to undefined when nothing carries params, so an untouched save stays byte-identical", () => {
expect(
hydrateTierModelParams({ SIMPLE: ["mini"], MEDIUM: [{ model_name: "opus", litellm_params: {} }] }, undefined),
).toBeUndefined();
});
});
describe("serializeTierModelConfigs", () => {
const tiers: ComplexityTiers = { SIMPLE: ["mini"], MEDIUM: ["opus"], COMPLEX: ["opus"], REASONING: [] };
it("emits the sibling wire shape per tier and model", () => {
expect(
serializeTierModelConfigs(tiers, {
MEDIUM: { opus: { reasoning_effort: "medium" } },
COMPLEX: { opus: { reasoning_effort: "high" } },
}),
).toEqual({
MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }],
COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }],
});
});
it("prunes params for a model no longer selected in the tier", () => {
expect(
serializeTierModelConfigs(tiers, { MEDIUM: { "removed-model": { reasoning_effort: "low" } } }),
).toBeUndefined();
});
// Params authored in config.yaml alongside reasoning_effort must survive an edit round-trip.
it("carries params keys this editor has no control for", () => {
expect(
serializeTierModelConfigs(tiers, { MEDIUM: { opus: { reasoning_effort: "medium", max_tokens: 512 } } }),
).toEqual({
MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium", max_tokens: 512 } }],
});
});
// This modal renders only the four built-in tiers; params stored under an operator-defined tier
// must pass through rather than being dropped the moment the key became managed.
it("passes tiers this editor does not render through untouched", () => {
expect(serializeTierModelConfigs(tiers, { DEEP_RESEARCH: { opus: { reasoning_effort: "xhigh" } } })).toEqual({
DEEP_RESEARCH: [{ model_name: "opus", litellm_params: { reasoning_effort: "xhigh" } }],
});
});
it("round-trips what hydration produced", () => {
const stored = { MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] };
expect(serializeTierModelConfigs(tiers, hydrateTierModelParams(tiers, stored))).toEqual(stored);
});
it("serializes to undefined when nothing is set", () => {
expect(serializeTierModelConfigs(tiers, undefined)).toBeUndefined();
expect(serializeTierModelConfigs(tiers, { MEDIUM: {} })).toBeUndefined();
});
});
describe("setTierModelReasoningEffort", () => {
it("sets an effort for a tier and model", () => {
expect(setTierModelReasoningEffort(undefined, "MEDIUM", "opus", "medium")).toEqual({
MEDIUM: { opus: { reasoning_effort: "medium" } },
});
});
it("unsetting removes the key and collapses empties back to undefined", () => {
const set = setTierModelReasoningEffort(undefined, "MEDIUM", "opus", "medium");
expect(setTierModelReasoningEffort(set, "MEDIUM", "opus", undefined)).toBeUndefined();
});
it("unsetting the effort keeps params keys it does not own", () => {
expect(
setTierModelReasoningEffort(
{ MEDIUM: { opus: { reasoning_effort: "medium", max_tokens: 512 } } },
"MEDIUM",
"opus",
undefined,
),
).toEqual({ MEDIUM: { opus: { max_tokens: 512 } } });
});
it("leaves other tiers and models alone", () => {
expect(
setTierModelReasoningEffort({ COMPLEX: { opus: { reasoning_effort: "high" } } }, "MEDIUM", "opus", "low"),
).toEqual({
COMPLEX: { opus: { reasoning_effort: "high" } },
MEDIUM: { opus: { reasoning_effort: "low" } },
});
});
});
describe("pruneTierModelParams", () => {
it("drops params for models deselected from the tier", () => {
expect(
pruneTierModelParams({ MEDIUM: { opus: { reasoning_effort: "medium" } } }, "MEDIUM", ["mini"]),
).toBeUndefined();
});
it("keeps params for models still selected", () => {
const current = { MEDIUM: { opus: { reasoning_effort: "medium" } } };
expect(pruneTierModelParams(current, "MEDIUM", ["opus", "mini"])).toEqual(current);
});
it("returns the input unchanged when the tier holds no params", () => {
const current = { COMPLEX: { opus: { reasoning_effort: "high" } } };
expect(pruneTierModelParams(current, "MEDIUM", [])).toBe(current);
});
});

View file

@ -1,19 +1,119 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
export type TierModelParams = Record<string, unknown>;
export type TierModelParamsByTier = Record<string, Record<string, TierModelParams>>;
export const REASONING_EFFORT_OPTIONS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number];
const asRecord = (raw: unknown): Record<string, unknown> | undefined =>
typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;
const asTierEntryObject = (entry: unknown): { model_name: string; litellm_params: TierModelParams } | undefined => {
const record = asRecord(entry);
if (record === undefined || typeof record.model_name !== "string" || !record.model_name) return undefined;
return { model_name: record.model_name, litellm_params: asRecord(record.litellm_params) ?? {} };
};
/**
* A complexity tier maps to `str | list[str]` on the backend
* (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"),
* and the router widens the bare string with `models if isinstance(models, list) else [models]`.
* A complexity tier maps to `str | object | list[str | object]` on the backend
* (litellm/router_strategy/complexity_router/config.py: string/object = pin; list = random pick;
* an object is `{model_name, litellm_params}`), and the router widens a bare value to a list.
*
* Every UI reader of a STORED complexity_router_config must widen the same way, so this is the
* single owner of that rule. Readers of in-memory ComplexityTiers state are already string[]
* and do not need it.
*/
export const normalizeTierModels = (value: unknown): string[] => {
if (Array.isArray(value)) return value.filter((model): model is string => typeof model === "string");
if (typeof value === "string" && value) return [value];
return [];
const entries = Array.isArray(value) ? value : [value];
return entries.flatMap((entry) => {
if (typeof entry === "string" && entry) return [entry];
const parsed = asTierEntryObject(entry);
return parsed ? [parsed.model_name] : [];
});
};
const tierEntriesWithParams = (value: unknown): [string, TierModelParams][] =>
(Array.isArray(value) ? value : [value])
.map(asTierEntryObject)
.filter((entry): entry is { model_name: string; litellm_params: TierModelParams } => entry !== undefined)
.filter((entry) => Object.keys(entry.litellm_params).length > 0)
.map((entry) => [entry.model_name, entry.litellm_params]);
/**
* Params can be stored two ways: inline object entries in `tiers`, or the sibling
* `tier_model_configs` key. The backend merges them with `tier_model_configs` winning per
* (tier, model) (config.py `_normalize_tier_model_configs`), so hydration must too.
*/
export const hydrateTierModelParams = (
storedTiers: unknown,
storedTierModelConfigs: unknown,
): TierModelParamsByTier | undefined => {
const fromInline = Object.entries(asRecord(storedTiers) ?? {}).map(
([tier, value]) => [tier, tierEntriesWithParams(value)] as const,
);
const fromSibling = Object.entries(asRecord(storedTierModelConfigs) ?? {}).map(
([tier, value]) => [tier, tierEntriesWithParams(value)] as const,
);
const merged = [...fromInline, ...fromSibling].reduce<TierModelParamsByTier>(
(byTier, [tier, entries]) =>
entries.length === 0 ? byTier : { ...byTier, [tier]: { ...byTier[tier], ...Object.fromEntries(entries) } },
{},
);
return Object.keys(merged).length > 0 ? merged : undefined;
};
/**
* Undefined when empty rather than `{}`, so an untouched router keeps the key out of its payload;
* tiers this editor does not render pass through rather than being dropped now the key is managed.
*/
export const serializeTierModelConfigs = (
tiers: ComplexityTiers,
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 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 }));
return [tier, entries] as const;
})
.filter(([, entries]) => entries.length > 0);
return serialized.length > 0 ? Object.fromEntries(serialized) : undefined;
};
export const setTierModelReasoningEffort = (
current: TierModelParamsByTier | undefined,
tier: string,
model: string,
effort: ReasoningEffort | undefined,
): TierModelParamsByTier | undefined => {
const { reasoning_effort: _dropped, ...rest } = current?.[tier]?.[model] ?? {};
const params = effort === undefined ? rest : { ...rest, reasoning_effort: effort };
const byModel = Object.fromEntries(
Object.entries({ ...current?.[tier], [model]: params }).filter(([, value]) => Object.keys(value).length > 0),
);
const next = Object.fromEntries(
Object.entries({ ...current, [tier]: byModel }).filter(([, value]) => Object.keys(value).length > 0),
);
return Object.keys(next).length > 0 ? next : undefined;
};
export const pruneTierModelParams = (
current: TierModelParamsByTier | undefined,
tier: string,
selectedModels: string[],
): TierModelParamsByTier | undefined => {
if (current?.[tier] === undefined) return current;
const byModel = Object.fromEntries(Object.entries(current[tier]).filter(([model]) => selectedModels.includes(model)));
const next = Object.fromEntries(
Object.entries({ ...current, [tier]: byModel }).filter(([, value]) => Object.keys(value).length > 0),
);
return Object.keys(next).length > 0 ? next : undefined;
};
/**

View file

@ -373,3 +373,59 @@ describe("buildUpdatedComplexityRouterConfig plan-mode minimum tier", () => {
expect(result.plan_mode_min_tier).toBe("MEDIUM");
});
});
describe("buildUpdatedComplexityRouterConfig tier model params", () => {
const storedWithParams = {
...STORED,
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["opus"], COMPLEX: ["opus"], REASONING: [] },
tier_model_configs: {
MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }],
COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }],
},
};
const formValueWithParams = {
...FORM_VALUE,
tiers: storedWithParams.tiers,
tier_model_params: {
MEDIUM: { opus: { reasoning_effort: "medium" } },
COMPLEX: { opus: { reasoning_effort: "high" } },
},
};
it("round-trips hydrated params on an untouched save", () => {
const result = buildUpdatedComplexityRouterConfig(storedWithParams, formValueWithParams, undefined, hydratedState);
expect(result.tier_model_configs).toEqual(storedWithParams.tier_model_configs);
});
// tier_model_configs is managed now that this modal renders a control for it. Before that, the
// stale stored key was carried through, so clearing the last effort could never persist.
it("drops the stored key entirely when the operator unsets every effort", () => {
const result = buildUpdatedComplexityRouterConfig(
storedWithParams,
{ ...formValueWithParams, tier_model_params: undefined },
undefined,
hydratedState,
);
expect(result).not.toHaveProperty("tier_model_configs");
});
it("drops params for a model removed from its tier", () => {
const result = buildUpdatedComplexityRouterConfig(
storedWithParams,
{
...formValueWithParams,
tiers: { ...storedWithParams.tiers, COMPLEX: ["gpt-4o-mini"] },
},
undefined,
hydratedState,
);
expect(result.tier_model_configs).toEqual({
MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }],
});
});
it("emits no tier_model_configs for a config that never had params", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
expect(result).not.toHaveProperty("tier_model_configs");
});
});

View file

@ -14,7 +14,12 @@ 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 { normalizeTierModels, resolveComplexityDefaultModel } from "../add_model/complexity_router_tiers";
import {
hydrateTierModelParams,
normalizeTierModels,
resolveComplexityDefaultModel,
serializeTierModelConfigs,
} from "../add_model/complexity_router_tiers";
import { isComplexityRouter } from "../add_model/auto_router_strategies";
import {
getKeywordTierRulesError,
@ -66,6 +71,7 @@ interface EditAutoRouterModalProps {
// actually renders a control that can set it.
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"tier_model_configs",
"default_model",
"plan_mode_min_tier",
"tier_labels",
@ -149,9 +155,12 @@ export const buildUpdatedComplexityRouterConfig = (
const serializedTierLabels = serializeTierLabels(value.tier_labels);
const scorerRuns = heuristicScoringRole(value) !== "never";
const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params);
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 }),
@ -342,6 +351,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = {
tiers: hydratedTiers,
tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
default_model: hydratePinnedDefaultModel(
parsedConfig.default_model,
modelData.litellm_params?.complexity_router_default_model,

View file

@ -6,6 +6,7 @@ import { modelAvailableCall, modelHubCall } from "@/components/networking";
export interface ModelGroup {
model_group: string;
mode?: string;
supports_reasoning?: boolean;
}
interface AvailableModel {
@ -13,6 +14,7 @@ interface AvailableModel {
model_name?: string | null;
id?: string | null;
mode?: string | null;
supports_reasoning?: boolean | null;
}
export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise<ModelGroup[]> => {
@ -36,6 +38,7 @@ export const fetchAvailableModels = async (accessToken: string): Promise<ModelGr
.map((item: AvailableModel) => ({
model_group: item.model_group || item.id || item.model_name || "",
mode: item.mode || undefined,
supports_reasoning: item.supports_reasoning === true || undefined,
}))
.filter((model: ModelGroup) => model.model_group !== "");