feat(ui): expose remaining complexity router advanced settings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yuneng 2026-09-21 19:25:52 +00:00
parent f92ff60ebd
commit bf4fccc937
13 changed files with 498 additions and 2 deletions

View file

@ -19,7 +19,10 @@ import HeuristicScoringConfig from "./HeuristicScoringConfig";
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import ClassifierVisionConfig from "./ClassifierVisionConfig";
import { getHeuristicV2SuccessThresholdError } from "./build_complexity_router_config";
import {
getClassifierPluginTimeoutError,
getHeuristicV2SuccessThresholdError,
} from "./build_complexity_router_config";
import type { ReasoningEffort } from "./complexity_router_tiers";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
@ -61,6 +64,7 @@ const CLASSIFIER_CONTEXT_WINDOW_SIZE_ID = "classifier-context-window-size";
const CLASSIFIER_CONTEXT_BUDGET_CHARS_ID = "classifier-context-budget-chars";
const HYBRID_BOUNDARY_MARGIN_ID = "hybrid-boundary-margin";
const HEURISTIC_V2_SUCCESS_THRESHOLD_ID = "heuristic-v2-success-threshold";
const CLASSIFIER_PLUGIN_TIMEOUT_ID = "classifier-plugin-timeout-ms";
const CUSTOM_PROMPT_WITH_HEURISTIC_FALLBACK =
"This router classifies with your own prompt, so the tier comes from whatever rubric it states. The four tier " +
@ -467,6 +471,40 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<>
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />
{classifierType === "custom" && (
<div className="mt-4 space-y-2">
<p className="text-sm text-muted-foreground">
This router uses a custom classifier plugin set in config.yaml. Pick a classifier below to replace it.
</p>
<Label htmlFor={CLASSIFIER_PLUGIN_TIMEOUT_ID} className="block font-semibold">
Classifier plugin timeout (ms)
</Label>
<Input
id={CLASSIFIER_PLUGIN_TIMEOUT_ID}
inputMode="numeric"
placeholder="3000"
value={value.classifier_plugin_timeout_ms ?? ""}
onChange={(event) =>
onChange({
...value,
classifier_plugin_timeout_ms: event.target.value.trim() === "" ? undefined : Number(event.target.value),
})
}
aria-invalid={Boolean(
showValidationErrors && getClassifierPluginTimeoutError(classifierType, value.classifier_plugin_timeout_ms),
)}
/>
<p className="text-sm text-muted-foreground">
Time budget for the plugin call. On expiry the fallback path decides the tier.
</p>
{showValidationErrors && getClassifierPluginTimeoutError(classifierType, value.classifier_plugin_timeout_ms) && (
<p className="text-sm text-destructive" role="alert">
{getClassifierPluginTimeoutError(classifierType, value.classifier_plugin_timeout_ms)}
</p>
)}
</div>
)}
{classifierType === "heuristic_v2" && (
<div className="mt-4 space-y-2">
<Label htmlFor={HEURISTIC_V2_SUCCESS_THRESHOLD_ID} className="block font-semibold">

View file

@ -63,9 +63,14 @@ import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from
import { type CustomDimensionRow } from "./custom_dimensions";
import CompressionControls from "./CompressionControls";
import { type AutoRouterCompressionState, DEFAULT_AUTO_ROUTER_COMPRESSION } from "./buildAutoRouterCompression";
import HeuristicKeywordOverrides from "./HeuristicKeywordOverrides";
import HousekeepingRoutingControls from "./HousekeepingRoutingControls";
import ReminderMarkers from "./ReminderMarkers";
import { type ReminderMarkerPair } from "./build_complexity_router_config";
export type { DimensionWeights, TierBoundaries, TokenThresholds };
export type { CustomTierSet, TierRow } from "./tier_rows";
export type { ReminderMarkerPair } from "./build_complexity_router_config";
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
@ -429,6 +434,16 @@ export interface ComplexityRouterConfigValue {
* edit round-trip.
*/
tier_model_params?: TierModelParamsByTier;
code_keywords?: string[];
reasoning_keywords?: string[];
technical_keywords?: string[];
simple_keywords?: string[];
plan_mode_patterns?: string[];
route_housekeeping_to_cheapest_tier?: boolean;
housekeeping_patterns?: string[];
reminder_markers?: ReminderMarkerPair[];
max_tokens_from_tier_model?: boolean;
classifier_plugin_timeout_ms?: number;
}
/** Session affinity wins where a hand-authored config sets both, matching the backend's own `or`. */
@ -786,6 +801,17 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
/>
),
},
]
: []),
...(!forecast
? [
{
key: "keyword-overrides",
label: (
<strong className="text-foreground font-semibold">Advanced: Heuristic Keyword Overrides</strong>
),
children: <HeuristicKeywordOverrides value={value} onChange={onChange} />,
},
]
: []),
{
@ -814,6 +840,16 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
),
},
{
key: "housekeeping",
label: <strong className="text-foreground font-semibold">Advanced: Housekeeping Routing</strong>,
children: <HousekeepingRoutingControls value={value} onChange={onChange} />,
},
{
key: "reminder-markers",
label: <strong className="text-foreground font-semibold">Advanced: Reminder Markers</strong>,
children: <ReminderMarkers value={value} onChange={onChange} showValidationErrors={showValidationErrors} />,
},
{
key: "context-window",
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,

View file

@ -0,0 +1,42 @@
import React from "react";
import { MultiSelect } from "@/components/shared/MultiSelect";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
const fields = [
["code_keywords", "Code keywords"],
["reasoning_keywords", "Reasoning keywords"],
["technical_keywords", "Technical keywords"],
["simple_keywords", "Simple keywords"],
] as const;
const HeuristicKeywordOverrides: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}> = ({ value, onChange }) => (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Each list replaces the built-in keyword list of the same name for the heuristic scorer. Leave a list empty to
keep the built-in one. To add technical terms without replacing the list, use custom technical keywords under
Classification Method.
</p>
{fields.map(([key, label]) => {
const keywords = value[key] ?? [];
return (
<div key={key}>
<strong className="mb-1 block font-semibold">{label}</strong>
<MultiSelect
options={keywords.map((keyword) => ({ label: keyword, value: keyword }))}
value={keywords}
onValueChange={(next) => onChange({ ...value, [key]: next.length > 0 ? next : undefined })}
placeholder={`Add ${label.toLowerCase()}`}
emptyText="Type to add a keyword"
allowCustomValues
className="w-full"
/>
</div>
);
})}
</div>
);
export default HeuristicKeywordOverrides;

