mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
f92ff60ebd
commit
bf4fccc937
13 changed files with 498 additions and 2 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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's output ceiling</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Replace the caller's max_tokens with the routed tier model's output ceiling so one client value fits every
|
||||
tier. Off forwards the caller's value unchanged.
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue