feat(ui): modality routing toggle on the auto-router create and edit forms (#39059)

This commit is contained in:
tin-berri 2026-08-31 23:08:40 -07:00 • committed by GitHub
parent 635a8ad308
commit db46973ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 138 additions and 21 deletions

View file

@ -16,6 +16,7 @@
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"modality_routing": false,
"deployment_affinity": true
}
},
@ -33,6 +34,7 @@
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"modality_routing": false,
"deployment_affinity": true
}
},
@ -60,6 +62,7 @@
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"modality_routing": false,
"deployment_affinity": true
}
},
@ -77,6 +80,7 @@
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"modality_routing": false,
"deployment_affinity": true
}
}

View file

@ -0,0 +1,26 @@
import React from "react";
import { Switch } from "@/components/ui/switch";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { DEFAULT_DEPLOYMENT_AFFINITY } from "./ComplexityRouterConfig";
export const AffinityControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}> = ({ value, onChange }) => (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY}
onCheckedChange={(deploymentAffinity) => onChange({ ...value, deployment_affinity: deploymentAffinity })}
aria-label="Pin a session to one deployment per model group"
/>
<strong className="font-semibold">Pin a session to one deployment per model group</strong>
</div>
<span className="block text-xs text-muted-foreground">
Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to
load-balance every turn.
</span>
</>
);

View file

@ -836,6 +836,27 @@ describe("ComplexityRouterConfig tier labels", () => {
});
});
describe("ComplexityRouterConfig modality panel", () => {
it("defaults the image-routing switch off and writes modality_routing through onChange", () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
fireEvent.click(screen.getByText("Advanced: Modality Routing"));
const toggle = screen.getByRole("switch", { name: "Route image requests to vision-capable models" });
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(onChange).toHaveBeenCalledWith({ ...defaultValue, modality_routing: true });
});
it("renders a stored modality_routing=true as on", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={{ ...defaultValue, modality_routing: true }} />);
fireEvent.click(screen.getByText("Advanced: Modality Routing"));
expect(screen.getByRole("switch", { name: "Route image requests to vision-capable models" })).toBeChecked();
});
});
describe("ComplexityRouterConfig affinity panel", () => {
it("holds the deployment switch at its backend default, session pinning having moved to the frequency choice", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);

View file

@ -4,6 +4,9 @@ import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { AffinityControls } from "./AffinityControls";
import { ModalityRoutingControls } from "./ModalityRoutingControls";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
@ -396,6 +399,7 @@ export interface ComplexityRouterConfigValue {
heuristic_first_max_tier?: string;
classification_mode?: ClassificationMode;
session_affinity?: boolean;
modality_routing?: boolean;
deployment_affinity?: boolean;
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
plan_mode_min_tier?: string;
@ -511,26 +515,6 @@ export const DEFAULT_HEURISTIC_FIRST_MAX_TIER = "SIMPLE";
*/
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_KEYS.slice(0, -1);
const AffinityControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}> = ({ value, onChange }) => (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY}
onCheckedChange={(deploymentAffinity) => onChange({ ...value, deployment_affinity: deploymentAffinity })}
aria-label="Pin a session to one deployment per model group"
/>
<strong className="font-semibold">Pin a session to one deployment per model group</strong>
</div>
<span className="block text-xs text-muted-foreground">
Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to
load-balance every turn.
</span>
</>
);
const PlanModeOverrideControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
@ -841,6 +825,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
children: <AffinityControls value={value} onChange={onChange} />,
},
{
key: "modality",
label: <strong className="text-foreground font-semibold">Advanced: Modality Routing</strong>,
children: <ModalityRoutingControls value={value} onChange={onChange} />,
},
{
key: "plan-mode",
label: <strong className="text-foreground font-semibold">Advanced: Plan-Mode Override</strong>,

View file

@ -0,0 +1,26 @@
import React from "react";
import { Switch } from "@/components/ui/switch";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
export const ModalityRoutingControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}> = ({ value, onChange }) => (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.modality_routing ?? false}
onCheckedChange={(modalityRouting) => onChange({ ...value, modality_routing: modalityRouting })}
aria-label="Route image requests to vision-capable models"
/>
<strong className="font-semibold">Route image requests to vision-capable models</strong>
</div>
<span className="block text-xs text-muted-foreground">
Replaces a routed model that cannot take image input with the nearest higher tier that can, then the default
model, instead of failing with a provider 400. Only models explicitly declared supports_vision false are replaced,
and a kept session pin still wins.
</span>
</>
);

