feat: add model routing UI settings, fix api handler restoration bug

- Add translation keys for MODEL_ROUTING in locales/en/settings.json
- Create ModelRoutingSettings component with toggle and light model ID input
- Wire up MODEL_ROUTING special-case in ExperimentalSettings.tsx
- Add modelRoutingLightModelId state binding in SettingsView.tsx
- Fix bug: restore this.api to primary handler on all paths (stream
  failure, empty-response retry, catch) not just the happy path
- Fix nit: remove unnecessary as any cast in ModelRouter.isEnabled()
This commit is contained in:
Roo Code 2026-02-08 00:57:36 +00:00
parent 6e090e7112
commit 37c8e2ae3f
6 changed files with 111 additions and 7 deletions

View file

@ -187,7 +187,7 @@ export class ModelRouter {
if (!experimentsConfig || !lightModelId || lightModelId.trim() === "") {
return false
}
return experiments.isEnabled(experimentsConfig, EXPERIMENT_IDS.MODEL_ROUTING as any)
return experiments.isEnabled(experimentsConfig, EXPERIMENT_IDS.MODEL_ROUTING)
}
/**

View file

@ -2795,6 +2795,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
// Model routing: declare outside try block so catch can restore it
let primaryApiHandler: typeof this.api | undefined
try {
let cacheWriteTokens = 0
let cacheReadTokens = 0
@ -2898,7 +2901,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.diffViewProvider.reset()
// Model routing: temporarily swap to light model if heuristics say so
let primaryApiHandler: typeof this.api | undefined
{
const routingState = await this.providerRef.deref()?.getState()
if (
@ -3604,6 +3606,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
presentAssistantMessage(this)
}
// Model routing: always restore primary API handler after streaming completes,
// regardless of whether the turn had content or not. This prevents permanently
// losing the primary model on empty-response retries or error paths.
if (primaryApiHandler) {
this.api = primaryApiHandler
primaryApiHandler = undefined
}
if (hasTextContent || hasToolUses) {
// NOTE: This comment is here for future reference - this was a
// workaround for `userMessageContent` not getting set to true.
@ -3623,12 +3633,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await pWaitFor(() => this.userMessageContentReady)
// Model routing: end current turn and restore primary handler
// Model routing: end current turn classification
this.modelRouter.endTurn()
if (primaryApiHandler) {
this.api = primaryApiHandler
primaryApiHandler = undefined
}
// If the model did not tool use, then we need to tell it to
// either use a tool or attempt_completion.
@ -3770,6 +3776,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// If we reach here without continuing, return false (will always be false for now)
return false
} catch (error) {
// Model routing: restore primary API handler on error paths
if (primaryApiHandler) {
this.api = primaryApiHandler
primaryApiHandler = undefined
}
// This should never happen since the only thing that can throw an
// error is the attemptApiRequest, which is wrapped in a try catch
// that sends an ask where if noButtonClicked, will clear current

View file

@ -14,6 +14,7 @@ import { SearchableSetting } from "./SearchableSetting"
import { ExperimentalFeature } from "./ExperimentalFeature"
import { ImageGenerationSettings } from "./ImageGenerationSettings"
import { CustomToolsSettings } from "./CustomToolsSettings"
import { ModelRoutingSettings } from "./ModelRoutingSettings"
type ExperimentalSettingsProps = HTMLAttributes<HTMLDivElement> & {
experiments: Experiments
@ -26,6 +27,8 @@ type ExperimentalSettingsProps = HTMLAttributes<HTMLDivElement> & {
setImageGenerationProvider?: (provider: ImageGenerationProvider) => void
setOpenRouterImageApiKey?: (apiKey: string) => void
setImageGenerationSelectedModel?: (model: string) => void
modelRoutingLightModelId?: string
setModelRoutingLightModelId?: (modelId: string) => void
}
export const ExperimentalSettings = ({
@ -39,6 +42,8 @@ export const ExperimentalSettings = ({
setImageGenerationProvider,
setOpenRouterImageApiKey,
setImageGenerationSelectedModel,
modelRoutingLightModelId,
setModelRoutingLightModelId,
className,
...props
}: ExperimentalSettingsProps) => {
@ -83,6 +88,24 @@ export const ExperimentalSettings = ({
</SearchableSetting>
)
}
if (config[0] === "MODEL_ROUTING" && setModelRoutingLightModelId) {
return (
<SearchableSetting
key={config[0]}
settingId={`experimental-${config[0].toLowerCase()}`}
section="experimental"
label={label}>
<ModelRoutingSettings
enabled={experiments[EXPERIMENT_IDS.MODEL_ROUTING] ?? false}
onChange={(enabled) =>
setExperimentEnabled(EXPERIMENT_IDS.MODEL_ROUTING, enabled)
}
modelRoutingLightModelId={modelRoutingLightModelId}
setModelRoutingLightModelId={setModelRoutingLightModelId}
/>
</SearchableSetting>
)
}
if (config[0] === "CUSTOM_TOOLS") {
return (
<SearchableSetting

View file

@ -0,0 +1,50 @@
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface ModelRoutingSettingsProps {
enabled: boolean
onChange: (enabled: boolean) => void
modelRoutingLightModelId: string | undefined
setModelRoutingLightModelId: (modelId: string) => void
}
export const ModelRoutingSettings = ({
enabled,
onChange,
modelRoutingLightModelId,
setModelRoutingLightModelId,
}: ModelRoutingSettingsProps) => {
const { t } = useAppTranslation()
return (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2">
<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>
<span className="font-medium">{t("settings:experimental.MODEL_ROUTING.name")}</span>
</VSCodeCheckbox>
</div>
<p className="text-vscode-descriptionForeground text-sm mt-0">
{t("settings:experimental.MODEL_ROUTING.description")}
</p>
</div>
{enabled && (
<div className="ml-2 space-y-3">
<div>
<label className="block font-medium mb-1">
{t("settings:experimental.MODEL_ROUTING.lightModelIdLabel")}
</label>
<VSCodeTextField
value={modelRoutingLightModelId ?? ""}
placeholder={t("settings:experimental.MODEL_ROUTING.lightModelIdPlaceholder")}
onInput={(e: any) => setModelRoutingLightModelId(e.target.value)}
className="w-full"
/>
</div>
</div>
)}
</div>
)
}

View file

@ -207,6 +207,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
modelRoutingLightModelId,
reasoningBlockCollapsed,
enterBehavior,
includeCurrentTime,
@ -338,6 +339,16 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
})
}, [])
const setModelRoutingLightModelId = useCallback((modelId: string) => {
setCachedState((prevState) => {
if (prevState.modelRoutingLightModelId !== modelId) {
setChangeDetected(true)
}
return { ...prevState, modelRoutingLightModelId: modelId }
})
}, [])
const setCustomSupportPromptsField = useCallback((prompts: Record<string, string | undefined>) => {
setCachedState((prevState) => {
const previousStr = JSON.stringify(prevState.customSupportPrompts)
@ -422,6 +433,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
modelRoutingLightModelId,
experiments,
customSupportPrompts,
},
@ -927,6 +939,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
setImageGenerationProvider={setImageGenerationProvider}
setOpenRouterImageApiKey={setOpenRouterImageApiKey}
setImageGenerationSelectedModel={setImageGenerationSelectedModel}
modelRoutingLightModelId={modelRoutingLightModelId}
setModelRoutingLightModelId={setModelRoutingLightModelId}
/>
)}

View file

@ -941,6 +941,12 @@
"refreshSuccess": "Tools refreshed successfully",
"refreshError": "Failed to refresh tools",
"toolParameters": "Parameters"
},
"MODEL_ROUTING": {
"name": "Enable model routing",
"description": "When enabled, Roo dynamically routes API calls to a lighter (cheaper) model during information-gathering phases. The light model must be from the same provider as your primary model.",
"lightModelIdLabel": "Light Model ID",
"lightModelIdPlaceholder": "e.g. claude-3-haiku-20241022"
}
},
"promptCaching": {