View file

@ -0,0 +1,44 @@
import React from "react";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { Switch } from "@/components/ui/switch";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
const HousekeepingRoutingControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}> = ({ value, onChange }) => {
const enabled = value.route_housekeeping_to_cheapest_tier ?? true;
const patterns = value.housekeeping_patterns ?? [];
return (
<>
<div className="mb-2 flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(next) => onChange({ ...value, route_housekeeping_to_cheapest_tier: next })}
aria-label="Route housekeeping calls to the cheapest tier"
/>
<strong className="font-semibold">Route housekeeping calls to the cheapest tier</strong>
</div>
<span className="mb-3 block text-xs text-muted-foreground">
Conversation-title style calls skip the classifier and go to the cheapest tier.
</span>
<strong className="mb-1 block font-semibold">Additional housekeeping sentinels</strong>
<MultiSelect
options={patterns.map((pattern) => ({ label: pattern, value: pattern }))}
value={patterns}
onValueChange={(next) => onChange({ ...value, housekeeping_patterns: next.length > 0 ? next : undefined })}
placeholder="e.g., conversation title"
emptyText="Type to add a sentinel"
allowCustomValues
disabled={!enabled}
className="w-full"
/>
<span className="mt-1 block text-xs text-muted-foreground">
Case-sensitive literal strings added to the built-in conversation-title sentinels.
{!enabled && " Turn housekeeping routing on for these to take effect."}
</span>
</>
);
};
export default HousekeepingRoutingControls;

View file

@ -1,5 +1,6 @@
import React from "react";
import { Switch } from "@/components/ui/switch";
import { MultiSelect } from "@/components/shared/MultiSelect";
import TierRowSelect from "./TierRowSelect";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
@ -38,6 +39,23 @@ const PlanModeOverrideControls: React.FC<{
/>
</div>
)}
<div className="mt-4">
<strong className="mb-1 block font-semibold">Additional plan-mode sentinels</strong>
<MultiSelect
options={(value.plan_mode_patterns ?? []).map((pattern) => ({ label: pattern, value: pattern }))}
value={value.plan_mode_patterns ?? []}
onValueChange={(patterns) =>
onChange({ ...value, plan_mode_patterns: patterns.length > 0 ? patterns : undefined })
}
placeholder="e.g., enter plan mode"
emptyText="Type to add a sentinel"
allowCustomValues
className="w-full"
/>
<span className="mt-1 block text-xs text-muted-foreground">
Case-sensitive literal strings added to the built-in Claude Code and Copilot plan-mode markers.
</span>
</div>
</>
);