View file

@ -351,6 +351,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
classifierContextIncludeAssistantTurns: complexityRouterConfig.classifier_context_include_assistant_turns,
classifierFallback: complexityRouterConfig.classifier_fallback,
sessionAffinity: complexityRouterConfig.session_affinity ?? DEFAULT_SESSION_AFFINITY,
modalityRouting: complexityRouterConfig.modality_routing ?? false,
deploymentAffinity: complexityRouterConfig.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
customTechnicalKeywords,
keywordTierRules,

View file

@ -55,6 +55,7 @@ describe("buildComplexityRouterConfig", () => {
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
modality_routing: false,
escalation_keywords: ["LITELLM ESCALATE"],
};
expect(config).toEqual(expected);
@ -248,6 +249,12 @@ describe("buildComplexityRouterConfig", () => {
expect(config.return_raw_model_name).toBeUndefined();
});
it("writes modality_routing explicitly both ways, so the stored config never relies on the backend default", () => {
expect(buildComplexityRouterConfig({ ...baseParams, modalityRouting: true }).modality_routing).toBe(true);
expect(buildComplexityRouterConfig(baseParams).modality_routing).toBe(false);
expect(buildComplexityRouterConfig({ ...baseParams, modalityRouting: false }).modality_routing).toBe(false);
});
it("writes session_affinity=true so turning the toggle on overrides the backend's off-by-default", () => {
const config = buildComplexityRouterConfig({ ...baseParams, sessionAffinity: true });
expect(config.session_affinity).toBe(true);

View file

@ -109,6 +109,7 @@ export interface BuildComplexityRouterConfigParams {
heuristicFirstMaxTier: string | undefined;
classificationMode: ClassificationMode | undefined;
sessionAffinity: boolean;
modalityRouting?: boolean;
deploymentAffinity: boolean;
customTechnicalKeywords: string[];
keywordTierRules: KeywordTierRule[];
@ -165,6 +166,7 @@ export interface ComplexityRouterConfigPayload {
classification_mode: ClassificationMode;
session_affinity: boolean;
deployment_affinity: boolean;
modality_routing: boolean;
custom_technical_keywords?: string[];
keyword_tier_rules?: { keywords: string[]; tier: KeywordTierRule["tier"] }[];
semantic_keyword_matching?: boolean;
@ -399,6 +401,7 @@ export const buildComplexityRouterConfig = ({
heuristicFirstMaxTier,
classificationMode,
sessionAffinity,
modalityRouting,
deploymentAffinity,
customTechnicalKeywords,
keywordTierRules,
@ -460,6 +463,7 @@ export const buildComplexityRouterConfig = ({
classification_mode: classificationMode ?? DEFAULT_CLASSIFICATION_MODE,
session_affinity: sessionAffinity,
deployment_affinity: deploymentAffinity,
modality_routing: modalityRouting ?? false,
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
escalation_keywords: cleanedEscalationKeywords,

View file

@ -1,4 +1,4 @@
import { buildUpdatedComplexityRouterConfig } from "./edit_auto_router_modal";
import { buildUpdatedComplexityRouterConfig, hydrateComplexityRouterConfig } from "./edit_auto_router_modal";
const storedConfigValue = {
tiers: {
@ -50,6 +50,7 @@ const expectedClassifiedTierConfig = {
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
modality_routing: false,
adaptive: true,
adaptive_weights: { quality: 0.4, cost: 0.6 },
adaptive_eligible: "classified_tier",
@ -72,6 +73,7 @@ const expectedAdaptiveDisabledConfig = {
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
modality_routing: false,
};
describe("buildUpdatedComplexityRouterConfig", () => {
@ -87,6 +89,26 @@ describe("buildUpdatedComplexityRouterConfig", () => {
expect(updatedConfig).toEqual(expectedAdaptiveDisabledConfig);
});
it("hydrates a stored modality_routing into form state and defaults absent to off", () => {
expect(hydrateComplexityRouterConfig({ ...storedConfig, modality_routing: true }, null).modality_routing).toBe(
true,
);
expect(hydrateComplexityRouterConfig(storedConfig, null).modality_routing).toBe(false);
});
it("round-trips modality_routing explicitly in both directions", () => {
const enabled = buildUpdatedComplexityRouterConfig(storedConfig, {
...classifiedTierValue,
modality_routing: true,
});
expect(enabled.modality_routing).toBe(true);
const disabled = buildUpdatedComplexityRouterConfig(
{ ...storedConfig, modality_routing: true },
{ ...classifiedTierValue, modality_routing: false },
);
expect(disabled.modality_routing).toBe(false);
});
it("includes return_raw_model_name only when enabled", () => {
const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, {
...classifiedTierValue,

View file

@ -102,6 +102,7 @@ export interface StoredComplexityRouterConfig {
dimension_weights?: unknown;
reasoning_override_min_score?: unknown;
session_affinity?: unknown;
modality_routing?: unknown;
deployment_affinity?: unknown;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
@ -176,6 +177,7 @@ export const hydrateComplexityRouterConfig = (
reasoning_override_min_score: hydrateReasoningOverrideMinScore(parsedConfig.reasoning_override_min_score),
session_affinity:
typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY,
modality_routing: typeof parsedConfig.modality_routing === "boolean" ? parsedConfig.modality_routing : false,
deployment_affinity:
typeof parsedConfig.deployment_affinity === "boolean"
? parsedConfig.deployment_affinity
@ -214,6 +216,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"heuristic_first_max_tier",
"classification_mode",
"session_affinity",
"modality_routing",
"deployment_affinity",
"adaptive",
"adaptive_weights",
@ -309,6 +312,7 @@ export const buildUpdatedComplexityRouterConfig = (
classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns,
classifierFallback: value.classifier_fallback,
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
modalityRouting: value.modality_routing ?? false,
deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
customTechnicalKeywords: customTechnicalKeywords ?? [],
keywordTierRules: keywordMatching?.keywordTierRules ?? [],

View file

@ -129,6 +129,18 @@ describe("autorouter_presets", () => {
}
});
it("carries a preset's modality_routing into the prefilled form state", () => {
const preset = getPresetByKey("anthropic_family")!;
const withFlag = { ...preset.complexity_router_config, modality_routing: true };
const prefill = buildPresetPrefill(withFlag, groupsOnly(getRequiredModelsInPreset(preset)));
expect(prefill.complexityRouterConfig.modality_routing).toBe(true);
const withoutFlag = buildPresetPrefill(
preset.complexity_router_config,
groupsOnly(getRequiredModelsInPreset(preset)),
);
expect(withoutFlag.complexityRouterConfig.modality_routing).toBe(false);
});
it("prefills the anthropic preset's effort through to tier_model_params", () => {
const preset = getPresetByKey("anthropic_family")!;
const prefill = buildPresetPrefill(preset.complexity_router_config, groupsOnly(getRequiredModelsInPreset(preset)));

View file

@ -292,6 +292,7 @@ export const buildPresetPrefill = (
classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE,
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
modality_routing: config.modality_routing ?? false,
adaptive: config.adaptive,
adaptive_weights: config.adaptive_weights,
tier_distance_penalty: config.tier_distance_penalty,