mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat(complexity_router): opt-in modality override of a kept session-affinity pin (#39454)
A session pinned to a text-only model failed every image turn with a provider 400, because the modality gate exempts a kept pin by cause. Add modality_pin_override so that exemption is conditional: the image turn is re-placed on a capable model for that request only, reported as cause modality_pin_override, and the stored pin is left untouched so the next text turn replays it. The pin write on the replay path already happens upstream of the gate and stores the session's own model, so pin survival is structural rather than bookkeeping. The new cause joins the non-pinnable set. Default off at every layer.
This commit is contained in:
parent
a5639b8e2a
commit
64e45a069d
22 changed files with 374 additions and 27 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"modality_routing": false,
|
||||
"modality_pin_override": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
},
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"modality_routing": false,
|
||||
"modality_pin_override": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
},
|
||||
|
|
@ -63,6 +65,7 @@
|
|||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"modality_routing": false,
|
||||
"modality_pin_override": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
},
|
||||
|
|
@ -84,6 +87,7 @@
|
|||
"classification_mode": "every_request",
|
||||
"session_affinity": false,
|
||||
"modality_routing": false,
|
||||
"modality_pin_override": false,
|
||||
"deployment_affinity": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ model_list:
|
|||
|
||||
# Replace a routed model that cannot take image input (default: false)
|
||||
modality_routing: true
|
||||
|
||||
# Let that replacement also override a kept session pin, for image turns only (default: false)
|
||||
modality_pin_override: true
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
@ -227,9 +230,16 @@ vision model sits below the decided tier gets the 400 and an actionable message
|
|||
A same-tier re-pick keeps the decision's cause and adds `modality:image` to `signals`; a tier
|
||||
change or default takeover records `cause: modality_escalation` with the displaced placement
|
||||
(`modality_escalated_from:<TIER>` or `modality_displaced_default_model`). Escalations are never
|
||||
pinned by session affinity, and a KEPT session pin bypasses the gate entirely: a session pinned
|
||||
pinned by session affinity, and by default a KEPT session pin bypasses the gate: a session pinned
|
||||
to a text-only model keeps it even when an image arrives.
|
||||
|
||||
Add `modality_pin_override: true` to lift that last exemption. The image turn is then re-placed
|
||||
the same way every other decision is, and records `cause: modality_pin_override` whether or not
|
||||
the tier moved, since the model left the pin either way. The pin itself is untouched: the session
|
||||
affinity write happens upstream of the gate and stores the session's own model, so the next text
|
||||
turn replays the original pin and the override is never pinned in its place. It does nothing
|
||||
unless `modality_routing` is also on.
|
||||
|
||||
### Heuristic-first chaining
|
||||
|
||||
`classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM
|
||||
|
|
|
|||
|
|
@ -751,7 +751,8 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
|
|||
|
||||
A modality escalation is transient the same way: it describes what this one call carries (an
|
||||
image), not what the session's traffic looks like, and pinning it would hold every following
|
||||
text turn on the vision-capable model the image forced.
|
||||
text turn on the vision-capable model the image forced. A modality pin override is the same
|
||||
fact on a session that already holds a pin, so it must not overwrite the pin it displaced.
|
||||
"""
|
||||
return decision is None or (
|
||||
decision.get("cause")
|
||||
|
|
@ -760,6 +761,7 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
|
|||
"plan_mode",
|
||||
"housekeeping",
|
||||
"modality_escalation",
|
||||
"modality_pin_override",
|
||||
)
|
||||
and not decision.get("context_escalated")
|
||||
)
|
||||
|
|
@ -2393,8 +2395,11 @@ class ComplexityRouter(CustomLogger):
|
|||
"""Replace a routed model that cannot accept this request's image input.
|
||||
|
||||
The single modality owner, applied to the decided response at the hook's exits so every
|
||||
routing path is covered uniformly. A KEPT session pin is exempt by design (its cause);
|
||||
replacement picks and every other path are just responses. The re-placement walks
|
||||
routing path is covered uniformly. A KEPT session pin is exempt by design (its cause)
|
||||
unless modality_pin_override is set, in which case the image turn is re-placed and reported
|
||||
as modality_pin_override while the stored pin, written upstream from the session's own
|
||||
model, is left for the next text turn; replacement picks and every other path are just
|
||||
responses. The re-placement walks
|
||||
UPWARD-ONLY from the decision's tier (so a plan-mode floor can never be undercut), picks
|
||||
through `_pick_model_for_tier` so routing plugins still apply, then falls to
|
||||
default_model (never on plugin routers, and never on a plan-floored decision, since
|
||||
|
|
@ -2407,7 +2412,11 @@ class ComplexityRouter(CustomLogger):
|
|||
not self.config.modality_routing
|
||||
or not resolved_messages
|
||||
or response.model is None
|
||||
or (decision is not None and decision.get("cause") == "session_affinity_pin")
|
||||
or (
|
||||
decision is not None
|
||||
and decision.get("cause") == "session_affinity_pin"
|
||||
and not self.config.modality_pin_override
|
||||
)
|
||||
or not request_contains_image_content(resolved_messages)
|
||||
or self._model_accepts_image_input(response.model)
|
||||
):
|
||||
|
|
@ -2449,6 +2458,10 @@ class ComplexityRouter(CustomLogger):
|
|||
self._restamp_adaptive_choice(request_kwargs, response.model, new_model)
|
||||
same_tier: Final = capable is not None and decided == capable
|
||||
base_cause: Final = (decision.get("cause") if decision is not None else None) or "default_fallback"
|
||||
# Reaching here on a kept pin means modality_pin_override is on, since the guard above
|
||||
# returns otherwise. The model moved off the pin even on a same-tier repick, so reporting
|
||||
# the pin's own cause would claim the session's model served a request it did not.
|
||||
displaced_pin: Final = base_cause == "session_affinity_pin"
|
||||
displaced_default: Final = decided is None and response.model == self.config.default_model
|
||||
markers: Final = (
|
||||
"modality:image",
|
||||
|
|
@ -2458,7 +2471,7 @@ class ComplexityRouter(CustomLogger):
|
|||
old_signals: Final = tuple(decision.get("signals") or ()) if decision is not None else ()
|
||||
new_decision: Final = self._build_routing_decision(
|
||||
routed_model=new_model,
|
||||
cause=base_cause if same_tier else "modality_escalation",
|
||||
cause="modality_pin_override" if displaced_pin else (base_cause if same_tier else "modality_escalation"),
|
||||
tier=new_tier,
|
||||
score=decision.get("score") if decision is not None else None,
|
||||
signals=(*old_signals, *markers),
|
||||
|
|
|
|||
|
|
@ -883,7 +883,20 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"a routed model explicitly declared supports_vision false (deployment model_info "
|
||||
"or the model cost map; unmapped names stay routable) is replaced by the nearest "
|
||||
"HIGHER tier holding a capable model, then default_model, else a clear 400. A kept "
|
||||
"session-affinity pin still wins even when an image arrives."
|
||||
"session-affinity pin still wins even when an image arrives, unless "
|
||||
"modality_pin_override is also enabled."
|
||||
),
|
||||
)
|
||||
modality_pin_override: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Let modality_routing replace a kept session-affinity pin on the turns that carry an "
|
||||
"image. Without this, a session pinned to a text-only model fails every image turn with "
|
||||
"a provider 400, since the pin is exempt from the modality gate. When enabled, such a "
|
||||
"turn routes to a capable model for that request only and the stored pin is left "
|
||||
"untouched, so the next text turn replays the session's own model; the override is "
|
||||
"reported as cause modality_pin_override and is never itself pinned. Inert unless "
|
||||
"modality_routing is also enabled."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2879,6 +2879,10 @@ RoutingDecisionCause = Literal[
|
|||
# routed model does not accept image input, so the nearest higher capable tier or
|
||||
# default_model served instead. The displaced placement rides in signals.
|
||||
"modality_escalation",
|
||||
# modality_pin_override replaced a KEPT session-affinity pin for this request only: the turn
|
||||
# carries an image the pinned model cannot accept. The stored pin is untouched, so the next
|
||||
# text turn replays it. Distinct from "modality_escalation", which never displaces a pin.
|
||||
"modality_pin_override",
|
||||
"session_affinity_pin",
|
||||
"session_affinity_escalation",
|
||||
# classification_mode 'user_turn': the request is an agent loop's continuation turn (no new
|
||||
|
|
|
|||
|
|
@ -10797,6 +10797,9 @@ class TestModalityRouting:
|
|||
("custom_tiers_walk", "premium-model", "modality_escalation"),
|
||||
("pin_kept_bypasses", "text-cheap", "session_affinity_pin"),
|
||||
("pin_replacement_gated", "vision-big", "modality_escalation"),
|
||||
("pin_override_escalates", "vision-mid", "modality_pin_override"),
|
||||
("pin_override_same_tier", "vision-cheap", "modality_pin_override"),
|
||||
("pin_override_inert_without_modality_routing", "text-cheap", "session_affinity_pin"),
|
||||
("adaptive_pick_rewritten", "vision-mid", "modality_escalation"),
|
||||
],
|
||||
)
|
||||
|
|
@ -10847,7 +10850,7 @@ class TestModalityRouting:
|
|||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "quick lookup: what is this?"}, IMG_PART]}
|
||||
]
|
||||
elif path in ("pin_kept_bypasses", "pin_replacement_gated"):
|
||||
elif path.startswith(("pin_kept", "pin_replacement", "pin_override")):
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value={"model": "text-cheap", "tier": "SIMPLE"})
|
||||
mock_router_instance.cache = cache
|
||||
|
|
@ -10859,6 +10862,13 @@ class TestModalityRouting:
|
|||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "LITELLM ESCALATE describe this"}, IMG_PART]}
|
||||
]
|
||||
elif path == "pin_override_same_tier":
|
||||
config["modality_pin_override"] = True
|
||||
config["tiers"]["SIMPLE"] = ["text-cheap", "vision-cheap"]
|
||||
vision["vision-cheap"] = True
|
||||
elif path == "pin_override_inert_without_modality_routing":
|
||||
config["modality_routing"] = False
|
||||
config["modality_pin_override"] = path.startswith("pin_override")
|
||||
elif path == "adaptive_pick_rewritten":
|
||||
config["adaptive"] = True
|
||||
mock_router_instance.model_list = []
|
||||
|
|
@ -11025,4 +11035,60 @@ class TestModalityRouting:
|
|||
from litellm.router_strategy.complexity_router.complexity_router import _decision_is_pinnable
|
||||
|
||||
assert _decision_is_pinnable({"cause": "modality_escalation"}) is False
|
||||
assert _decision_is_pinnable({"cause": "modality_pin_override"}) is False
|
||||
assert _decision_is_pinnable({"cause": "heuristic_scorer"}) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_override_serves_the_image_turn_without_repinning(self, mock_router_instance):
|
||||
"""The override is for one request: the session keeps the model it was pinned to."""
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value={"model": "text-cheap", "tier": "SIMPLE"})
|
||||
mock_router_instance.cache = cache
|
||||
router = self._router(
|
||||
mock_router_instance,
|
||||
{
|
||||
"tiers": dict(self.BASE_TIERS),
|
||||
"modality_routing": True,
|
||||
"modality_pin_override": True,
|
||||
"session_affinity": True,
|
||||
},
|
||||
dict(self.BASE_VISION),
|
||||
)
|
||||
request_kwargs = {"metadata": {"session_id": "s1"}}
|
||||
|
||||
image_turn = await router.async_pre_routing_hook(
|
||||
model="m", request_kwargs=request_kwargs, messages=self.IMAGE_MESSAGE
|
||||
)
|
||||
assert image_turn.model == "vision-mid"
|
||||
assert image_turn.routing_decision["cause"] == "modality_pin_override"
|
||||
assert "modality_escalated_from:SIMPLE" in image_turn.routing_decision["signals"]
|
||||
|
||||
assert cache.async_set_cache.await_args.kwargs["value"] == {"model": "text-cheap", "tier": "SIMPLE"}
|
||||
|
||||
text_turn = await router.async_pre_routing_hook(
|
||||
model="m", request_kwargs={"metadata": {"session_id": "s1"}}, messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert text_turn.model == "text-cheap"
|
||||
assert text_turn.routing_decision["cause"] == "session_affinity_pin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_override_with_no_capable_model_rejects_and_keeps_the_pin(self, mock_router_instance):
|
||||
"""The clear 400 replaces the provider's, and a rejected turn must not cost the session its pin."""
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value={"model": "text-cheap", "tier": "SIMPLE"})
|
||||
mock_router_instance.cache = cache
|
||||
router = self._router(
|
||||
mock_router_instance,
|
||||
{
|
||||
"tiers": {"SIMPLE": "text-cheap", "COMPLEX": "text-big"},
|
||||
"modality_routing": True,
|
||||
"modality_pin_override": True,
|
||||
"session_affinity": True,
|
||||
},
|
||||
{"text-cheap": False, "text-big": False},
|
||||
)
|
||||
with pytest.raises(litellm.BadRequestError, match="no model"):
|
||||
await router.async_pre_routing_hook(
|
||||
model="m", request_kwargs={"metadata": {"session_id": "s1"}}, messages=self.IMAGE_MESSAGE
|
||||
)
|
||||
assert cache.async_set_cache.await_args.kwargs["value"] == {"model": "text-cheap", "tier": "SIMPLE"}
|
||||
|
|
|
|||
|
|
@ -880,6 +880,44 @@ describe("ComplexityRouterConfig modality panel", () => {
|
|||
|
||||
expect(screen.getByRole("switch", { name: "Route image requests to vision-capable models" })).toBeChecked();
|
||||
});
|
||||
|
||||
// The backend ignores modality_pin_override unless modality_routing is on, so offering it while
|
||||
// image routing is off would let an operator save a flag that does nothing.
|
||||
it("disables the pin-override switch while image routing is off", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Modality Routing"));
|
||||
|
||||
const override = screen.getByRole("switch", { name: "Override session pin for image requests" });
|
||||
expect(override).toHaveAttribute("aria-disabled", "true");
|
||||
fireEvent.click(override);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes modality_pin_override through onChange once image routing is on", () => {
|
||||
const onChange = vi.fn();
|
||||
const value = { ...defaultValue, modality_routing: true };
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={value} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Modality Routing"));
|
||||
|
||||
const override = screen.getByRole("switch", { name: "Override session pin for image requests" });
|
||||
expect(override).not.toBeChecked();
|
||||
fireEvent.click(override);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...value, modality_pin_override: true });
|
||||
});
|
||||
|
||||
it("renders a stored modality_pin_override=true as on", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{ ...defaultValue, modality_routing: true, modality_pin_override: true }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Modality Routing"));
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Override session pin for image requests" })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig affinity panel", () => {
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ export interface ComplexityRouterConfigValue {
|
|||
classification_mode?: ClassificationMode;
|
||||
session_affinity?: boolean;
|
||||
modality_routing?: boolean;
|
||||
modality_pin_override?: 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;
|
||||
|
|
|
|||
|
|
@ -7,20 +7,36 @@ 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>
|
||||
</>
|
||||
);
|
||||
}> = ({ value, onChange }) => {
|
||||
const modalityRouting = value.modality_routing ?? false;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={modalityRouting}
|
||||
onCheckedChange={(nextModalityRouting) => onChange({ ...value, modality_routing: nextModalityRouting })}
|
||||
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 mb-3 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 unless you turn on the override below.
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.modality_pin_override ?? false}
|
||||
onCheckedChange={(modalityPinOverride) => onChange({ ...value, modality_pin_override: modalityPinOverride })}
|
||||
disabled={!modalityRouting}
|
||||
aria-label="Override session pin for image requests"
|
||||
/>
|
||||
<strong className="font-semibold">Override session pin for image requests</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Route an image turn to a capable model even when the session is pinned to one that cannot take images. The pin
|
||||
is kept, so the next text turn goes back to it. Needs image routing turned on.
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -590,6 +590,44 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("writes both modality flags as false into the create payload when the panel stays untouched", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "modality-router" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
modality_routing: false,
|
||||
modality_pin_override: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the pin override through to the create payload once image routing unlocks it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "modality-router" } });
|
||||
expandDetailedConfiguration();
|
||||
await user.click(screen.getByText("Advanced: Modality Routing"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Route image requests to vision-capable models" }));
|
||||
await user.click(await screen.findByRole("switch", { name: "Override session pin for image requests" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
modality_routing: true,
|
||||
modality_pin_override: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Custom is the escape hatch, not the headline choice, so it's listed after every bundled preset
|
||||
// rather than first.
|
||||
it("lists Custom Configuration after the bundled presets", () => {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
classifierFallback: complexityRouterConfig.classifier_fallback,
|
||||
sessionAffinity: complexityRouterConfig.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
modalityRouting: complexityRouterConfig.modality_routing ?? false,
|
||||
modalityPinOverride: complexityRouterConfig.modality_pin_override ?? false,
|
||||
deploymentAffinity: complexityRouterConfig.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
customTechnicalKeywords,
|
||||
keywordTierRules,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ describe("buildComplexityRouterConfig", () => {
|
|||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
modality_routing: false,
|
||||
modality_pin_override: false,
|
||||
escalation_keywords: ["LITELLM ESCALATE"],
|
||||
};
|
||||
expect(config).toEqual(expected);
|
||||
|
|
@ -270,6 +271,14 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(buildComplexityRouterConfig({ ...baseParams, modalityRouting: false }).modality_routing).toBe(false);
|
||||
});
|
||||
|
||||
it("writes modality_pin_override explicitly both ways, so the stored config never relies on the backend default", () => {
|
||||
expect(buildComplexityRouterConfig({ ...baseParams, modalityPinOverride: true }).modality_pin_override).toBe(true);
|
||||
expect(buildComplexityRouterConfig(baseParams).modality_pin_override).toBe(false);
|
||||
expect(buildComplexityRouterConfig({ ...baseParams, modalityPinOverride: false }).modality_pin_override).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);
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
classificationMode: ClassificationMode | undefined;
|
||||
sessionAffinity: boolean;
|
||||
modalityRouting?: boolean;
|
||||
modalityPinOverride?: boolean;
|
||||
deploymentAffinity: boolean;
|
||||
customTechnicalKeywords: string[];
|
||||
keywordTierRules: KeywordTierRule[];
|
||||
|
|
@ -169,6 +170,7 @@ export interface ComplexityRouterConfigPayload {
|
|||
session_affinity: boolean;
|
||||
deployment_affinity: boolean;
|
||||
modality_routing: boolean;
|
||||
modality_pin_override: boolean;
|
||||
custom_technical_keywords?: string[];
|
||||
keyword_tier_rules?: { keywords: string[]; tier: KeywordTierRule["tier"] }[];
|
||||
semantic_keyword_matching?: boolean;
|
||||
|
|
@ -409,6 +411,7 @@ export const buildComplexityRouterConfig = ({
|
|||
classificationMode,
|
||||
sessionAffinity,
|
||||
modalityRouting,
|
||||
modalityPinOverride,
|
||||
deploymentAffinity,
|
||||
customTechnicalKeywords,
|
||||
keywordTierRules,
|
||||
|
|
@ -472,6 +475,7 @@ export const buildComplexityRouterConfig = ({
|
|||
session_affinity: sessionAffinity,
|
||||
deployment_affinity: deploymentAffinity,
|
||||
modality_routing: modalityRouting ?? false,
|
||||
modality_pin_override: modalityPinOverride ?? false,
|
||||
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
|
||||
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
|
||||
escalation_keywords: cleanedEscalationKeywords,
|
||||
|
|
|
|||
|
|
@ -257,6 +257,30 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig modality pin override", () => {
|
||||
it("writes modality_pin_override explicitly both ways", () => {
|
||||
expect(
|
||||
buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, modality_pin_override: true }).modality_pin_override,
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, modality_pin_override: false }).modality_pin_override,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("re-asserts the backend's off-by-default when the form value is absent, rather than dropping the key", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig({ ...STORED, modality_pin_override: true }, FORM_VALUE);
|
||||
expect(result.modality_pin_override).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips a stored modality_pin_override=true through hydrate then save", () => {
|
||||
const stored = { ...STORED, modality_routing: true, modality_pin_override: true };
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
|
||||
expect(hydrated.modality_pin_override).toBe(true);
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).modality_pin_override).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig classification mode", () => {
|
||||
it("round-trips a stored user_turn through hydrate then save", () => {
|
||||
const stored = { ...STORED, classification_mode: "user_turn" };
|
||||
|
|
@ -505,6 +529,8 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
classifier_fallback: "default_model",
|
||||
classification_mode: "user_turn",
|
||||
session_affinity: true,
|
||||
modality_routing: true,
|
||||
modality_pin_override: true,
|
||||
deployment_affinity: false,
|
||||
adaptive: true,
|
||||
adaptive_weights: { quality: 0.4, cost: 0.6 },
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const expectedClassifiedTierConfig = {
|
|||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
modality_routing: false,
|
||||
modality_pin_override: false,
|
||||
adaptive: true,
|
||||
adaptive_weights: { quality: 0.4, cost: 0.6 },
|
||||
adaptive_eligible: "classified_tier",
|
||||
|
|
@ -74,6 +75,7 @@ const expectedAdaptiveDisabledConfig = {
|
|||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
modality_routing: false,
|
||||
modality_pin_override: false,
|
||||
};
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig", () => {
|
||||
|
|
@ -109,6 +111,26 @@ describe("buildUpdatedComplexityRouterConfig", () => {
|
|||
expect(disabled.modality_routing).toBe(false);
|
||||
});
|
||||
|
||||
it("hydrates a stored modality_pin_override into form state and defaults absent to off", () => {
|
||||
expect(
|
||||
hydrateComplexityRouterConfig({ ...storedConfig, modality_pin_override: true }, null).modality_pin_override,
|
||||
).toBe(true);
|
||||
expect(hydrateComplexityRouterConfig(storedConfig, null).modality_pin_override).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips modality_pin_override explicitly in both directions", () => {
|
||||
const enabled = buildUpdatedComplexityRouterConfig(storedConfig, {
|
||||
...classifiedTierValue,
|
||||
modality_pin_override: true,
|
||||
});
|
||||
expect(enabled.modality_pin_override).toBe(true);
|
||||
const disabled = buildUpdatedComplexityRouterConfig(
|
||||
{ ...storedConfig, modality_pin_override: true },
|
||||
{ ...classifiedTierValue, modality_pin_override: false },
|
||||
);
|
||||
expect(disabled.modality_pin_override).toBe(false);
|
||||
});
|
||||
|
||||
it("includes return_raw_model_name only when enabled", () => {
|
||||
const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, {
|
||||
...classifiedTierValue,
|
||||
|
|
|
|||
|
|
@ -549,6 +549,50 @@ describe("EditAutoRouterModal deployment affinity", () => {
|
|||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().deployment_affinity).toBe(false);
|
||||
});
|
||||
|
||||
// modality_pin_override is a managed key, so the modal rewrites it from form state on save. A
|
||||
// hydration gap would silently turn a stored override off on the next untouched save.
|
||||
it("shows a stored modality_pin_override=true as on and preserves it through an untouched save", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, modality_routing: true, modality_pin_override: true });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Modality Routing"));
|
||||
expect(await screen.findByRole("switch", { name: "Override session pin for image requests" })).toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().modality_pin_override).toBe(true);
|
||||
});
|
||||
|
||||
it("persists turning the modality pin override on", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig({ ...STORED_CONFIG, modality_routing: true });
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Modality Routing"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Override session pin for image requests" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().modality_pin_override).toBe(true);
|
||||
});
|
||||
|
||||
it("writes modality_pin_override=false for a stored config that never carried the key", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithStoredConfig(STORED_CONFIG);
|
||||
|
||||
await user.click(await screen.findByText("Advanced: Modality Routing"));
|
||||
expect(await screen.findByRole("switch", { name: "Override session pin for image requests" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().modality_pin_override).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal custom classifier prompt and fallback", () => {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export interface StoredComplexityRouterConfig {
|
|||
reasoning_override_min_score?: unknown;
|
||||
session_affinity?: unknown;
|
||||
modality_routing?: unknown;
|
||||
modality_pin_override?: unknown;
|
||||
deployment_affinity?: unknown;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
|
|
@ -181,6 +182,8 @@ export const hydrateComplexityRouterConfig = (
|
|||
session_affinity:
|
||||
typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY,
|
||||
modality_routing: typeof parsedConfig.modality_routing === "boolean" ? parsedConfig.modality_routing : false,
|
||||
modality_pin_override:
|
||||
typeof parsedConfig.modality_pin_override === "boolean" ? parsedConfig.modality_pin_override : false,
|
||||
deployment_affinity:
|
||||
typeof parsedConfig.deployment_affinity === "boolean"
|
||||
? parsedConfig.deployment_affinity
|
||||
|
|
@ -221,6 +224,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"classification_mode",
|
||||
"session_affinity",
|
||||
"modality_routing",
|
||||
"modality_pin_override",
|
||||
"deployment_affinity",
|
||||
"adaptive",
|
||||
"adaptive_weights",
|
||||
|
|
@ -318,6 +322,7 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
classifierFallback: value.classifier_fallback,
|
||||
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
modalityRouting: value.modality_routing ?? false,
|
||||
modalityPinOverride: value.modality_pin_override ?? false,
|
||||
deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
customTechnicalKeywords: customTechnicalKeywords ?? [],
|
||||
keywordTierRules: keywordMatching?.keywordTierRules ?? [],
|
||||
|
|
|
|||
|
|
@ -186,6 +186,12 @@ describe("RoutingDecisionCard", () => {
|
|||
expect(screen.queryByText("housekeeping")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a modality pin override instead of showing the raw cause token", () => {
|
||||
render(<RoutingDecisionCard decision={{ ...heuristic, cause: "modality_pin_override" }} />);
|
||||
expect(screen.getByText("Overrode session pin for image input")).toBeInTheDocument();
|
||||
expect(screen.queryByText("modality_pin_override")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a modality escalation instead of showing the raw cause token", () => {
|
||||
render(<RoutingDecisionCard decision={{ ...heuristic, cause: "modality_escalation" }} />);
|
||||
expect(screen.getByText("Escalated for image input")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
|
|||
session_affinity_escalation: "Escalated from session pin",
|
||||
user_turn_continuation: "Continuation turn, classifier skipped",
|
||||
modality_escalation: "Escalated for image input",
|
||||
modality_pin_override: "Overrode session pin for image input",
|
||||
quality_tier: "Quality tier mapping",
|
||||
bandit: "Adaptive bandit",
|
||||
default_fallback: "Default model, no route matched",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,25 @@ describe("autorouter_presets", () => {
|
|||
expect(withoutFlag.complexityRouterConfig.modality_routing).toBe(false);
|
||||
});
|
||||
|
||||
it("carries a preset's modality_pin_override into the prefilled form state", () => {
|
||||
const preset = getPresetByKey("anthropic_family")!;
|
||||
const withFlag = { ...preset.complexity_router_config, modality_routing: true, modality_pin_override: true };
|
||||
const prefill = buildPresetPrefill(withFlag, groupsOnly(getRequiredModelsInPreset(preset)));
|
||||
expect(prefill.complexityRouterConfig.modality_pin_override).toBe(true);
|
||||
const withoutFlag = buildPresetPrefill(
|
||||
preset.complexity_router_config,
|
||||
groupsOnly(getRequiredModelsInPreset(preset)),
|
||||
);
|
||||
expect(withoutFlag.complexityRouterConfig.modality_pin_override).toBe(false);
|
||||
});
|
||||
|
||||
it("ships every bundled preset with both modality flags written out, since the payload type requires them", () => {
|
||||
for (const preset of getAllPresets()) {
|
||||
expect(preset.complexity_router_config.modality_routing, preset.key).toBe(false);
|
||||
expect(preset.complexity_router_config.modality_pin_override, preset.key).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)));
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ export const buildPresetPrefill = (
|
|||
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
modality_routing: config.modality_routing ?? false,
|
||||
modality_pin_override: config.modality_pin_override ?? false,
|
||||
adaptive: config.adaptive,
|
||||
adaptive_weights: config.adaptive_weights,
|
||||
tier_distance_penalty: config.tier_distance_penalty,
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -34740,9 +34740,15 @@ export interface components {
|
|||
* @default 0.5
|
||||
*/
|
||||
match_threshold: number;
|
||||
/**
|
||||
* Modality Pin Override
|
||||
* @description Let modality_routing replace a kept session-affinity pin on the turns that carry an image. Without this, a session pinned to a text-only model fails every image turn with a provider 400, since the pin is exempt from the modality gate. When enabled, such a turn routes to a capable model for that request only and the stored pin is left untouched, so the next text turn replays the session's own model; the override is reported as cause modality_pin_override and is never itself pinned. Inert unless modality_routing is also enabled.
|
||||
* @default false
|
||||
*/
|
||||
modality_pin_override: boolean;
|
||||
/**
|
||||
* Modality Routing
|
||||
* @description Route image-bearing requests only to models that can accept image input. The classifier reads text alone, so an image request whose text classifies cheap otherwise lands on a text-only model and fails with a provider 400. When enabled, a routed model explicitly declared supports_vision false (deployment model_info or the model cost map; unmapped names stay routable) is replaced by the nearest HIGHER tier holding a capable model, then default_model, else a clear 400. A kept session-affinity pin still wins even when an image arrives.
|
||||
* @description Route image-bearing requests only to models that can accept image input. The classifier reads text alone, so an image request whose text classifies cheap otherwise lands on a text-only model and fails with a provider 400. When enabled, a routed model explicitly declared supports_vision false (deployment model_info or the model cost map; unmapped names stay routable) is replaced by the nearest HIGHER tier holding a capable model, then default_model, else a clear 400. A kept session-affinity pin still wins even when an image arrives, unless modality_pin_override is also enabled.
|
||||
* @default false
|
||||
*/
|
||||
modality_routing: boolean;
|
||||
|
|
@ -35945,7 +35951,7 @@ export interface components {
|
|||
* Cause
|
||||
* @enum {string}
|
||||
*/
|
||||
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "modality_pin_override" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
/** Classifier Cost */
|
||||
classifier_cost?: number;
|
||||
/** Classifier Model */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue