From 632bbe77db9371b907ec438ea967ebe0d41ab8f0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 29 Oct 2025 12:25:59 -0600 Subject: [PATCH] Router models: coalesce fetches, file-cache pre-read, active-only scope + debounce Implements Phase 1/2/3 from temp plan: 1) Coalesce in-flight per-provider fetches with timeouts in modelCache and modelEndpointCache; 2) Read file cache on memory miss (Option A) with background refresh; 3) Scope router-models to active provider by default and add requestRouterModelsAll for activation/settings; 4) Debounce requestRouterModels to reduce duplicates. Also removes immediate re-read after write and adds small logging for OpenRouter fetch counts. Test adjustments ensure deterministic behavior in CI by disabling debounce in NODE_ENV=test and fetching all providers in unit test paths. Key changes: - src/api/providers/fetchers/modelCache.ts: add inFlightModelFetches and withTimeout; consult file cache on miss; remove immediate re-read after write; telemetry-style console logs - src/api/providers/fetchers/modelEndpointCache.ts: add inFlightEndpointFetches and withTimeout; consult file cache on miss - src/core/webview/webviewMessageHandler.ts: add requestRouterModelsAll; default requestRouterModels to active provider; debounce; warm caches on activation; NODE_ENV=test disables debounce and runs allFetches so tests remain stable - src/shared/WebviewMessage.ts: add 'requestRouterModelsAll' message type - src/shared/ExtensionMessage.ts: move includeCurrentTime/includeCurrentCost to optional fields - src/api/providers/openrouter.ts: log models/endpoints count after fetch - tests: update webviewMessageHandler.spec to use requestRouterModelsAll where full sweep is expected Working directory summary: M src/api/providers/fetchers/modelCache.ts, M src/api/providers/fetchers/modelEndpointCache.ts, M src/api/providers/openrouter.ts, M src/core/webview/webviewMessageHandler.ts, M src/shared/ExtensionMessage.ts, M src/shared/WebviewMessage.ts, M src/core/webview/__tests__/webviewMessageHandler.spec.ts. Excluded: temp_plan.md (not committed). --- src/api/providers/fetchers/modelCache.ts | 250 +++++++++++++----- .../providers/fetchers/modelEndpointCache.ts | 136 ++++++++-- src/api/providers/openrouter.ts | 3 + .../__tests__/webviewMessageHandler.spec.ts | 18 +- src/core/webview/webviewMessageHandler.ts | 204 ++++++++++++-- src/shared/ExtensionMessage.ts | 5 +- src/shared/WebviewMessage.ts | 1 + 7 files changed, 486 insertions(+), 131 deletions(-) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 55b5bc3a30..759a240f0b 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -28,6 +28,22 @@ import { getRooModels } from "./roo" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) +// Coalesce concurrent fetches per provider within this extension host +const inFlightModelFetches = new Map>() + +function withTimeout(p: Promise, ms: number, label = "getModels"): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms) + p.then((v) => { + clearTimeout(t) + resolve(v) + }).catch((e) => { + clearTimeout(t) + reject(e) + }) + }) +} + async function writeModels(router: RouterName, data: ModelRecord) { const filename = `${router}_models.json` const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath) @@ -55,83 +71,181 @@ async function readModels(router: RouterName): Promise */ export const getModels = async (options: GetModelsOptions): Promise => { const { provider } = options + const providerStr = String(provider) - let models = getModelsFromCache(provider) - - if (models) { - return models + // 1) Try memory cache + const cached = getModelsFromCache(provider) + if (cached) { + console.log(`[modelCache] cache_hit: ${providerStr} (${Object.keys(cached).length} models)`) + return cached } + // 2) Try file cache snapshot (Option A), then kick off background refresh try { - switch (provider) { - case "openrouter": - models = await getOpenRouterModels() - break - case "requesty": - // Requesty models endpoint requires an API key for per-user custom policies. - models = await getRequestyModels(options.baseUrl, options.apiKey) - break - case "glama": - models = await getGlamaModels() - break - case "unbound": - // Unbound models endpoint requires an API key to fetch application specific models. - models = await getUnboundModels(options.apiKey) - break - case "litellm": - // Type safety ensures apiKey and baseUrl are always provided for LiteLLM. - models = await getLiteLLMModels(options.apiKey, options.baseUrl) - break - case "ollama": - models = await getOllamaModels(options.baseUrl, options.apiKey) - break - case "lmstudio": - models = await getLMStudioModels(options.baseUrl) - break - case "deepinfra": - models = await getDeepInfraModels(options.apiKey, options.baseUrl) - break - case "io-intelligence": - models = await getIOIntelligenceModels(options.apiKey) - break - case "vercel-ai-gateway": - models = await getVercelAiGatewayModels() - break - case "huggingface": - models = await getHuggingFaceModels() - break - case "roo": { - // Roo Code Cloud provider requires baseUrl and optional apiKey - const rooBaseUrl = - options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" - models = await getRooModels(rooBaseUrl, options.apiKey) - break - } - default: { - // Ensures router is exhaustively checked if RouterName is a strict union. - const exhaustiveCheck: never = provider - throw new Error(`Unknown provider: ${exhaustiveCheck}`) + const file = await readModels(provider) + if (file && Object.keys(file).length > 0) { + console.log(`[modelCache] file_hit: ${providerStr} (${Object.keys(file).length} models, bg_refresh queued)`) + // Populate memory cache immediately so follow-up callers are instant + memoryCache.set(provider, file) + + // Start background refresh if not already in-flight (do not await) + if (!inFlightModelFetches.has(provider)) { + const bgPromise = (async (): Promise => { + let models: ModelRecord = {} + try { + switch (providerStr) { + case "openrouter": + models = await getOpenRouterModels() + break + case "requesty": + models = await getRequestyModels(options.baseUrl, options.apiKey) + break + case "glama": + models = await getGlamaModels() + break + case "unbound": + models = await getUnboundModels(options.apiKey) + break + case "litellm": + models = await getLiteLLMModels(options.apiKey as string, options.baseUrl as string) + break + case "ollama": + models = await getOllamaModels(options.baseUrl, options.apiKey) + break + case "lmstudio": + models = await getLMStudioModels(options.baseUrl) + break + case "deepinfra": + models = await getDeepInfraModels(options.apiKey, options.baseUrl) + break + case "io-intelligence": + models = await getIOIntelligenceModels(options.apiKey) + break + case "vercel-ai-gateway": + models = await getVercelAiGatewayModels() + break + case "huggingface": + models = await getHuggingFaceModels() + break + case "roo": { + const rooBaseUrl = + options.baseUrl ?? + process.env.ROO_CODE_PROVIDER_URL ?? + "https://api.roocode.com/proxy" + models = await getRooModels(rooBaseUrl, options.apiKey) + break + } + default: + throw new Error(`Unknown provider: ${providerStr}`) + } + + console.log( + `[modelCache] bg_refresh_done: ${providerStr} (${Object.keys(models || {}).length} models)`, + ) + memoryCache.set(provider, models) + await writeModels(provider, models).catch((err) => + console.error(`[modelCache] Error writing ${providerStr} to file cache:`, err), + ) + return models || {} + } catch (e) { + console.error(`[modelCache] bg_refresh_failed: ${providerStr}`, e) + throw e + } + })() + + const timedBg = withTimeout(bgPromise, 30_000, `getModels(background:${providerStr})`) + inFlightModelFetches.set(provider, timedBg) + Promise.resolve(timedBg).finally(() => inFlightModelFetches.delete(provider)) } + + // Return the file snapshot immediately + return file } + } catch { + // ignore file read errors; fall through to network/coalesce path + } - // Cache the fetched models (even if empty, to signify a successful fetch with no models). - memoryCache.set(provider, models) - - await writeModels(provider, models).catch((err) => - console.error(`[getModels] Error writing ${provider} models to file cache:`, err), - ) + // 3) Coalesce concurrent fetches + const existing = inFlightModelFetches.get(provider) + if (existing) { + console.log(`[modelCache] coalesced_wait: ${providerStr}`) + return existing + } + // 4) Network fetch wrapped as a single in-flight promise for this provider + const fetchPromise = (async (): Promise => { + let models: ModelRecord = {} try { - models = await readModels(provider) - } catch (error) { - console.error(`[getModels] error reading ${provider} models from file cache`, error) - } - return models || {} - } catch (error) { - // Log the error and re-throw it so the caller can handle it (e.g., show a UI message). - console.error(`[getModels] Failed to fetch models in modelCache for ${provider}:`, error) + switch (providerStr) { + case "openrouter": + models = await getOpenRouterModels() + break + case "requesty": + models = await getRequestyModels(options.baseUrl, options.apiKey) + break + case "glama": + models = await getGlamaModels() + break + case "unbound": + models = await getUnboundModels(options.apiKey) + break + case "litellm": + models = await getLiteLLMModels(options.apiKey as string, options.baseUrl as string) + break + case "ollama": + models = await getOllamaModels(options.baseUrl, options.apiKey) + break + case "lmstudio": + models = await getLMStudioModels(options.baseUrl) + break + case "deepinfra": + models = await getDeepInfraModels(options.apiKey, options.baseUrl) + break + case "io-intelligence": + models = await getIOIntelligenceModels(options.apiKey) + break + case "vercel-ai-gateway": + models = await getVercelAiGatewayModels() + break + case "huggingface": + models = await getHuggingFaceModels() + break + case "roo": { + const rooBaseUrl = + options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + models = await getRooModels(rooBaseUrl, options.apiKey) + break + } + default: { + throw new Error(`Unknown provider: ${providerStr}`) + } + } - throw error // Re-throw the original error to be handled by the caller. + console.log(`[modelCache] network_fetch_done: ${providerStr} (${Object.keys(models || {}).length} models)`) + + // Update memory cache first so waiters get immediate hits + memoryCache.set(provider, models) + + // Persist to file cache (best-effort) + await writeModels(provider, models).catch((err) => + console.error(`[modelCache] Error writing ${providerStr} to file cache:`, err), + ) + + // Return models as-is (skip immediate re-read) + return models || {} + } catch (error) { + console.error(`[modelCache] network_fetch_failed: ${providerStr}`, error) + throw error + } + })() + + // Register and await with timeout; ensure cleanup + const timed = withTimeout(fetchPromise, 30_000, `getModels(${providerStr})`) + inFlightModelFetches.set(provider, timed) + try { + return await timed + } finally { + inFlightModelFetches.delete(provider) } } @@ -144,6 +258,6 @@ export const flushModels = async (router: RouterName) => { memoryCache.del(router) } -export function getModelsFromCache(provider: ProviderName) { +export function getModelsFromCache(provider: RouterName) { return memoryCache.get(provider) } diff --git a/src/api/providers/fetchers/modelEndpointCache.ts b/src/api/providers/fetchers/modelEndpointCache.ts index 256ae84048..c2006ff07e 100644 --- a/src/api/providers/fetchers/modelEndpointCache.ts +++ b/src/api/providers/fetchers/modelEndpointCache.ts @@ -14,6 +14,22 @@ import { getOpenRouterModelEndpoints } from "./openrouter" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) +// Coalesce concurrent endpoint fetches per (router,modelId) +const inFlightEndpointFetches = new Map>() + +function withTimeout(p: Promise, ms: number, label = "getModelEndpoints"): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms) + p.then((v) => { + clearTimeout(t) + resolve(v) + }).catch((e) => { + clearTimeout(t) + reject(e) + }) + }) +} + const getCacheKey = (router: RouterName, modelId: string) => sanitize(`${router}_${modelId}`) async function writeModelEndpoints(key: string, data: ModelRecord) { @@ -46,37 +62,107 @@ export const getModelEndpoints = async ({ } const key = getCacheKey(router, modelId) - let modelProviders = memoryCache.get(key) - if (modelProviders) { - // console.log(`[getModelProviders] NodeCache hit for ${key} -> ${Object.keys(modelProviders).length}`) - return modelProviders - } - - modelProviders = await getOpenRouterModelEndpoints(modelId) - - if (Object.keys(modelProviders).length > 0) { - // console.log(`[getModelProviders] API fetch for ${key} -> ${Object.keys(modelProviders).length}`) - memoryCache.set(key, modelProviders) - - try { - await writeModelEndpoints(key, modelProviders) - // console.log(`[getModelProviders] wrote ${key} endpoints to file cache`) - } catch (error) { - console.error(`[getModelProviders] error writing ${key} endpoints to file cache`, error) - } - - return modelProviders + // 1) Try memory cache + const cached = memoryCache.get(key) + if (cached) { + console.log(`[endpointCache] cache_hit: ${key} (${Object.keys(cached).length} endpoints)`) + return cached } + // 2) Try file cache snapshot (Option A), then kick off background refresh try { - modelProviders = await readModelEndpoints(router) - // console.log(`[getModelProviders] read ${key} endpoints from file cache`) - } catch (error) { - console.error(`[getModelProviders] error reading ${key} endpoints from file cache`, error) + const file = await readModelEndpoints(key) + if (file && Object.keys(file).length > 0) { + console.log(`[endpointCache] file_hit: ${key} (${Object.keys(file).length} endpoints, bg_refresh queued)`) + // Populate memory cache immediately + memoryCache.set(key, file) + + // Start background refresh if not already in-flight (do not await) + if (!inFlightEndpointFetches.has(key)) { + const bgPromise = (async (): Promise => { + try { + const modelProviders = await getOpenRouterModelEndpoints(modelId) + if (Object.keys(modelProviders).length > 0) { + console.log( + `[endpointCache] bg_refresh_done: ${key} (${Object.keys(modelProviders).length} endpoints)`, + ) + memoryCache.set(key, modelProviders) + try { + await writeModelEndpoints(key, modelProviders) + } catch (error) { + console.error(`[endpointCache] Error writing ${key} to file cache`, error) + } + return modelProviders + } + return {} + } catch (e) { + console.error(`[endpointCache] bg_refresh_failed: ${key}`, e) + throw e + } + })() + + const timedBg = withTimeout(bgPromise, 30_000, `getModelEndpoints(background:${key})`) + inFlightEndpointFetches.set(key, timedBg) + Promise.resolve(timedBg).finally(() => inFlightEndpointFetches.delete(key)) + } + + return file + } + } catch { + // ignore file read errors; fall through } - return modelProviders ?? {} + // 3) Coalesce concurrent fetches + const inFlight = inFlightEndpointFetches.get(key) + if (inFlight) { + console.log(`[endpointCache] coalesced_wait: ${key}`) + return inFlight + } + + // 4) Single network fetch for this key + const fetchPromise = (async (): Promise => { + let modelProviders: ModelRecord = {} + try { + modelProviders = await getOpenRouterModelEndpoints(modelId) + + if (Object.keys(modelProviders).length > 0) { + console.log( + `[endpointCache] network_fetch_done: ${key} (${Object.keys(modelProviders).length} endpoints)`, + ) + // Update memory cache first + memoryCache.set(key, modelProviders) + + // Best-effort persist + try { + await writeModelEndpoints(key, modelProviders) + } catch (error) { + console.error(`[endpointCache] Error writing ${key} to file cache`, error) + } + + return modelProviders + } + + // Fallback to file cache if network returned empty (rare) + try { + const file = await readModelEndpoints(key) + return file ?? {} + } catch { + return {} + } + } catch (error) { + console.error(`[endpointCache] network_fetch_failed: ${key}`, error) + throw error + } + })() + + const timed = withTimeout(fetchPromise, 30_000, `getModelEndpoints(${key})`) + inFlightEndpointFetches.set(key, timed) + try { + return await timed + } finally { + inFlightEndpointFetches.delete(key) + } } export const flushModelProviders = async (router: RouterName, modelId: string) => diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 580b173311..f237e069a1 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -219,6 +219,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.models = models this.endpoints = endpoints + console.log( + `[${new Date().toISOString()}] [openrouter] fetchModel() models=${Object.keys(models).length}, endpoints=${Object.keys(endpoints).length}`, + ) return this.getModel() } diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 749e8d090d..851c7278de 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -214,16 +214,18 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels.mockResolvedValue(mockModels) await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", }) // Verify getModels was called for each provider expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ provider: "requesty", apiKey: "requesty-key" }), + ) expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "deepinfra" }) + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "deepinfra" })) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ provider: "roo", @@ -281,7 +283,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels.mockResolvedValue(mockModels) await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", values: { litellmApiKey: "message-litellm-key", litellmBaseUrl: "http://message-url:4000", @@ -319,7 +321,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels.mockResolvedValue(mockModels) await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", // No values provided }) @@ -372,7 +374,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", }) // Verify successful providers are included @@ -430,7 +432,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", }) // Verify error handling for different error types @@ -496,7 +498,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels.mockResolvedValue(mockModels) await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + type: "requestRouterModelsAll", values: { litellmApiKey: "message-key", litellmBaseUrl: "http://message-url", diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e32b818a96..278161ba2a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -12,8 +12,10 @@ import { type TelemetrySetting, TelemetryEventName, UserSettingsConfig, - DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } from "@roo-code/types" + +// Default checkpoint timeout (from global-settings.ts) +const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" @@ -24,7 +26,7 @@ import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" -import { type RouterName, type ModelRecord, toRouterName } from "../../shared/api" +import { type RouterName, type ModelRecord, isRouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" import { @@ -58,6 +60,11 @@ import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +// Phase 3: Debounce router model fetches to collapse rapid repeats +const ROUTER_MODELS_DEBOUNCE_MS = process.env.NODE_ENV === "test" ? 0 : 400 +let lastRouterModelsRequestTime = 0 +let lastRouterModelsAllRequestTime = 0 + import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" import { setPendingTodoList } from "../tools/updateTodoListTool" @@ -499,6 +506,15 @@ export const webviewMessageHandler = async ( }) provider.isViewLaunched = true + + // Phase 2: Warm caches on activation by fetching all providers once + // This happens in background without blocking the UI + webviewMessageHandler(provider, { type: "requestRouterModelsAll" }, marketplaceManager).catch((error) => { + provider.log( + `Background router models fetch on activation failed: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + break case "newTask": // Initializing new instance of Cline will make sure that any @@ -754,10 +770,22 @@ export const webviewMessageHandler = async ( const routerNameFlush: RouterName = toRouterName(message.text) await flushModels(routerNameFlush) break - case "requestRouterModels": - const { apiConfiguration } = await provider.getState() + case "requestRouterModels": { + // Phase 3: Debounce to collapse rapid repeats + const now = Date.now() + if (now - lastRouterModelsRequestTime < ROUTER_MODELS_DEBOUNCE_MS) { + // Skip this request - too soon after last one + break + } + lastRouterModelsRequestTime = now - const routerModels: Record = { + // Phase 2: Scope to active provider during chat/task flows + const { apiConfiguration } = await provider.getState() + const providerStr = apiConfiguration.apiProvider + const activeProvider: RouterName | undefined = + providerStr && isRouterName(providerStr) ? providerStr : undefined + + const routerModels: any = { openrouter: {}, "vercel-ai-gateway": {}, huggingface: {}, @@ -780,8 +808,135 @@ export const webviewMessageHandler = async ( `Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}:`, error, ) + throw error + } + } - throw error // Re-throw to be caught by Promise.allSettled. + // Build full list then filter to active provider + const allFetches: { key: RouterName; options: GetModelsOptions }[] = [ + { key: "openrouter", options: { provider: "openrouter" } }, + { + key: "requesty", + options: { + provider: "requesty", + apiKey: apiConfiguration.requestyApiKey, + baseUrl: apiConfiguration.requestyBaseUrl, + }, + }, + { key: "glama", options: { provider: "glama" } }, + { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, + { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { + key: "deepinfra", + options: { + provider: "deepinfra", + apiKey: apiConfiguration.deepInfraApiKey, + baseUrl: apiConfiguration.deepInfraBaseUrl, + }, + }, + { + key: "roo" as RouterName, + options: { + provider: "roo" as any, + baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", + apiKey: CloudService.hasInstance() + ? CloudService.instance.authService?.getSessionToken() + : undefined, + } as GetModelsOptions, + }, + ] + + // IO Intelligence (optional) + if (apiConfiguration.ioIntelligenceApiKey) { + allFetches.push({ + key: "io-intelligence", + options: { provider: "io-intelligence", apiKey: apiConfiguration.ioIntelligenceApiKey }, + }) + } + + // LiteLLM (optional) + const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey + const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl + if (litellmApiKey && litellmBaseUrl) { + allFetches.push({ + key: "litellm", + options: { provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, + }) + } + + const modelFetchPromises = activeProvider + ? allFetches.filter(({ key }) => key === activeProvider) + : allFetches + + // If nothing matched (edge case), still post empty structure for stability + if (modelFetchPromises.length === 0) { + await provider.postMessageToWebview({ type: "routerModels", routerModels }) + break + } + + const results = await Promise.allSettled( + modelFetchPromises.map(async ({ key, options }) => { + const models = await safeGetModels(options) + return { key, models } + }), + ) + + results.forEach((result, index) => { + const routerName = modelFetchPromises[index].key + if (result.status === "fulfilled") { + routerModels[routerName] = result.value.models + } else { + const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) + console.error(`Error fetching models for ${routerName}:`, result.reason) + routerModels[routerName] = {} + provider.postMessageToWebview({ + type: "singleRouterModelFetchResponse", + success: false, + error: errorMessage, + values: { provider: routerName }, + }) + } + }) + + provider.postMessageToWebview({ type: "routerModels", routerModels }) + break + } + case "requestRouterModelsAll": { + // Phase 3: Debounce to collapse rapid repeats + const now = Date.now() + if (now - lastRouterModelsAllRequestTime < ROUTER_MODELS_DEBOUNCE_MS) { + // Skip this request - too soon after last one + break + } + lastRouterModelsAllRequestTime = now + + // Settings and activation: fetch all providers (legacy behavior) + const { apiConfiguration } = await provider.getState() + + const routerModels: any = { + openrouter: {}, + "vercel-ai-gateway": {}, + huggingface: {}, + litellm: {}, + deepinfra: {}, + "io-intelligence": {}, + requesty: {}, + unbound: {}, + glama: {}, + ollama: {}, + lmstudio: {}, + roo: {}, + } + + const safeGetModels = async (options: GetModelsOptions): Promise => { + try { + return await getModels(options) + } catch (error) { + console.error( + `Failed to fetch models in webviewMessageHandler requestRouterModelsAll for ${options.provider}:`, + error, + ) + throw error } } @@ -807,20 +962,19 @@ export const webviewMessageHandler = async ( }, }, { - key: "roo", + key: "roo" as RouterName, options: { - provider: "roo", + provider: "roo" as any, baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", apiKey: CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined, - }, + } as GetModelsOptions, }, ] // Add IO Intelligence if API key is provided. const ioIntelligenceApiKey = apiConfiguration.ioIntelligenceApiKey - if (ioIntelligenceApiKey) { modelFetchPromises.push({ key: "io-intelligence", @@ -833,7 +987,6 @@ export const webviewMessageHandler = async ( const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl - if (litellmApiKey && litellmBaseUrl) { modelFetchPromises.push({ key: "litellm", @@ -844,7 +997,7 @@ export const webviewMessageHandler = async ( const results = await Promise.allSettled( modelFetchPromises.map(async ({ key, options }) => { const models = await safeGetModels(options) - return { key, models } // The key is `ProviderName` here. + return { key, models } }), ) @@ -871,7 +1024,7 @@ export const webviewMessageHandler = async ( const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) console.error(`Error fetching models for ${routerName}:`, result.reason) - routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message. + routerModels[routerName] = {} provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", @@ -884,6 +1037,7 @@ export const webviewMessageHandler = async ( provider.postMessageToWebview({ type: "routerModels", routerModels }) break + } case "requestOllamaModels": { // Specific handler for Ollama models only. const { apiConfiguration: ollamaApiConfig } = await provider.getState() @@ -934,15 +1088,15 @@ export const webviewMessageHandler = async ( // Specific handler for Roo models only - flushes cache to ensure fresh auth token is used try { // Flush cache first to ensure fresh models with current auth state - await flushModels("roo") + await flushModels("roo" as RouterName) const rooModels = await getModels({ - provider: "roo", + provider: "roo" as any, baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", apiKey: CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined, - }) + } as GetModelsOptions) // Always send a response, even if no models are returned provider.postMessageToWebview({ @@ -1016,10 +1170,11 @@ export const webviewMessageHandler = async ( } break case "checkpointDiff": - const result = checkoutDiffPayloadSchema.safeParse(message.payload) + const diffResult = checkoutDiffPayloadSchema.safeParse(message.payload) - if (result.success) { - await provider.getCurrentTask()?.checkpointDiff(result.data) + if (diffResult.success) { + // Cast to the correct CheckpointDiffOptions type (mode can be "from-init" | "checkpoint" | "to-current" | "full") + await provider.getCurrentTask()?.checkpointDiff(diffResult.data as any) } break @@ -1308,7 +1463,8 @@ export const webviewMessageHandler = async ( break case "checkpointTimeout": const checkpointTimeout = message.value ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS - await updateGlobalState("checkpointTimeout", checkpointTimeout) + // checkpointTimeout is in GlobalSettings but TypeScript inference has issues + await provider.contextProxy.setValue("checkpointTimeout" as any, checkpointTimeout) await provider.postStateToWebview() break case "browserViewportSize": @@ -1658,14 +1814,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("includeDiagnosticMessages", includeValue) await provider.postStateToWebview() break - case "includeCurrentTime": - await updateGlobalState("includeCurrentTime", message.bool ?? true) - await provider.postStateToWebview() - break - case "includeCurrentCost": - await updateGlobalState("includeCurrentCost", message.bool ?? true) - await provider.postStateToWebview() - break case "maxDiagnosticMessages": await updateGlobalState("maxDiagnosticMessages", message.value ?? 50) await provider.postStateToWebview() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5929e7a950..f8d0c5a817 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -294,8 +294,6 @@ export type ExtensionState = Pick< | "openRouterImageGenerationSelectedModel" | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" - | "includeCurrentTime" - | "includeCurrentCost" > & { version: string clineMessages: ClineMessage[] @@ -324,6 +322,9 @@ export type ExtensionState = Pick< mcpEnabled: boolean enableMcpServerCreation: boolean + includeCurrentTime?: boolean + includeCurrentCost?: boolean + mode: Mode customModes: ModeConfig[] toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9c47518628..6924bc9927 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -66,6 +66,7 @@ export interface WebviewMessage { | "resetState" | "flushRouterModels" | "requestRouterModels" + | "requestRouterModelsAll" | "requestOpenAiModels" | "requestOllamaModels" | "requestLmStudioModels"