mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
parent
aabfbd6e37
commit
4a24be886d
14 changed files with 380 additions and 40 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue