mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): modality routing toggle on the auto-router create and edit forms (#39059)
This commit is contained in:
parent
635a8ad308
commit
db46973ec4
12 changed files with 138 additions and 21 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue