From 6292f2ab9e53c11130dfe9eb14db7a0373ee1742 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 1 Nov 2026 13:57:44 +0000 Subject: [PATCH 1/2] refactor(cli): read context window from extension state instead of resolving it locally The CLI resolved the context window itself by looking up the current model in routerModels via getModelId(). That only worked for router providers (static providers such as Anthropic, Bedrock and Gemini always fell back to 200K) and tied the CLI to provider model-ID logic. The extension now reports the active task's resolved context window as ExtensionState.contextWindow, read from the task's API handler in getStateToPostToWebview(). The CLI stores that value and falls back to the 200K default when it is absent, and no longer tracks routerModels or apiConfiguration for this purpose. --- .../utils/__tests__/context-window.test.ts | 163 ++---------------- apps/cli/src/lib/utils/context-window.ts | 36 ++-- apps/cli/src/ui/App.tsx | 9 +- apps/cli/src/ui/__tests__/store.test.ts | 22 +-- apps/cli/src/ui/hooks/useMessageHandlers.ts | 12 +- apps/cli/src/ui/store.ts | 25 +-- packages/types/src/vscode-extension-host.ts | 6 + src/core/webview/ClineProvider.ts | 7 + .../webview/__tests__/ClineProvider.spec.ts | 36 ++++ 9 files changed, 91 insertions(+), 225 deletions(-) diff --git a/apps/cli/src/lib/utils/__tests__/context-window.test.ts b/apps/cli/src/lib/utils/__tests__/context-window.test.ts index 0fd5f8c209..775d05891b 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -1,160 +1,19 @@ -import { providerIdentifiers, retiredProviderIdentifiers, type ProviderSettings } from "@roo-code/types" - import { DEFAULT_CONTEXT_WINDOW, getContextWindow } from "../context-window.js" describe("getContextWindow", () => { - it.each([ - [providerIdentifiers.openrouter, "openRouterModelId"], - [providerIdentifiers.ollama, "ollamaModelId"], - [providerIdentifiers.lmstudio, "lmStudioModelId"], - [providerIdentifiers.openai, "openAiModelId"], - [providerIdentifiers.requesty, "requestyModelId"], - [providerIdentifiers.unbound, "unboundModelId"], - [providerIdentifiers.litellm, "litellmModelId"], - [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"], - [providerIdentifiers.opencodeGo, "opencodeGoModelId"], - [providerIdentifiers.kenari, "kenariModelId"], - [providerIdentifiers.ioIntelligence, "ioIntelligenceModelId"], - [providerIdentifiers.nanogpt, "nanoGptModelId"], - [providerIdentifiers.zooGateway, "zooGatewayModelId"], - ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { - const config = { apiProvider: provider, [modelField]: "selected-model" } as ProviderSettings - const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } } - - expect(getContextWindow(routerModels, config)).toBe(123_456) + it.each([1, 32_000, 200_000, 1_048_576])("returns the window reported by the extension (%d)", (contextWindow) => { + expect(getContextWindow(contextWindow)).toBe(contextWindow) }) it.each([ - providerIdentifiers.anthropic, - providerIdentifiers.bedrock, - providerIdentifiers.deepseek, - providerIdentifiers.fireworks, - providerIdentifiers.gemini, - providerIdentifiers.mistral, - providerIdentifiers.openaiNative, - providerIdentifiers.vertex, - providerIdentifiers.xai, - ] as const)("uses the shared apiModelId field for %s", (provider) => { - const config: ProviderSettings = { apiProvider: provider, apiModelId: "selected-model" } - const routerModels = { [provider]: { "selected-model": { contextWindow: 64_000 } } } - - expect(getContextWindow(routerModels, config)).toBe(64_000) - }) - - it("ignores another provider's model field", () => { - const config: ProviderSettings = { - apiProvider: providerIdentifiers.ollama, - openRouterModelId: "openrouter-model", - ollamaModelId: "ollama-model", - } - const routerModels = { - [providerIdentifiers.ollama]: { "ollama-model": { contextWindow: 32_000 } }, - } - - expect(getContextWindow(routerModels, config)).toBe(32_000) - }) - - describe("VS Code LM", () => { - const provider = providerIdentifiers.vscodeLm - const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } } - - it("takes the model from the saved selector", () => { - const config: ProviderSettings = { - apiProvider: provider, - vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, - } - - expect(getContextWindow(routerModels, config)).toBe(123_456) - }) - - it("ignores apiModelId, which the selector replaces", () => { - const config: ProviderSettings = { - apiProvider: provider, - apiModelId: "other-model", - vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, - } - - expect(getContextWindow(routerModels, config)).toBe(123_456) - }) - }) - - describe("GitHub Copilot", () => { - const provider = providerIdentifiers.githubCopilot - const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } } - - it("takes the model from the saved selector, where Copilot keeps it", () => { - const config: ProviderSettings = { - apiProvider: provider, - vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, - } - - expect(getContextWindow(routerModels, config)).toBe(123_456) - }) - - it("ignores apiModelId, which Copilot profiles do not use", () => { - const config: ProviderSettings = { - apiProvider: provider, - apiModelId: "other-model", - vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, - } - - expect(getContextWindow(routerModels, config)).toBe(123_456) - }) - - it.each([ - ["no selector", undefined], - ["a selector that names a family but no id", { vendor: "copilot", family: "gpt-5.5" }], - ])("returns the default for %s", (_label, vsCodeLmModelSelector) => { - const config: ProviderSettings = { apiProvider: provider, vsCodeLmModelSelector } - - expect(getContextWindow(routerModels, config)).toBe(DEFAULT_CONTEXT_WINDOW) - }) - - it("returns the default when the selected model is not in the list", () => { - const config: ProviderSettings = { - apiProvider: provider, - vsCodeLmModelSelector: { vendor: "copilot", id: "unlisted-model" }, - } - - expect(getContextWindow(routerModels, config)).toBe(DEFAULT_CONTEXT_WINDOW) - }) - }) - - it("returns the default when the selected model is unavailable", () => { - const routerModels = { [providerIdentifiers.openrouter]: { "other-model": { contextWindow: 1_000 } } } - const config: ProviderSettings = { - apiProvider: providerIdentifiers.openrouter, - openRouterModelId: "missing-model", - } - - expect(getContextWindow(routerModels, config)).toBe(DEFAULT_CONTEXT_WINDOW) - }) - - it("returns the default when no provider is selected", () => { - expect(getContextWindow({}, {})).toBe(DEFAULT_CONTEXT_WINDOW) - }) - - it("returns the default when router models are unavailable for a retired provider", () => { - const config: ProviderSettings = { - apiProvider: retiredProviderIdentifiers.groq, - apiModelId: "selected-model", - } - - expect(getContextWindow({}, config)).toBe(DEFAULT_CONTEXT_WINDOW) - }) - - it("resolves the model for a retired provider instead of throwing", () => { - const config: ProviderSettings = { - apiProvider: retiredProviderIdentifiers.groq, - apiModelId: "selected-model", - } - const routerModels = { [retiredProviderIdentifiers.groq]: { "selected-model": { contextWindow: 48_000 } } } - - expect(getContextWindow(routerModels, config)).toBe(48_000) - }) - - it("returns the default when router models or configuration is missing", () => { - expect(getContextWindow(null, { apiProvider: providerIdentifiers.anthropic })).toBe(DEFAULT_CONTEXT_WINDOW) - expect(getContextWindow({}, null)).toBe(DEFAULT_CONTEXT_WINDOW) + ["undefined", undefined], + ["null", null], + ["zero", 0], + ["negative", -1], + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["-Infinity", Number.NEGATIVE_INFINITY], + ])("returns the default when the reported window is %s", (_label, contextWindow) => { + expect(getContextWindow(contextWindow)).toBe(DEFAULT_CONTEXT_WINDOW) }) }) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 0e00db1047..5ba03d1ad9 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -1,32 +1,20 @@ -import { getModelId, type ProviderSettings } from "@roo-code/types" - -import type { RouterModels } from "@/ui/store.js" - const DEFAULT_CONTEXT_WINDOW = 200_000 /** - * Looks up the context window size for the current model from routerModels. + * Resolves the context window to display for the current model. * - * @param routerModels - The router models data containing model info per provider - * @param apiConfiguration - The current API configuration with provider and model ID - * @returns The context window size, or DEFAULT_CONTEXT_WINDOW (200K) if not found + * The extension resolves the model through its provider handler and posts the result as + * `ExtensionState.contextWindow`, so this covers static and router providers alike. + * + * @param contextWindow - The context window reported by the extension state, if any + * @returns The reported context window, or DEFAULT_CONTEXT_WINDOW (200K) when it is missing or invalid */ -export function getContextWindow(routerModels: RouterModels | null, apiConfiguration: ProviderSettings | null): number { - if (!routerModels || !apiConfiguration) { - return DEFAULT_CONTEXT_WINDOW - } - - const provider = apiConfiguration.apiProvider - const modelId = getModelId(apiConfiguration) - - if (!provider || !modelId) { - return DEFAULT_CONTEXT_WINDOW - } - - const providerModels = routerModels[provider] - const modelInfo = providerModels?.[modelId] - - return modelInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW +export function getContextWindow(contextWindow: number | null | undefined): number { + // Reject non-finite values (NaN, Infinity) as well as non-positive ones, so the + // progress bar never divides by an infinite window and reports 0% usage. + return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : DEFAULT_CONTEXT_WINDOW } export { DEFAULT_CONTEXT_WINDOW } diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index c00af46683..8565563f23 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -106,8 +106,7 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) taskHistory, currentMode, tokenUsage, - routerModels, - apiConfiguration, + contextWindow: reportedContextWindow, currentTodos, } = useCLIStore() @@ -122,10 +121,8 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) setIsTransitioningToCustomInput, } = useUIStateStore() - // Compute context window from router models and API configuration - const contextWindow = useMemo(() => { - return getContextWindow(routerModels, apiConfiguration) - }, [routerModels, apiConfiguration]) + // Context window reported by the extension, falling back to the default + const contextWindow = getContextWindow(reportedContextWindow) // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteRef = useRef>(null) diff --git a/apps/cli/src/ui/__tests__/store.test.ts b/apps/cli/src/ui/__tests__/store.test.ts index 970d52aa0a..9ec9b2d6f4 100644 --- a/apps/cli/src/ui/__tests__/store.test.ts +++ b/apps/cli/src/ui/__tests__/store.test.ts @@ -1,6 +1,3 @@ -import { providerIdentifiers } from "@roo-code/types" -import { RooCodeSettings } from "@roo-code/types" - import { useCLIStore } from "../store.js" describe("useCLIStore", () => { @@ -159,25 +156,12 @@ describe("useCLIStore", () => { expect(useCLIStore.getState().currentMode).toBe("architect") }) - it("should PRESERVE routerModels", () => { - const models = { openai: { "gpt-4": { contextWindow: 128000 } } } - useCLIStore.getState().setRouterModels(models) - - useCLIStore.getState().resetForTaskSwitch() - - expect(useCLIStore.getState().routerModels).toEqual(models) - }) - - it("should PRESERVE apiConfiguration", () => { - const config: RooCodeSettings = { apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4" } - - useCLIStore - .getState() - .setApiConfiguration(config as ReturnType["apiConfiguration"]) + it("should PRESERVE contextWindow", () => { + useCLIStore.getState().setContextWindow(128_000) useCLIStore.getState().resetForTaskSwitch() - expect(useCLIStore.getState().apiConfiguration).toEqual(config) + expect(useCLIStore.getState().contextWindow).toBe(128_000) }) }) diff --git a/apps/cli/src/ui/hooks/useMessageHandlers.ts b/apps/cli/src/ui/hooks/useMessageHandlers.ts index 68695e39c7..73663a82bc 100644 --- a/apps/cli/src/ui/hooks/useMessageHandlers.ts +++ b/apps/cli/src/ui/hooks/useMessageHandlers.ts @@ -40,7 +40,7 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions setAvailableModes, setCurrentMode, setTokenUsage, - setRouterModels, + setContextWindow, setTaskHistory, currentTodos, setTodos, @@ -315,6 +315,10 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions setCurrentMode(newMode) } + // The extension resolves the current task's model and reports its context window. + // When no task is active it's undefined (nothing uses the value when no task is active) + setContextWindow(state.contextWindow ?? null) + // Extract and update task history from state const newTaskHistory = state.taskHistory @@ -382,10 +386,6 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) } else if (msg.type === "modes") { setAvailableModes((msg.modes as ModeResult[]) || []) - } else if (msg.type === "routerModels") { - if (msg.routerModels) { - setRouterModels(msg.routerModels) - } } }, [ @@ -396,7 +396,7 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions setAvailableModes, setCurrentMode, setTokenUsage, - setRouterModels, + setContextWindow, setTaskHistory, ], ) diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index 6c9566a006..dbe33753dc 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -1,6 +1,6 @@ import { create } from "zustand" -import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" +import type { TokenUsage, TodoItem } from "@roo-code/types" import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js" import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js" @@ -37,12 +37,6 @@ interface PendingStreamUpdate { const pendingStreamUpdates: Map = new Map() let streamingDebounceTimer: ReturnType | null = null -/** - * RouterModels type for context window lookup. - * Simplified version - we only need contextWindow from ModelInfo. - */ -export type RouterModels = Record> - /** * CLI application state. * @@ -82,9 +76,8 @@ interface CLIState { // Token usage metrics (from getApiMetrics) tokenUsage: TokenUsage | null - // Model info for context window lookup - routerModels: RouterModels | null - apiConfiguration: ProviderSettings | null + // Context window of the current model, as resolved by the extension + contextWindow: number | null // Todo list tracking currentTodos: TodoItem[] @@ -124,8 +117,7 @@ interface CLIActions { // Metrics actions setTokenUsage: (usage: TokenUsage | null) => void - setRouterModels: (models: RouterModels | null) => void - setApiConfiguration: (config: ProviderSettings | null) => void + setContextWindow: (contextWindow: number | null) => void // Todo actions setTodos: (todos: TodoItem[]) => void @@ -146,8 +138,7 @@ const initialState: CLIState = { currentTaskId: null, currentMode: null, tokenUsage: null, - routerModels: null, - apiConfiguration: null, + contextWindow: null, currentTodos: [], previousTodos: [], } @@ -273,8 +264,7 @@ export const useCLIStore = create((set, get) => ({ allSlashCommands: state.allSlashCommands, fileSearchResults: state.fileSearchResults, currentMode: state.currentMode, - routerModels: state.routerModels, - apiConfiguration: state.apiConfiguration, + contextWindow: state.contextWindow, })), setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }), // Use shallow equality to prevent unnecessary re-renders when array content is the same @@ -289,7 +279,6 @@ export const useCLIStore = create((set, get) => ({ setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }), setCurrentMode: (mode) => set({ currentMode: mode }), setTokenUsage: (usage) => set({ tokenUsage: usage }), - setRouterModels: (models) => set({ routerModels: models }), - setApiConfiguration: (config) => set({ apiConfiguration: config }), + setContextWindow: (contextWindow) => set({ contextWindow }), setTodos: (todos) => set((state) => ({ previousTodos: state.currentTodos, currentTodos: todos })), })) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 55cf302722..133b6f50df 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -354,6 +354,12 @@ export type ExtensionState = Pick< currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings + /** + * Context window (in tokens) of the current task's resolved model, as computed by the + * extension's provider handler. Covers static and router providers alike, so consumers + * (e.g. the CLI) need no provider-specific model lookup. Undefined when no task is active. + */ + contextWindow?: number uriScheme?: string shouldShowAnnouncement: boolean diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 636c022f62..fc2623940a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3033,6 +3033,12 @@ export class ClineProvider } const currentTaskApiConfigName = currentTask?.taskApiConfigName const currentTaskApiConfiguration = currentTask?.apiConfiguration + let contextWindow: number | undefined + try { + contextWindow = currentTask?.api?.getModel().info.contextWindow + } catch { + // The handler may be mid-rebuild; omit the window for this post rather than failing the whole state. + } let zooCodeState: { zooCodeIsAuthenticated: boolean zooCodeUserName: string | undefined @@ -3068,6 +3074,7 @@ export class ClineProvider return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration: currentTaskApiConfiguration ?? apiConfiguration, + contextWindow, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d0155955d0..15e47f17e7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4119,6 +4119,42 @@ describe("ClineProvider", () => { expect(state.alwaysDenyUnapprovedCommands).toBe(false) }) + describe("getStateToPostToWebview contextWindow", () => { + const addTaskWithModel = async (getModel: () => { id: string; info: { contextWindow: number } }) => { + const task = new Task(defaultTaskOptions) + Object.assign(task, { api: { getModel } }) + await provider.addClineToStack(task) + } + + test("returns the current task's resolved model context window", async () => { + await provider.resolveWebviewView(mockWebviewView) + await addTaskWithModel(() => ({ id: "static-model", info: { contextWindow: 1_048_576 } })) + + const state = await provider.getStateToPostToWebview() + + expect(state.contextWindow).toBe(1_048_576) + }) + + test("omits the context window when no task is active", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.contextWindow).toBeUndefined() + }) + + test("omits the context window instead of failing when the handler throws", async () => { + await provider.resolveWebviewView(mockWebviewView) + await addTaskWithModel(() => { + throw new Error("handler rebuilding") + }) + + const state = await provider.getStateToPostToWebview() + + expect(state.contextWindow).toBeUndefined() + }) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" From c9c57d49f7dce12f43a20cee1457da238414c832 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 1 Nov 2026 18:35:37 +0200 Subject: [PATCH 2/2] Update apps/cli/src/lib/utils/context-window.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/cli/src/lib/utils/context-window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 5ba03d1ad9..7256cdc9e7 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -12,7 +12,7 @@ const DEFAULT_CONTEXT_WINDOW = 200_000 export function getContextWindow(contextWindow: number | null | undefined): number { // Reject non-finite values (NaN, Infinity) as well as non-positive ones, so the // progress bar never divides by an infinite window and reports 0% usage. - return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + return typeof contextWindow === "number" && Number.isInteger(contextWindow) && contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW }