feat(ui): one classification frequency picker for complexity auto-routers (#39042)

Classification timing and session affinity are the same operator question,
so Advanced: Classification Method now carries a single "How often to
classify" radio: every request, every new user message, or once per session.
The session choice writes session_affinity and stays disabled on custom tier
sets, where the backend rejects it. Advanced: Affinity keeps the deployment
switch alone.

The serializer always writes classification_mode, matching session_affinity
on the line below it, so an explicitly stored every_request survives an
untouched save instead of being dropped back to the backend default.
This commit is contained in:
tin-berri 2026-08-31 22:12:59 -07:00 committed by GitHub
parent aabfbd6e37
commit 4a24be886d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 380 additions and 40 deletions

View file

@ -14,6 +14,7 @@
},
"classifier_type": "heuristic",
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"deployment_affinity": true
}
@ -30,6 +31,7 @@
},
"classifier_type": "heuristic",
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"deployment_affinity": true
}
@ -56,6 +58,7 @@
},
"classifier_context_window_size": 0,
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"deployment_affinity": true
}
@ -72,6 +75,7 @@
},
"classifier_type": "heuristic",
"escalation_keywords": ["LITELLM ESCALATE"],
"classification_mode": "every_request",
"session_affinity": false,
"deployment_affinity": true
}

View file

@ -15,9 +15,12 @@ import { RestrictedSection, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
ClassificationFrequency,
ClassifierFallback,
ClassifierType,
ComplexityRouterConfigValue,
classificationFrequency,
withClassificationFrequency,
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
MIN_QUOTED_CONTEXT_TURN_CHARS,
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
@ -211,6 +214,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null);
const hasDefaultModel = Boolean(defaultModel);
const classifierType = effectiveClassifierType(value);
const sessionFrequencyRestriction = restrictedBy(value, "sessionAffinity");
const classifierModelMissing =
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
@ -305,6 +309,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange({ ...value, classifier_fallback: fallback });
};
const handleClassificationFrequencyChange = (frequency: ClassificationFrequency) => {
onChange(withClassificationFrequency(value, frequency));
};
const handleClassifierContextWindowSizeChange = (windowSize: number) => {
onChange({
...value,
@ -367,6 +375,49 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
)}
<div className="mt-4 space-y-2">
<strong className="block font-semibold">How often to classify</strong>
<RadioGroup
value={classificationFrequency(value)}
onValueChange={(frequency: unknown) =>
handleClassificationFrequencyChange(frequency as ClassificationFrequency)
}
>
<div className="inline-flex flex-col gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="every_request" className="mt-0.5" />
<span>
<span>Every request</span>{" "}
<span className="text-muted-foreground">: score every turn, tool-result continuations included</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="user_turn" className="mt-0.5" />
<span>
<span>Every new user message</span>{" "}
<span className="text-muted-foreground">
: score each new human ask, then hold that tier for the tool calls that follow it
</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="session" className="mt-0.5" disabled={Boolean(sessionFrequencyRestriction)} />
<span>
<span>Once per session</span>{" "}
<span className="text-muted-foreground">
{sessionFrequencyRestriction?.reason ??
": score the first turn only, then hold that tier and its deployment for the whole session"}
</span>
</span>
</Label>
</div>
</RadioGroup>
<p className="text-sm text-muted-foreground">
Holding the tier keeps an agent on one model for a whole tool loop and cuts scoring cost. A turn the router
cannot match to a held decision, such as one with no session id or an expired one, is scored again
</p>
</div>
{usesLlmClassifier(classifierType) && (
<div className="mt-4 space-y-3">
<div>

View file

@ -598,6 +598,82 @@ describe("ComplexityRouterConfig classifier fallback", () => {
});
});
describe("ComplexityRouterConfig classification frequency", () => {
const llmValue: ComplexityRouterConfigValue = {
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
};
it("defaults to every request, matching both backend field defaults", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Every request/ })).toBeChecked();
expect(screen.getByRole("radio", { name: /Every new user message/ })).not.toBeChecked();
expect(screen.getByRole("radio", { name: /Once per session/ })).not.toBeChecked();
});
it("writes both wire fields when the frequency moves to every new user message", () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
fireEvent.click(screen.getByRole("radio", { name: /Every new user message/ }));
expect(onChange).toHaveBeenCalledWith({
...llmValue,
classification_mode: "user_turn",
session_affinity: false,
});
});
it("writes session affinity, not a classification mode, when the frequency moves to once per session", () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
fireEvent.click(screen.getByRole("radio", { name: /Once per session/ }));
expect(onChange).toHaveBeenCalledWith({
...llmValue,
classification_mode: "every_request",
session_affinity: true,
});
});
it("shows a hand-authored config that sets both fields as once per session, matching the backend", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={{ ...llmValue, classification_mode: "user_turn", session_affinity: true }}
onChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Once per session/ })).toBeChecked();
expect(screen.getByRole("radio", { name: /Every new user message/ })).not.toBeChecked();
});
it("records a switch back to every request", () => {
const onChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={{ ...llmValue, classification_mode: "user_turn" }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Every new user message/ })).toBeChecked();
fireEvent.click(screen.getByRole("radio", { name: /Every request/ }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ classification_mode: "every_request" }));
});
it("offers the frequency on a heuristic router, where holding the tier still pins the model", () => {
// The backend pin is gated on the two fields alone, so a heuristic router that switches models
// mid tool loop is fixed by this control too.
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Every new user message/ })).toBeInTheDocument();
});
});
describe("ComplexityRouterConfig classifier rubric", () => {
const llmValue: ComplexityRouterConfigValue = {
...defaultValue,
@ -761,12 +837,12 @@ describe("ComplexityRouterConfig tier labels", () => {
});
describe("ComplexityRouterConfig affinity panel", () => {
it("holds both affinity switches with their backend defaults", () => {
it("holds the deployment switch at its backend default, session pinning having moved to the frequency choice", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
fireEvent.click(screen.getByText("Advanced: Affinity"));
expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
expect(screen.queryByRole("switch", { name: "Pin a session to its first model" })).not.toBeInTheDocument();
});
it("writes deployment_affinity through onChange without touching other keys", () => {
@ -1291,10 +1367,18 @@ describe("ComplexityRouterConfig tier editing", () => {
expect(screen.getByLabelText("Fallback tier")).toBeInTheDocument();
});
it("disables session pinning and says why, rather than letting a stripped value look saved", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Affinity"));
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
it("disables the once-per-session frequency and says why, rather than letting a stripped value look saved", () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...customValue, session_affinity: true }}
onEditingTiersChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
const sessionOption = screen.getByRole("radio", { name: /Once per session/ });
expect(sessionOption).toHaveAttribute("aria-disabled", "true");
expect(sessionOption).not.toBeChecked();
expect(
screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
).toBeInTheDocument();

View file

@ -56,6 +56,16 @@ export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120;
export const DEFAULT_SESSION_AFFINITY = false;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
export type ClassificationMode = "every_request" | "user_turn";
export const DEFAULT_CLASSIFICATION_MODE: ClassificationMode = "every_request";
/**
* One operator-facing choice over the two wire fields that share the router's tier-pin machinery:
* session affinity pins every turn, user_turn pins every turn except a new human ask.
*/
export type ClassificationFrequency = ClassificationMode | "session";
export type ComplexityTiers = {
SIMPLE: string[];
MEDIUM: string[];
@ -384,6 +394,7 @@ export interface ComplexityRouterConfigValue {
classification_prompt?: string;
/** Highest tier the scorer may decide alone under heuristic_first. Required by that type, rejected by the others. */
heuristic_first_max_tier?: string;
classification_mode?: ClassificationMode;
session_affinity?: boolean;
deployment_affinity?: boolean;
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
@ -420,6 +431,21 @@ export interface ComplexityRouterConfigValue {
tier_model_params?: TierModelParamsByTier;
}
/** Session affinity wins where a hand-authored config sets both, matching the backend's own `or`. */
export const classificationFrequency = (value: ComplexityRouterConfigValue): ClassificationFrequency => {
if (!value.custom_tier_set && (value.session_affinity ?? DEFAULT_SESSION_AFFINITY)) return "session";
return value.classification_mode === "user_turn" ? "user_turn" : "every_request";
};
export const withClassificationFrequency = (
value: ComplexityRouterConfigValue,
frequency: ClassificationFrequency,
): ComplexityRouterConfigValue => ({
...value,
classification_mode: frequency === "user_turn" ? "user_turn" : "every_request",
session_affinity: frequency === "session",
});
interface ComplexityRouterConfigProps {
modelInfo: ModelGroup[];
value: ComplexityRouterConfigValue;
@ -498,23 +524,10 @@ const AffinityControls: React.FC<{
/>
<strong className="font-semibold">Pin a session to one deployment per model group</strong>
</div>
<span className="block text-xs mb-3 text-muted-foreground">
<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>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.custom_tier_set ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
disabled={Boolean(value.custom_tier_set)}
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
aria-label="Pin a session to its first model"
/>
<strong className="font-semibold">Pin a session to its first model</strong>
</div>
<span className="block text-xs text-muted-foreground">
{restrictedBy(value, "sessionAffinity")?.reason ??
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
</span>
</>
);

View file

@ -362,8 +362,8 @@ describe("AddAutoRouterTab", () => {
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
await user.click(screen.getByText("Advanced: Classification Method"));
expect(await screen.findByRole("radio", { name: /Once per session/ })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@ -468,8 +468,8 @@ describe("AddAutoRouterTab", () => {
await user.type(screen.getByPlaceholderText(/smart_router/i), "affinity-router");
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(screen.getByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Once per session/ }));
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@ -479,6 +479,44 @@ describe("AddAutoRouterTab", () => {
});
});
it("carries every new user message through to the create payload", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(<Harness />);
await user.type(screen.getByPlaceholderText(/smart_router/i), "user-turn-router");
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
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({
classification_mode: "user_turn",
});
});
it("writes every_request into the create payload when the default frequency stays selected", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(<Harness />);
await user.type(screen.getByPlaceholderText(/smart_router/i), "default-timing-router");
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Classification Method"));
expect(await screen.findByRole("radio", { name: /Every request/ })).toBeChecked();
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.classification_mode,
).toBe("every_request");
});
it("defaults a new router to deployment affinity on, matching the backend field default", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);

View file

@ -342,6 +342,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
classificationPrompt: complexityRouterConfig.classification_prompt,
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
classificationMode: complexityRouterConfig.classification_mode,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,

View file

@ -52,6 +52,7 @@ describe("buildComplexityRouterConfig", () => {
const expected = {
tiers,
classifier_type: "heuristic",
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
escalation_keywords: ["LITELLM ESCALATE"],
@ -790,6 +791,20 @@ describe("heuristic_first", () => {
});
});
describe("classification_mode", () => {
it("emits user_turn", () => {
const config = buildComplexityRouterConfig({ ...baseParams, classificationMode: "user_turn" });
expect(config.classification_mode).toBe("user_turn");
});
it("writes every_request explicitly, so a saved router never depends on the backend default", () => {
expect(
buildComplexityRouterConfig({ ...baseParams, classificationMode: "every_request" }).classification_mode,
).toBe("every_request");
expect(buildComplexityRouterConfig(baseParams).classification_mode).toBe("every_request");
});
});
describe("buildComplexityRouterConfig with an edited tier set", () => {
const customTierSet = {
tiers: [
@ -902,6 +917,10 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
expect(payload.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
});
it("keeps classification_mode, which the backend accepts beside tier_definitions", () => {
expect(build({ classificationMode: "user_turn" }).classification_mode).toBe("user_turn");
});
it("carries the plan-mode floor as the row's name, not the row id the form holds", () => {
expect(build({ planModeMinTier: "sec" }).plan_mode_min_tier).toBe("SECURITY_REVIEW");
});

View file

@ -19,10 +19,12 @@ import {
import {
AdaptiveEligible,
AdaptiveRouterWeights,
ClassificationMode,
ClassifierFallback,
ClassifierLLMConfig,
ClassifierType,
ComplexityTierLabels,
DEFAULT_CLASSIFICATION_MODE,
ComplexityRouterConfigValue,
ComplexityTiers,
DimensionWeights,
@ -105,6 +107,7 @@ export interface BuildComplexityRouterConfigParams {
classifierFallback: ClassifierFallback | undefined;
classificationPrompt: string | undefined;
heuristicFirstMaxTier: string | undefined;
classificationMode: ClassificationMode | undefined;
sessionAffinity: boolean;
deploymentAffinity: boolean;
customTechnicalKeywords: string[];
@ -159,6 +162,7 @@ export interface ComplexityRouterConfigPayload {
classifier_fallback?: ClassifierFallback;
classification_prompt?: string;
heuristic_first_max_tier?: string;
classification_mode: ClassificationMode;
session_affinity: boolean;
deployment_affinity: boolean;
custom_technical_keywords?: string[];
@ -393,6 +397,7 @@ export const buildComplexityRouterConfig = ({
classifierFallback,
classificationPrompt,
heuristicFirstMaxTier,
classificationMode,
sessionAffinity,
deploymentAffinity,
customTechnicalKeywords,
@ -452,6 +457,7 @@ export const buildComplexityRouterConfig = ({
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...classifierWireFields(effectiveType, classifierInputs),
classification_mode: classificationMode ?? DEFAULT_CLASSIFICATION_MODE,
session_affinity: sessionAffinity,
deployment_affinity: deploymentAffinity,
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),

View file

@ -257,6 +257,33 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => {
});
});
describe("buildUpdatedComplexityRouterConfig classification mode", () => {
it("round-trips a stored user_turn through hydrate then save", () => {
const stored = { ...STORED, classification_mode: "user_turn" };
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(hydrated.classification_mode).toBe("user_turn");
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).classification_mode).toBe("user_turn");
});
it("round-trips an explicitly stored every_request, so an untouched save leaves it as written", () => {
const stored = { ...STORED, classification_mode: "every_request" };
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(hydrated.classification_mode).toBe("every_request");
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).classification_mode).toBe("every_request");
});
it("rewrites a stored user_turn to every_request once the operator picks the default back", () => {
const stored = { ...STORED, classification_mode: "user_turn" };
const result = buildUpdatedComplexityRouterConfig(stored, {
...FORM_VALUE,
classification_mode: "every_request",
});
expect(result.classification_mode).toBe("every_request");
});
});
describe("buildUpdatedComplexityRouterConfig deployment affinity", () => {
it("writes deployment_affinity=false when the toggle is off", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, deployment_affinity: false });
@ -476,6 +503,7 @@ describe("managed keys survive an untouched open-and-save", () => {
classifier_context_budget_chars: 4000,
classifier_context_include_assistant_turns: true,
classifier_fallback: "default_model",
classification_mode: "user_turn",
session_affinity: true,
deployment_affinity: false,
adaptive: true,

View file

@ -47,6 +47,7 @@ const expectedClassifiedTierConfig = {
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.65,
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
adaptive: true,
@ -68,6 +69,7 @@ const expectedAdaptiveDisabledConfig = {
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.65,
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
};

View file

@ -364,7 +364,7 @@ describe("EditAutoRouterModal assistant turns", () => {
});
});
describe("EditAutoRouterModal session affinity", () => {
describe("EditAutoRouterModal classification frequency", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});
@ -381,15 +381,15 @@ describe("EditAutoRouterModal session affinity", () => {
/>,
);
// A stored config with no session_affinity key now runs with affinity OFF, because the backend
// field defaults to False. The toggle has to render what the router actually does, and an
// untouched save must not flip it.
it("shows a stored config with no session_affinity key as off", async () => {
// A stored config with neither key now runs with affinity OFF, because both backend fields
// default that way. The picker has to render what the router actually does, and an untouched
// save must not flip it.
it("shows a stored config with neither key as every request", async () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);
await user.click(await screen.findByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).not.toBeChecked();
await user.click(await screen.findByText("Advanced: Classification Method"));
expect(await screen.findByRole("radio", { name: /Every request/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -397,12 +397,12 @@ describe("EditAutoRouterModal session affinity", () => {
expect(savedConfig().session_affinity).toBe(false);
});
it("shows a stored session_affinity=true as on and preserves it through an untouched save", async () => {
it("shows a stored session_affinity=true as once per session and preserves it through an untouched save", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
await user.click(await screen.findByText("Advanced: Affinity"));
expect(await screen.findByRole("switch", { name: "Pin a session to its first model" })).toBeChecked();
await user.click(await screen.findByText("Advanced: Classification Method"));
expect(await screen.findByRole("radio", { name: /Once per session/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -410,12 +410,12 @@ describe("EditAutoRouterModal session affinity", () => {
expect(savedConfig().session_affinity).toBe(true);
});
it("persists turning session affinity on", async () => {
it("persists picking once per session", async () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);
await user.click(await screen.findByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(await screen.findByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Once per session/ }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -423,18 +423,71 @@ describe("EditAutoRouterModal session affinity", () => {
expect(savedConfig().session_affinity).toBe(true);
});
it("persists turning session affinity back off", async () => {
it("persists picking every request back over a stored session pin", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
await user.click(await screen.findByText("Advanced: Affinity"));
await user.click(await screen.findByRole("switch", { name: "Pin a session to its first model" }));
await user.click(await screen.findByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Every request/ }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(false);
});
it("clears a stored session pin when the operator moves to every new user message", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, session_affinity: true });
await user.click(await screen.findByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().session_affinity).toBe(false);
expect(savedConfig().classification_mode).toBe("user_turn");
});
it("shows a stored user_turn as selected and preserves it through an untouched save", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, classification_mode: "user_turn" });
await user.click(await screen.findByText("Advanced: Classification Method"));
expect(await screen.findByRole("radio", { name: /Every new user message/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().classification_mode).toBe("user_turn");
});
it("persists switching a stored config to every new user message", async () => {
const user = userEvent.setup();
renderWithStoredConfig(STORED_CONFIG);
await user.click(await screen.findByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Every new user message/ }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().classification_mode).toBe("user_turn");
});
it("rewrites the stored mode to every_request when the operator picks it back", async () => {
const user = userEvent.setup();
renderWithStoredConfig({ ...STORED_CONFIG, classification_mode: "user_turn" });
await user.click(await screen.findByText("Advanced: Classification Method"));
await user.click(await screen.findByRole("radio", { name: /Every request/ }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().classification_mode).toBe("every_request");
});
});
describe("EditAutoRouterModal deployment affinity", () => {

View file

@ -96,6 +96,7 @@ export interface StoredComplexityRouterConfig {
classifier_context_budget_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
classifier_fallback?: unknown;
classification_mode?: unknown;
tier_boundaries?: unknown;
token_thresholds?: unknown;
dimension_weights?: unknown;
@ -165,6 +166,10 @@ export const hydrateComplexityRouterConfig = (
typeof parsedConfig.heuristic_first_max_tier === "string" && parsedConfig.heuristic_first_max_tier.trim() !== ""
? parsedConfig.heuristic_first_max_tier
: undefined,
classification_mode:
parsedConfig.classification_mode === "user_turn" || parsedConfig.classification_mode === "every_request"
? parsedConfig.classification_mode
: undefined,
tier_boundaries: hydrateTierBoundaries(parsedConfig.tier_boundaries),
token_thresholds: hydrateTokenThresholds(parsedConfig.token_thresholds),
dimension_weights: hydrateDimensionWeights(parsedConfig.dimension_weights),
@ -207,6 +212,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"classifier_fallback",
"classification_prompt",
"heuristic_first_max_tier",
"classification_mode",
"session_affinity",
"deployment_affinity",
"adaptive",
@ -294,6 +300,7 @@ export const buildUpdatedComplexityRouterConfig = (
planModeMinTier: value.plan_mode_min_tier,
classificationPrompt: value.classification_prompt,
heuristicFirstMaxTier: value.heuristic_first_max_tier,
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
classifierLlmConfig: value.classifier_llm_config,

View file

@ -230,6 +230,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -273,6 +274,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -287,6 +289,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -320,6 +323,7 @@ describe("autorouter_presets", () => {
const simpleTierConfig = (presetModel: string) => ({
tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
});
@ -563,6 +567,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
match_threshold: 0,
@ -578,6 +583,7 @@ describe("autorouter_presets", () => {
{
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
classification_mode: "every_request",
session_affinity: false,
deployment_affinity: true,
enable_context_window_escalation: false,
@ -589,11 +595,29 @@ describe("autorouter_presets", () => {
expect(prefill.complexityRouterConfig.context_window_escalation_buffer).toBe(0.9);
});
it("carries a preset's classification_mode and defaults it when the preset omits one", () => {
const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
const base = {
tiers,
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
const availability = groupsOnly(["gpt-5-nano"]);
expect(
buildPresetPrefill({ ...base, classification_mode: "user_turn" }, availability).complexityRouterConfig
.classification_mode,
).toBe("user_turn");
expect(buildPresetPrefill(base, availability).complexityRouterConfig.classification_mode).toBe("every_request");
});
it("falls back to the defaults when a preset omits match_threshold and escalation_keywords", () => {
const prefill = buildPresetPrefill(
{
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
},
@ -610,6 +634,7 @@ describe("autorouter_presets", () => {
const base = {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -625,6 +650,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: ["claude-sonnet-4-5"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -639,6 +665,7 @@ describe("autorouter_presets", () => {
REASONING: [{ model_name: "o3", litellm_params: { reasoning_effort: "high" } }],
},
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -658,6 +685,7 @@ describe("autorouter_presets", () => {
REASONING: [{ model_name: "claude-sonnet-4-5", litellm_params: { reasoning_effort: "high" } }],
},
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
@ -680,6 +708,9 @@ describe("autorouter_presets", () => {
],
},
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};
const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
// temperature survives from the spelling that would otherwise have been overwritten;
@ -693,6 +724,7 @@ describe("autorouter_presets", () => {
const config = {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
};

View file

@ -6,6 +6,7 @@ import {
ComplexityRouterConfigValue,
ClassifierType,
ClassifierLLMConfig,
DEFAULT_CLASSIFICATION_MODE,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
usesLlmClassifier,
@ -288,6 +289,7 @@ export const buildPresetPrefill = (
classifier_context_budget_chars: config.classifier_context_budget_chars,
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE,
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
adaptive: config.adaptive,