View file

@ -0,0 +1,77 @@
import React from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { getReminderMarkersError, type ReminderMarkerPair } from "./build_complexity_router_config";
const ReminderMarkers: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
showValidationErrors?: boolean;
}> = ({ value, onChange, showValidationErrors = false }) => {
const markers = value.reminder_markers ?? [];
const update = (index: number, patch: Partial<ReminderMarkerPair>) =>
onChange({
...value,
reminder_markers: markers.map((marker, markerIndex) => (markerIndex === index ? { ...marker, ...patch } : marker)),
});
const remove = (index: number) => {
const next = markers.filter((_, markerIndex) => markerIndex !== index);
onChange({ ...value, reminder_markers: next.length > 0 ? next : undefined });
};
const error = getReminderMarkersError(value.reminder_markers);
return (
<div>
<p className="mb-4 text-sm text-muted-foreground">
Delimiter pairs that wrap harness-injected reminder blocks, which are stripped before classification. Setting any
pair replaces the built-in pairs, so list every pair your harness emits. Matching is case-insensitive and values
are saved lowercased.
</p>
<div className="space-y-3">
{markers.map((marker, index) => (
<div className="flex items-end gap-2" key={`${index}-${marker.open}-${marker.close}`}>
<div className="flex-1">
<label className="mb-1 block text-sm font-medium" htmlFor={`reminder-marker-${index}-open`}>
Opening delimiter
</label>
<Input
id={`reminder-marker-${index}-open`}
aria-label={`Reminder marker ${index + 1} opening delimiter`}
placeholder="<system-reminder>"
value={marker.open}
onChange={(event) => update(index, { open: event.target.value })}
/>
</div>
<div className="flex-1">
<label className="mb-1 block text-sm font-medium" htmlFor={`reminder-marker-${index}-close`}>
Closing delimiter
</label>
<Input
id={`reminder-marker-${index}-close`}
aria-label={`Reminder marker ${index + 1} closing delimiter`}
placeholder="</system-reminder>"
value={marker.close}
onChange={(event) => update(index, { close: event.target.value })}
/>
</div>
<Button variant="ghost" size="icon" aria-label={`Remove reminder marker pair ${index + 1}`} onClick={() => remove(index)}>
<Trash2 />
</Button>
</div>
))}
</div>
<Button
className="mt-3"
variant="outline"
onClick={() => onChange({ ...value, reminder_markers: [...markers, { open: "", close: "" }] })}
>
<Plus />
Add marker pair
</Button>
{showValidationErrors && error && <p className="mt-2 text-xs text-destructive">{error}</p>}
</div>
);
};
export default ReminderMarkers;

View file

@ -18,6 +18,18 @@ const ResponseFormatControls: React.FC<{
<span className="block text-xs text-muted-foreground">
Return the resolved underlying model name in responses instead of the autorouter alias.
</span>
<div className="mt-4 flex items-center gap-2 mb-2">
<Switch
checked={value.max_tokens_from_tier_model ?? true}
onCheckedChange={(enabled) => onChange({ ...value, max_tokens_from_tier_model: enabled })}
aria-label="Cap max_tokens at the tier model's output ceiling"
/>
<strong className="font-semibold">Cap max_tokens at the tier model&apos;s output ceiling</strong>
</div>
<span className="block text-xs text-muted-foreground">
Replace the caller&apos;s max_tokens with the routed tier model&apos;s output ceiling so one client value fits every
tier. Off forwards the caller&apos;s value unchanged.
</span>
</>
);

View file

