mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
2dcd453860
commit
cb4eb82249
11 changed files with 555 additions and 12 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 !== "");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue