From 37c8e2ae3f5ca0f89fbfc66e250e7049f03b0c4c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 8 Feb 2026 00:57:36 +0000 Subject: [PATCH] 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() --- src/core/task/ModelRouter.ts | 2 +- src/core/task/Task.ts | 23 ++++++--- .../settings/ExperimentalSettings.tsx | 23 +++++++++ .../settings/ModelRoutingSettings.tsx | 50 +++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 14 ++++++ webview-ui/src/i18n/locales/en/settings.json | 6 +++ 6 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 webview-ui/src/components/settings/ModelRoutingSettings.tsx diff --git a/src/core/task/ModelRouter.ts b/src/core/task/ModelRouter.ts index 0ebc2fec38..bc35a2761b 100644 --- a/src/core/task/ModelRouter.ts +++ b/src/core/task/ModelRouter.ts @@ -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) } /** diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 22cd577868..d282034c48 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2795,6 +2795,9 @@ export class Task extends EventEmitter 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 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 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 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 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 diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 23786ce0b9..9ca2e24c9c 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -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 & { experiments: Experiments @@ -26,6 +27,8 @@ type ExperimentalSettingsProps = HTMLAttributes & { 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 = ({ ) } + if (config[0] === "MODEL_ROUTING" && setModelRoutingLightModelId) { + return ( + + + setExperimentEnabled(EXPERIMENT_IDS.MODEL_ROUTING, enabled) + } + modelRoutingLightModelId={modelRoutingLightModelId} + setModelRoutingLightModelId={setModelRoutingLightModelId} + /> + + ) + } if (config[0] === "CUSTOM_TOOLS") { return ( void + modelRoutingLightModelId: string | undefined + setModelRoutingLightModelId: (modelId: string) => void +} + +export const ModelRoutingSettings = ({ + enabled, + onChange, + modelRoutingLightModelId, + setModelRoutingLightModelId, +}: ModelRoutingSettingsProps) => { + const { t } = useAppTranslation() + + return ( +
+
+
+ onChange(e.target.checked)}> + {t("settings:experimental.MODEL_ROUTING.name")} + +
+

+ {t("settings:experimental.MODEL_ROUTING.description")} +

+
+ + {enabled && ( +
+
+ + setModelRoutingLightModelId(e.target.value)} + className="w-full" + /> +
+
+ )} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 1876302b47..0647095683 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -207,6 +207,7 @@ const SettingsView = forwardRef(({ onDone, t imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + modelRoutingLightModelId, reasoningBlockCollapsed, enterBehavior, includeCurrentTime, @@ -338,6 +339,16 @@ const SettingsView = forwardRef(({ onDone, t }) }, []) + const setModelRoutingLightModelId = useCallback((modelId: string) => { + setCachedState((prevState) => { + if (prevState.modelRoutingLightModelId !== modelId) { + setChangeDetected(true) + } + + return { ...prevState, modelRoutingLightModelId: modelId } + }) + }, []) + const setCustomSupportPromptsField = useCallback((prompts: Record) => { setCachedState((prevState) => { const previousStr = JSON.stringify(prevState.customSupportPrompts) @@ -422,6 +433,7 @@ const SettingsView = forwardRef(({ onDone, t imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + modelRoutingLightModelId, experiments, customSupportPrompts, }, @@ -927,6 +939,8 @@ const SettingsView = forwardRef(({ onDone, t setImageGenerationProvider={setImageGenerationProvider} setOpenRouterImageApiKey={setOpenRouterImageApiKey} setImageGenerationSelectedModel={setImageGenerationSelectedModel} + modelRoutingLightModelId={modelRoutingLightModelId} + setModelRoutingLightModelId={setModelRoutingLightModelId} /> )} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 61dfaf42af..becd7ff223 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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": {