@ -48,6 +48,8 @@ import {
getKeywordTierRulesError,
getClassifierModelError,
getHeuristicV2SuccessThresholdError,
getReminderMarkersError,
getClassifierPluginTimeoutError,
getClassifierReasoningEffortError,
getMissingTiersError,
getPlanModeTierError,
@ -152,6 +154,8 @@ export const getSubmitBlockedReason = (
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
getHeuristicV2SuccessThresholdError(config.heuristic_v2_success_threshold) ??
getReminderMarkersError(config.reminder_markers) ??
getClassifierPluginTimeoutError(config.classifier_type, config.classifier_plugin_timeout_ms) ??
(heuristicScoringRole(config) === "decides" ? customDimensionsError(config.custom_dimensions) : null) ??
getClassifierReasoningEffortError(config, modelInfo) ??
getReferencedModelsError(referencedModelsParams, availability)
@ -448,6 +452,16 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
sessionAffinityTtlSeconds: complexityRouterConfig.session_affinity_ttl_seconds,
codeKeywords: complexityRouterConfig.code_keywords,
reasoningKeywords: complexityRouterConfig.reasoning_keywords,
technicalKeywords: complexityRouterConfig.technical_keywords,
simpleKeywords: complexityRouterConfig.simple_keywords,
planModePatterns: complexityRouterConfig.plan_mode_patterns,
routeHousekeepingToCheapestTier: complexityRouterConfig.route_housekeeping_to_cheapest_tier,
housekeepingPatterns: complexityRouterConfig.housekeeping_patterns,
reminderMarkers: complexityRouterConfig.reminder_markers,
maxTokensFromTierModel: complexityRouterConfig.max_tokens_from_tier_model,
classifierPluginTimeoutMs: complexityRouterConfig.classifier_plugin_timeout_ms,
};
const submitRecommendedRouter = async (name: string) => {

View file

@ -6,6 +6,8 @@ import {
getKeywordTierRulesError,
getClassifierModelError,
getHeuristicV2SuccessThresholdError,
getReminderMarkersError,
getClassifierPluginTimeoutError,
getClassifierReasoningEffortError,
getMissingTiersError,
hydrateCustomTierSet,
@ -1482,3 +1484,59 @@ describe("classifier vision wire payload", () => {
expect(payload.classifier_llm_config).not.toHaveProperty("vision");
});
});
describe("advanced complexity router fields", () => {
it("normalizes lists, reminder markers, and explicit false values", () => {
const payload = buildComplexityRouterConfig({
...baseParams,
codeKeywords: [" async ", " "],
reasoningKeywords: ["prove"],
technicalKeywords: ["api"],
simpleKeywords: ["hello"],
planModePatterns: [" plan "],
routeHousekeepingToCheapestTier: false,
housekeepingPatterns: [" title "],
reminderMarkers: [{ open: " <SYSTEM> ", close: " </SYSTEM> " }],
maxTokensFromTierModel: false,
classifierType: "custom",
classifierPluginTimeoutMs: 3000,
});
expect(payload).toMatchObject({
code_keywords: ["async"],
reasoning_keywords: ["prove"],
technical_keywords: ["api"],
simple_keywords: ["hello"],
plan_mode_patterns: ["plan"],
route_housekeeping_to_cheapest_tier: false,
housekeeping_patterns: ["title"],
reminder_markers: [{ open: "<system>", close: "</system>" }],
max_tokens_from_tier_model: false,
classifier_plugin_timeout_ms: 3000,
});
});
it("omits defaults, empty lists, and timeout values for non-custom classifiers", () => {
const payload = buildComplexityRouterConfig({
...baseParams,
codeKeywords: [" ", ""],
reminderMarkers: [],
routeHousekeepingToCheapestTier: true,
maxTokensFromTierModel: true,
classifierPluginTimeoutMs: 3000,
});
expect(payload).not.toHaveProperty("code_keywords");
expect(payload).not.toHaveProperty("reminder_markers");
expect(payload).not.toHaveProperty("route_housekeeping_to_cheapest_tier");
expect(payload).not.toHaveProperty("max_tokens_from_tier_model");
expect(payload).not.toHaveProperty("classifier_plugin_timeout_ms");
});
it("validates marker pairs and custom classifier timeout", () => {
expect(getReminderMarkersError([{ open: " <X> ", close: " <x> " }])).toContain("different");
expect(getReminderMarkersError([{ open: "", close: "</x>" }])).toContain("needs both");
expect(getReminderMarkersError([{ open: "<x>", close: "</x>" }])).toBeNull();
expect(getClassifierPluginTimeoutError("custom", 0)).toContain("whole number");
expect(getClassifierPluginTimeoutError("custom", 3000)).toBeNull();
expect(getClassifierPluginTimeoutError("heuristic", 0)).toBeNull();
});
});

View file

@ -54,6 +54,10 @@ import {
export type ClassifierVisionConfig = { enabled?: boolean; max_images?: number };
export type ClassifierLLMConfigWire = ClassifierLLMConfig & { vision?: ClassifierVisionConfig };
export interface ReminderMarkerPair {
open: string;
close: string;
}
/**
* Drop an empty system_prompt so the payload carries an override only when there is one. The
@ -181,6 +185,16 @@ export interface StoredComplexityRouterConfig {
stall_escalation_enabled?: unknown;
stall_escalation_window?: unknown;
stall_escalation_repeat_threshold?: unknown;
code_keywords?: unknown;
reasoning_keywords?: unknown;
technical_keywords?: unknown;
simple_keywords?: unknown;
plan_mode_patterns?: unknown;
route_housekeeping_to_cheapest_tier?: unknown;
housekeeping_patterns?: unknown;
reminder_markers?: unknown;
max_tokens_from_tier_model?: unknown;
classifier_plugin_timeout_ms?: unknown;
}
export interface BuildComplexityRouterConfigParams {
@ -233,6 +247,16 @@ export interface BuildComplexityRouterConfigParams {
enableContextWindowEscalation?: boolean;
contextWindowEscalationBuffer?: number;
sessionAffinityTtlSeconds?: number;
codeKeywords?: string[];
reasoningKeywords?: string[];
technicalKeywords?: string[];
simpleKeywords?: string[];
planModePatterns?: string[];
routeHousekeepingToCheapestTier?: boolean;
housekeepingPatterns?: string[];
reminderMarkers?: ReminderMarkerPair[];
maxTokensFromTierModel?: boolean;
classifierPluginTimeoutMs?: number;
}
/**
@ -302,6 +326,16 @@ export interface ComplexityRouterConfigPayload {
enable_context_window_escalation?: boolean;
context_window_escalation_buffer?: number;
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
code_keywords?: string[];
reasoning_keywords?: string[];
technical_keywords?: string[];
simple_keywords?: string[];
plan_mode_patterns?: string[];
route_housekeeping_to_cheapest_tier?: boolean;
housekeeping_patterns?: string[];
reminder_markers?: ReminderMarkerPair[];
max_tokens_from_tier_model?: boolean;
classifier_plugin_timeout_ms?: number;
}
export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => {
@ -376,6 +410,26 @@ export const getHeuristicV2SuccessThresholdError = (threshold: number | undefine
return validProbability ? null : "Success threshold must be a number between 0 and 1";
};
export const getReminderMarkersError = (pairs: ReminderMarkerPair[] | undefined): string | null => {
for (const [index, pair] of (pairs ?? []).entries()) {
const open = pair.open.trim().toLowerCase();
const close = pair.close.trim().toLowerCase();
if (!open || !close) return `Reminder marker pair ${index + 1} needs both an opening and a closing delimiter`;
if (open === close) return `Reminder marker pair ${index + 1} must use different opening and closing delimiters`;
}
return null;
};
export const getClassifierPluginTimeoutError = (
classifierType: ClassifierType,
timeoutMs: number | undefined,
): string | null => {
if (classifierType !== "custom" || timeoutMs === undefined) return null;
return Number.isInteger(timeoutMs) && timeoutMs > 0
? null
: "Classifier plugin timeout must be a whole number of milliseconds greater than 0";
};
export const getClassifierModelError = (
config: Pick<
ComplexityRouterConfigValue,
@ -640,6 +694,16 @@ export const buildComplexityRouterConfig = ({
enableContextWindowEscalation,
contextWindowEscalationBuffer,
sessionAffinityTtlSeconds,
codeKeywords,
reasoningKeywords,
technicalKeywords,
simpleKeywords,
planModePatterns,
routeHousekeepingToCheapestTier,
housekeepingPatterns,
reminderMarkers,
maxTokensFromTierModel,
classifierPluginTimeoutMs,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = customTierSet
? serializeTierModelConfigs(
@ -672,6 +736,14 @@ export const buildComplexityRouterConfig = ({
};
const effectiveType = effectiveClassifierType({ custom_tier_set: customTierSet, classifier_type: classifierType });
const forecast = isForecastClassifier(effectiveType);
const cleanList = (items: string[] | undefined): string[] | undefined => {
const cleaned = (items ?? []).map((item) => item.trim()).filter(Boolean);
return cleaned.length > 0 ? cleaned : undefined;
};
const cleanedReminderMarkers = reminderMarkers?.map(({ open, close }) => ({
open: open.trim().toLowerCase(),
close: close.trim().toLowerCase(),
}));
const supportsOpeningPrompt = !customTierSet && !forecast && usesLlmClassifier(effectiveType);
const payload: ComplexityRouterConfigPayload = {
@ -740,6 +812,19 @@ export const buildComplexityRouterConfig = ({
...(sessionAffinityTtlSeconds !== undefined && {
session_affinity_ttl_seconds: sessionAffinityTtlSeconds,
}),
...(cleanList(codeKeywords) && { code_keywords: cleanList(codeKeywords) }),
...(cleanList(reasoningKeywords) && { reasoning_keywords: cleanList(reasoningKeywords) }),
...(cleanList(technicalKeywords) && { technical_keywords: cleanList(technicalKeywords) }),
...(cleanList(simpleKeywords) && { simple_keywords: cleanList(simpleKeywords) }),
...(cleanList(planModePatterns) && { plan_mode_patterns: cleanList(planModePatterns) }),
...(routeHousekeepingToCheapestTier === false && { route_housekeeping_to_cheapest_tier: false }),
...(cleanList(housekeepingPatterns) && { housekeeping_patterns: cleanList(housekeepingPatterns) }),
...(cleanedReminderMarkers && cleanedReminderMarkers.length > 0 && { reminder_markers: cleanedReminderMarkers }),
...(maxTokensFromTierModel === false && { max_tokens_from_tier_model: false }),
...(classifierType === "custom" &&
classifierPluginTimeoutMs !== undefined &&
Number.isInteger(classifierPluginTimeoutMs) &&
classifierPluginTimeoutMs > 0 && { classifier_plugin_timeout_ms: classifierPluginTimeoutMs }),
...scorerKnobs,
};
if (!customTierSet) return payload;

View file

@ -6,7 +6,8 @@ export type ClassifierType =
| "heuristic_first"
| "hybrid"
| "capability"
| "llm_v2";
| "llm_v2"
| "custom";
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
(["llm", "heuristic_first", "hybrid", "capability", "llm_v2"] as const).some((type) => type === classifierType);

View file

@ -854,6 +854,16 @@ describe("managed keys survive an untouched open-and-save", () => {
reasoning_override_min_score: 0.3,
enable_context_window_escalation: false,
context_window_escalation_buffer: 0.9,
code_keywords: ["async", "await"],
reasoning_keywords: ["prove"],
technical_keywords: ["api"],
simple_keywords: ["hello"],
plan_mode_patterns: ["plan now"],
route_housekeeping_to_cheapest_tier: false,
housekeeping_patterns: ["conversation title"],
reminder_markers: [{ open: "<system-reminder>", close: "</system-reminder>" }],
max_tokens_from_tier_model: false,
classifier_plugin_timeout_ms: 3000,
};
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses,
@ -864,6 +874,7 @@ describe("managed keys survive an untouched open-and-save", () => {
"fallback_tier",
"hybrid_boundary_margin",
"jev_classifier_config",
"classifier_plugin_timeout_ms",
]);
// The stall keys are rejected beside the session pinning and user-turn classification this
@ -894,6 +905,12 @@ describe("managed keys survive an untouched open-and-save", () => {
expect(dropped).toEqual([]);
});
it("keeps the custom classifier plugin timeout through an untouched save", () => {
const stored = { ...STORED_ALL_MANAGED, classifier_type: "custom", classifier_plugin_timeout_ms: 3000 };
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).classifier_plugin_timeout_ms).toBe(3000);
});
it("carries an enabled non-reasoning tier and its models through their own round trip", () => {
// `tiers` is rewritten wholesale on save, so this is the regression that matters: opening an
// enabled router and saving an unrelated edit must not delete the tier or its pool.

View file

@ -45,6 +45,8 @@ import {
buildComplexityRouterConfig,
getClassifierModelError,
getHeuristicV2SuccessThresholdError,
getReminderMarkersError,
getClassifierPluginTimeoutError,
getClassifierReasoningEffortError,
getKeywordTierRulesError,
getMissingTiersError,
@ -113,6 +115,8 @@ export const hydrateComplexityRouterConfig = (
parsedConfig: StoredComplexityRouterConfig,
complexityRouterDefaultModel: string | null | undefined,
): ComplexityRouterConfigValue => {
const stringList = (input: unknown): string[] | undefined =>
Array.isArray(input) ? input.filter((item): item is string => typeof item === "string") : undefined;
const builtIn = hydrateBuiltInTiers(parsedConfig.tiers, parsedConfig.enable_non_reasoning_tier);
const { tiers: hydratedTiers, enable_non_reasoning_tier } = builtIn;
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
@ -219,6 +223,31 @@ export const hydrateComplexityRouterConfig = (
typeof parsedConfig.stall_escalation_repeat_threshold === "number"
? parsedConfig.stall_escalation_repeat_threshold
: undefined,
code_keywords: stringList(parsedConfig.code_keywords),
reasoning_keywords: stringList(parsedConfig.reasoning_keywords),
technical_keywords: stringList(parsedConfig.technical_keywords),
simple_keywords: stringList(parsedConfig.simple_keywords),
plan_mode_patterns: stringList(parsedConfig.plan_mode_patterns),
route_housekeeping_to_cheapest_tier:
typeof parsedConfig.route_housekeeping_to_cheapest_tier === "boolean"
? parsedConfig.route_housekeeping_to_cheapest_tier
: undefined,
housekeeping_patterns: stringList(parsedConfig.housekeeping_patterns),
reminder_markers: Array.isArray(parsedConfig.reminder_markers)
? parsedConfig.reminder_markers.filter(
(pair): pair is { open: string; close: string } =>
typeof pair === "object" &&
pair !== null &&
typeof (pair as { open?: unknown }).open === "string" &&
typeof (pair as { close?: unknown }).close === "string",
)
: undefined,
max_tokens_from_tier_model:
typeof parsedConfig.max_tokens_from_tier_model === "boolean" ? parsedConfig.max_tokens_from_tier_model : undefined,
classifier_plugin_timeout_ms:
typeof parsedConfig.classifier_plugin_timeout_ms === "number" && Number.isFinite(parsedConfig.classifier_plugin_timeout_ms)
? parsedConfig.classifier_plugin_timeout_ms
: undefined,
};
};
@ -266,6 +295,16 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"stall_escalation_enabled",
"stall_escalation_window",
"stall_escalation_repeat_threshold",
"code_keywords",
"reasoning_keywords",
"technical_keywords",
"simple_keywords",
"plan_mode_patterns",
"route_housekeeping_to_cheapest_tier",
"housekeeping_patterns",
"reminder_markers",
"max_tokens_from_tier_model",
"classifier_plugin_timeout_ms",
]);
// Managed only when the caller passes the corresponding state. A caller that does not render
@ -387,6 +426,16 @@ export const buildUpdatedComplexityRouterConfig = (
stallEscalationEnabled: value.stall_escalation_enabled,
stallEscalationWindow: value.stall_escalation_window,
stallEscalationRepeatThreshold: value.stall_escalation_repeat_threshold,
codeKeywords: value.code_keywords,
reasoningKeywords: value.reasoning_keywords,
technicalKeywords: value.technical_keywords,
simpleKeywords: value.simple_keywords,
planModePatterns: value.plan_mode_patterns,
routeHousekeepingToCheapestTier: value.route_housekeeping_to_cheapest_tier,
housekeepingPatterns: value.housekeeping_patterns,
reminderMarkers: value.reminder_markers,
maxTokensFromTierModel: value.max_tokens_from_tier_model,
classifierPluginTimeoutMs: value.classifier_plugin_timeout_ms,
};
const built = buildComplexityRouterConfig(builderParams);
@ -585,6 +634,11 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const classifierError =
getClassifierModelError(complexityRouterConfig) ??
getHeuristicV2SuccessThresholdError(complexityRouterConfig.heuristic_v2_success_threshold) ??
getReminderMarkersError(complexityRouterConfig.reminder_markers) ??
getClassifierPluginTimeoutError(
complexityRouterConfig.classifier_type,
complexityRouterConfig.classifier_plugin_timeout_ms,
) ??
getForecastConfigError(complexityRouterConfig) ??
(heuristicScoringRole(complexityRouterConfig) === "decides"
? customDimensionsError(complexityRouterConfig.custom_dimensions)