From c4ddbf65d057f105d99dd2f7b8f77a0fd03b1d33 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 30 Oct 2025 09:39:26 -0500 Subject: [PATCH] Propagate AbortSignal to all provider fetchers and use native timeout - Remove inline withTimeout helper in favor of AbortSignal.timeout() - Add optional AbortSignal parameter to all provider model fetchers: - openrouter, requesty, glama, unbound, litellm, ollama, lmstudio - deepinfra, io-intelligence, vercel-ai-gateway, huggingface, roo - Standardize timeout handling across modelCache and modelEndpointCache - Add useRouterModelsAll hook for settings UI to fetch all providers - Update Unbound and ApiOptions to use requestRouterModelsAll This ensures consistent cancellation behavior and prepares for better request lifecycle management across the codebase. --- src/api/providers/fetchers/deepinfra.ts | 3 +- src/api/providers/fetchers/glama.ts | 4 +- src/api/providers/fetchers/huggingface.ts | 6 +- src/api/providers/fetchers/io-intelligence.ts | 4 +- src/api/providers/fetchers/litellm.ts | 5 +- src/api/providers/fetchers/lmstudio.ts | 7 +- src/api/providers/fetchers/modelCache.ts | 266 +++++++++--------- .../providers/fetchers/modelEndpointCache.ts | 114 ++++---- src/api/providers/fetchers/ollama.ts | 5 +- src/api/providers/fetchers/openrouter.ts | 12 +- src/api/providers/fetchers/requesty.ts | 8 +- src/api/providers/fetchers/roo.ts | 142 +++++----- src/api/providers/fetchers/unbound.ts | 7 +- .../providers/fetchers/vercel-ai-gateway.ts | 7 +- src/core/webview/webviewMessageHandler.ts | 89 +++--- .../src/components/settings/ApiOptions.tsx | 4 +- .../components/settings/providers/Unbound.tsx | 4 +- .../components/ui/hooks/useRouterModelsAll.ts | 38 +++ 18 files changed, 375 insertions(+), 350 deletions(-) create mode 100644 webview-ui/src/components/ui/hooks/useRouterModelsAll.ts diff --git a/src/api/providers/fetchers/deepinfra.ts b/src/api/providers/fetchers/deepinfra.ts index f38daff822..5d817cbb7f 100644 --- a/src/api/providers/fetchers/deepinfra.ts +++ b/src/api/providers/fetchers/deepinfra.ts @@ -35,6 +35,7 @@ const DeepInfraModelsResponseSchema = z.object({ data: z.array(DeepInfraModelSch export async function getDeepInfraModels( apiKey?: string, baseUrl: string = "https://api.deepinfra.com/v1/openai", + signal?: AbortSignal, ): Promise> { const headers: Record = { ...DEFAULT_HEADERS } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}` @@ -42,7 +43,7 @@ export async function getDeepInfraModels( const url = `${baseUrl.replace(/\/$/, "")}/models` const models: Record = {} - const response = await axios.get(url, { headers }) + const response = await axios.get(url, { headers, signal }) const parsed = DeepInfraModelsResponseSchema.safeParse(response.data) const data = parsed.success ? parsed.data.data : response.data?.data || [] diff --git a/src/api/providers/fetchers/glama.ts b/src/api/providers/fetchers/glama.ts index ae36c751fb..f451cd9348 100644 --- a/src/api/providers/fetchers/glama.ts +++ b/src/api/providers/fetchers/glama.ts @@ -4,11 +4,11 @@ import type { ModelInfo } from "@roo-code/types" import { parseApiPrice } from "../../../shared/cost" -export async function getGlamaModels(): Promise> { +export async function getGlamaModels(signal?: AbortSignal): Promise> { const models: Record = {} try { - const response = await axios.get("https://glama.ai/api/gateway/v1/models") + const response = await axios.get("https://glama.ai/api/gateway/v1/models", { signal }) const rawModels = response.data for (const rawModel of rawModels) { diff --git a/src/api/providers/fetchers/huggingface.ts b/src/api/providers/fetchers/huggingface.ts index 1a7a995bc6..b6b1cd28e6 100644 --- a/src/api/providers/fetchers/huggingface.ts +++ b/src/api/providers/fetchers/huggingface.ts @@ -107,7 +107,7 @@ function parseHuggingFaceModel(model: HuggingFaceModel, provider?: HuggingFacePr * @returns A promise that resolves to a record of model IDs to model info * @throws Will throw an error if the request fails */ -export async function getHuggingFaceModels(): Promise { +export async function getHuggingFaceModels(signal?: AbortSignal): Promise { const now = Date.now() if (cache && now - cache.timestamp < HUGGINGFACE_CACHE_DURATION) { @@ -128,7 +128,7 @@ export async function getHuggingFaceModels(): Promise { Pragma: "no-cache", "Cache-Control": "no-cache", }, - timeout: 10000, + signal, }) const result = huggingFaceApiResponseSchema.safeParse(response.data) @@ -236,7 +236,7 @@ export async function getHuggingFaceModelsWithMetadata(): Promise1 */ -export async function getIOIntelligenceModels(apiKey?: string): Promise { +export async function getIOIntelligenceModels(apiKey?: string, signal?: AbortSignal): Promise { const now = Date.now() if (cache && now - cache.timestamp < IO_INTELLIGENCE_CACHE_DURATION) { @@ -108,7 +108,7 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise { +export async function getLiteLLMModels(apiKey: string, baseUrl: string, signal?: AbortSignal): Promise { try { const headers: Record = { "Content-Type": "application/json", @@ -27,8 +27,7 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise // Normalize the pathname by removing trailing slashes and multiple slashes urlObj.pathname = urlObj.pathname.replace(/\/+$/, "").replace(/\/+/g, "/") + "/v1/model/info" const url = urlObj.href - // Added timeout to prevent indefinite hanging - const response = await axios.get(url, { headers, timeout: 5000 }) + const response = await axios.get(url, { headers, signal }) const models: ModelRecord = {} // Process the model info from the response diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index de3f804c28..f7253f9380 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -49,7 +49,10 @@ export const parseLMStudioModel = (rawModel: LLMInstanceInfo | LLMInfo): ModelIn return modelInfo } -export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Promise> { +export async function getLMStudioModels( + baseUrl = "http://localhost:1234", + signal?: AbortSignal, +): Promise> { // clear the set of models that have full details loaded modelsWithLoadedDetails.clear() // clearing the input can leave an empty string; use the default in that case @@ -66,7 +69,7 @@ export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Prom // test the connection to LM Studio first // errors will be caught further down - await axios.get(`${baseUrl}/v1/models`) + await axios.get(`${baseUrl}/v1/models`, { signal }) const client = new LMStudioClient({ baseUrl: lmsUrl }) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 759a240f0b..bcf5554102 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -31,19 +31,6 @@ 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) @@ -76,6 +63,7 @@ export const getModels = async (options: GetModelsOptions): Promise // 1) Try memory cache const cached = getModelsFromCache(provider) if (cached) { + // Using console.log for cache layer logging (no provider access in utility functions) console.log(`[modelCache] cache_hit: ${providerStr} (${Object.keys(cached).length} models)`) return cached } @@ -84,78 +72,83 @@ export const getModels = async (options: GetModelsOptions): Promise try { const file = await readModels(provider) if (file && Object.keys(file).length > 0) { + // Using console.log for cache layer logging (no provider access in utility functions) 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 signal = AbortSignal.timeout(30_000) 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}`) + switch (providerStr) { + case "openrouter": + models = await getOpenRouterModels(undefined, signal) + break + case "requesty": + models = await getRequestyModels(options.baseUrl, options.apiKey, signal) + break + case "glama": + models = await getGlamaModels(signal) + break + case "unbound": + models = await getUnboundModels(options.apiKey, signal) + break + case "litellm": + models = await getLiteLLMModels(options.apiKey as string, options.baseUrl as string, signal) + break + case "ollama": + models = await getOllamaModels(options.baseUrl, options.apiKey, signal) + break + case "lmstudio": + models = await getLMStudioModels(options.baseUrl, signal) + break + case "deepinfra": + models = await getDeepInfraModels(options.apiKey, options.baseUrl, signal) + break + case "io-intelligence": + models = await getIOIntelligenceModels(options.apiKey, signal) + break + case "vercel-ai-gateway": + models = await getVercelAiGatewayModels(undefined, signal) + break + case "huggingface": + models = await getHuggingFaceModels(signal) + break + case "roo": { + const rooBaseUrl = + options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + models = await getRooModels(rooBaseUrl, options.apiKey, signal) + break } - - 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 + 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 during background refresh:`, + err instanceof Error ? err.message : String(err), + ) + }) + return models || {} })() - const timedBg = withTimeout(bgPromise, 30_000, `getModels(background:${providerStr})`) - inFlightModelFetches.set(provider, timedBg) - Promise.resolve(timedBg).finally(() => inFlightModelFetches.delete(provider)) + inFlightModelFetches.set(provider, bgPromise) + Promise.resolve(bgPromise) + .catch((err) => { + // Log background refresh failures for monitoring + console.error( + `[modelCache] Background refresh failed for ${providerStr}:`, + err instanceof Error ? err.message : String(err), + ) + }) + .finally(() => inFlightModelFetches.delete(provider)) } // Return the file snapshot immediately @@ -168,82 +161,81 @@ export const getModels = async (options: GetModelsOptions): Promise // 3) Coalesce concurrent fetches const existing = inFlightModelFetches.get(provider) if (existing) { + // Using console.log for cache layer logging (no provider access in utility functions) console.log(`[modelCache] coalesced_wait: ${providerStr}`) return existing } // 4) Network fetch wrapped as a single in-flight promise for this provider + const signal = AbortSignal.timeout(30_000) const fetchPromise = (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}`) - } + switch (providerStr) { + case "openrouter": + models = await getOpenRouterModels(undefined, signal) + break + case "requesty": + models = await getRequestyModels(options.baseUrl, options.apiKey, signal) + break + case "glama": + models = await getGlamaModels(signal) + break + case "unbound": + models = await getUnboundModels(options.apiKey, signal) + break + case "litellm": + models = await getLiteLLMModels(options.apiKey as string, options.baseUrl as string, signal) + break + case "ollama": + models = await getOllamaModels(options.baseUrl, options.apiKey, signal) + break + case "lmstudio": + models = await getLMStudioModels(options.baseUrl, signal) + break + case "deepinfra": + models = await getDeepInfraModels(options.apiKey, options.baseUrl, signal) + break + case "io-intelligence": + models = await getIOIntelligenceModels(options.apiKey, signal) + break + case "vercel-ai-gateway": + models = await getVercelAiGatewayModels(undefined, signal) + break + case "huggingface": + models = await getHuggingFaceModels(signal) + break + case "roo": { + const rooBaseUrl = + options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + models = await getRooModels(rooBaseUrl, options.apiKey, signal) + break + } + default: { + throw new Error(`Unknown provider: ${providerStr}`) } - - 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 } + + 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 after network fetch:`, + err instanceof Error ? err.message : String(err), + ) + }) + + // Return models as-is (skip immediate re-read) + return models || {} })() - // Register and await with timeout; ensure cleanup - const timed = withTimeout(fetchPromise, 30_000, `getModels(${providerStr})`) - inFlightModelFetches.set(provider, timed) + // Register and await; ensure cleanup + inFlightModelFetches.set(provider, fetchPromise) try { - return await timed + return await fetchPromise } finally { inFlightModelFetches.delete(provider) } diff --git a/src/api/providers/fetchers/modelEndpointCache.ts b/src/api/providers/fetchers/modelEndpointCache.ts index c2006ff07e..9a9018e4d9 100644 --- a/src/api/providers/fetchers/modelEndpointCache.ts +++ b/src/api/providers/fetchers/modelEndpointCache.ts @@ -17,19 +17,6 @@ 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) { @@ -66,6 +53,7 @@ export const getModelEndpoints = async ({ // 1) Try memory cache const cached = memoryCache.get(key) if (cached) { + // Using console.log for cache layer logging (no provider access in utility functions) console.log(`[endpointCache] cache_hit: ${key} (${Object.keys(cached).length} endpoints)`) return cached } @@ -74,37 +62,44 @@ export const getModelEndpoints = async ({ try { const file = await readModelEndpoints(key) if (file && Object.keys(file).length > 0) { + // Using console.log for cache layer logging (no provider access in utility functions) 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 signal = AbortSignal.timeout(30_000) 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)`, + const modelProviders = await getOpenRouterModelEndpoints(modelId, undefined, signal) + 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 during background refresh:`, + error instanceof Error ? error.message : String(error), ) - 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 + return modelProviders } + return {} })() - const timedBg = withTimeout(bgPromise, 30_000, `getModelEndpoints(background:${key})`) - inFlightEndpointFetches.set(key, timedBg) - Promise.resolve(timedBg).finally(() => inFlightEndpointFetches.delete(key)) + inFlightEndpointFetches.set(key, bgPromise) + Promise.resolve(bgPromise) + .catch((err) => { + // Log background refresh failures for monitoring + console.error( + `[endpointCache] Background refresh failed for ${key}:`, + err instanceof Error ? err.message : String(err), + ) + }) + .finally(() => inFlightEndpointFetches.delete(key)) } return file @@ -116,50 +111,47 @@ export const getModelEndpoints = async ({ // 3) Coalesce concurrent fetches const inFlight = inFlightEndpointFetches.get(key) if (inFlight) { + // Using console.log for cache layer logging (no provider access in utility functions) console.log(`[endpointCache] coalesced_wait: ${key}`) return inFlight } // 4) Single network fetch for this key + const signal = AbortSignal.timeout(30_000) const fetchPromise = (async (): Promise => { let modelProviders: ModelRecord = {} - try { - modelProviders = await getOpenRouterModelEndpoints(modelId) + modelProviders = await getOpenRouterModelEndpoints(modelId, undefined, signal) - 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) + 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) + // Best-effort persist try { - const file = await readModelEndpoints(key) - return file ?? {} - } catch { - return {} + await writeModelEndpoints(key, modelProviders) + } catch (error) { + console.error( + `[endpointCache] Error writing ${key} to file cache after network fetch:`, + error instanceof Error ? error.message : String(error), + ) } - } catch (error) { - console.error(`[endpointCache] network_fetch_failed: ${key}`, error) - throw error + + return modelProviders + } + + // Fallback to file cache if network returned empty (rare) + try { + const file = await readModelEndpoints(key) + return file ?? {} + } catch { + return {} } })() - const timed = withTimeout(fetchPromise, 30_000, `getModelEndpoints(${key})`) - inFlightEndpointFetches.set(key, timed) + inFlightEndpointFetches.set(key, fetchPromise) try { - return await timed + return await fetchPromise } finally { inFlightEndpointFetches.delete(key) } diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 4bf43b6faf..bfb75b6b5e 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -56,6 +56,7 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo = export async function getOllamaModels( baseUrl = "http://localhost:11434", apiKey?: string, + signal?: AbortSignal, ): Promise> { const models: Record = {} @@ -73,7 +74,7 @@ export async function getOllamaModels( headers["Authorization"] = `Bearer ${apiKey}` } - const response = await axios.get(`${baseUrl}/api/tags`, { headers }) + const response = await axios.get(`${baseUrl}/api/tags`, { headers, signal }) const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data) let modelInfoPromises = [] @@ -86,7 +87,7 @@ export async function getOllamaModels( { model: ollamaModel.model, }, - { headers }, + { headers, signal }, ) .then((ollamaModelInfo) => { models[ollamaModel.name] = parseOllamaModel(ollamaModelInfo.data) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index b546c40a3c..8d8659b3f1 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -94,12 +94,15 @@ type OpenRouterModelEndpointsResponse = z.infer> { +export async function getOpenRouterModels( + options?: ApiHandlerOptions, + signal?: AbortSignal, +): Promise> { const models: Record = {} const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1" try { - const response = await axios.get(`${baseURL}/models`) + const response = await axios.get(`${baseURL}/models`, { signal }) const result = openRouterModelsResponseSchema.safeParse(response.data) const data = result.success ? result.data.data : response.data.data @@ -140,12 +143,15 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< export async function getOpenRouterModelEndpoints( modelId: string, options?: ApiHandlerOptions, + signal?: AbortSignal, ): Promise> { const models: Record = {} const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1" try { - const response = await axios.get(`${baseURL}/models/${modelId}/endpoints`) + const response = await axios.get(`${baseURL}/models/${modelId}/endpoints`, { + signal, + }) const result = openRouterModelEndpointsResponseSchema.safeParse(response.data) const data = result.success ? result.data.data : response.data.data diff --git a/src/api/providers/fetchers/requesty.ts b/src/api/providers/fetchers/requesty.ts index 64c7de6689..1fe0f15857 100644 --- a/src/api/providers/fetchers/requesty.ts +++ b/src/api/providers/fetchers/requesty.ts @@ -5,7 +5,11 @@ import type { ModelInfo } from "@roo-code/types" import { parseApiPrice } from "../../../shared/cost" import { toRequestyServiceUrl } from "../../../shared/utils/requesty" -export async function getRequestyModels(baseUrl?: string, apiKey?: string): Promise> { +export async function getRequestyModels( + baseUrl?: string, + apiKey?: string, + signal?: AbortSignal, +): Promise> { const models: Record = {} try { @@ -18,7 +22,7 @@ export async function getRequestyModels(baseUrl?: string, apiKey?: string): Prom const resolvedBaseUrl = toRequestyServiceUrl(baseUrl) const modelsUrl = new URL("v1/models", resolvedBaseUrl) - const response = await axios.get(modelsUrl.toString(), { headers }) + const response = await axios.get(modelsUrl.toString(), { headers, signal }) const rawModels = response.data.data for (const rawModel of rawModels) { diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 17aec4253b..0dca037680 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -13,7 +13,7 @@ import { DEFAULT_HEADERS } from "../constants" * @returns A promise that resolves to a record of model IDs to model info * @throws Will throw an error if the request fails or the response is not as expected. */ -export async function getRooModels(baseUrl: string, apiKey?: string): Promise { +export async function getRooModels(baseUrl: string, apiKey?: string, signal?: AbortSignal): Promise { try { const headers: Record = { "Content-Type": "application/json", @@ -29,87 +29,79 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise controller.abort(), 10000) + const response = await fetch(url, { + headers, + signal, + }) - try { - const response = await fetch(url, { - headers, - signal: controller.signal, - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - const data = await response.json() - const models: ModelRecord = {} - - // Validate response against schema - const parsed = RooModelsResponseSchema.safeParse(data) - - if (!parsed.success) { - console.error("Error fetching Roo Code Cloud models: Unexpected response format", data) - console.error("Validation errors:", parsed.error.format()) - throw new Error("Failed to fetch Roo Code Cloud models: Unexpected response format.") - } - - // Process the validated model data - for (const model of parsed.data.data) { - const modelId = model.id - - if (!modelId) continue - - // Extract model data from the validated API response - // All required fields are guaranteed by the schema - const contextWindow = model.context_window - const maxTokens = model.max_tokens - const tags = model.tags || [] - const pricing = model.pricing - - // Determine if the model supports images based on tags - const supportsImages = tags.includes("vision") - - // Determine if the model supports reasoning effort based on tags - const supportsReasoningEffort = tags.includes("reasoning") - - // Determine if the model requires reasoning effort based on tags - const requiredReasoningEffort = tags.includes("reasoning-required") - - // Parse pricing (API returns strings, convert to numbers) - const inputPrice = parseApiPrice(pricing.input) - const outputPrice = parseApiPrice(pricing.output) - const cacheReadPrice = pricing.input_cache_read ? parseApiPrice(pricing.input_cache_read) : undefined - const cacheWritePrice = pricing.input_cache_write ? parseApiPrice(pricing.input_cache_write) : undefined - - models[modelId] = { - maxTokens, - contextWindow, - supportsImages, - supportsReasoningEffort, - requiredReasoningEffort, - supportsPromptCache: Boolean(cacheReadPrice !== undefined), - inputPrice, - outputPrice, - cacheWritesPrice: cacheWritePrice, - cacheReadsPrice: cacheReadPrice, - description: model.description || model.name, - deprecated: model.deprecated || false, - isFree: tags.includes("free"), - } - } - - return models - } finally { - clearTimeout(timeoutId) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) } + + const data = await response.json() + const models: ModelRecord = {} + + // Validate response against schema + const parsed = RooModelsResponseSchema.safeParse(data) + + if (!parsed.success) { + console.error("Error fetching Roo Code Cloud models: Unexpected response format", data) + console.error("Validation errors:", parsed.error.format()) + throw new Error("Failed to fetch Roo Code Cloud models: Unexpected response format.") + } + + // Process the validated model data + for (const model of parsed.data.data) { + const modelId = model.id + + if (!modelId) continue + + // Extract model data from the validated API response + // All required fields are guaranteed by the schema + const contextWindow = model.context_window + const maxTokens = model.max_tokens + const tags = model.tags || [] + const pricing = model.pricing + + // Determine if the model supports images based on tags + const supportsImages = tags.includes("vision") + + // Determine if the model supports reasoning effort based on tags + const supportsReasoningEffort = tags.includes("reasoning") + + // Determine if the model requires reasoning effort based on tags + const requiredReasoningEffort = tags.includes("reasoning-required") + + // Parse pricing (API returns strings, convert to numbers) + const inputPrice = parseApiPrice(pricing.input) + const outputPrice = parseApiPrice(pricing.output) + const cacheReadPrice = pricing.input_cache_read ? parseApiPrice(pricing.input_cache_read) : undefined + const cacheWritePrice = pricing.input_cache_write ? parseApiPrice(pricing.input_cache_write) : undefined + + models[modelId] = { + maxTokens, + contextWindow, + supportsImages, + supportsReasoningEffort, + requiredReasoningEffort, + supportsPromptCache: Boolean(cacheReadPrice !== undefined), + inputPrice, + outputPrice, + cacheWritesPrice: cacheWritePrice, + cacheReadsPrice: cacheReadPrice, + description: model.description || model.name, + deprecated: model.deprecated || false, + isFree: tags.includes("free"), + } + } + + return models } catch (error: any) { console.error("Error fetching Roo Code Cloud models:", error.message ? error.message : error) // Handle abort/timeout if (error.name === "AbortError") { - throw new Error("Failed to fetch Roo Code Cloud models: Request timed out after 10 seconds.") + throw new Error("Failed to fetch Roo Code Cloud models: Request timed out.") } // Handle fetch errors diff --git a/src/api/providers/fetchers/unbound.ts b/src/api/providers/fetchers/unbound.ts index 354c0fde58..a339a4da19 100644 --- a/src/api/providers/fetchers/unbound.ts +++ b/src/api/providers/fetchers/unbound.ts @@ -2,7 +2,10 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" -export async function getUnboundModels(apiKey?: string | null): Promise> { +export async function getUnboundModels( + apiKey?: string | null, + signal?: AbortSignal, +): Promise> { const models: Record = {} try { @@ -12,7 +15,7 @@ export async function getUnboundModels(apiKey?: string | null): Promise = response.data diff --git a/src/api/providers/fetchers/vercel-ai-gateway.ts b/src/api/providers/fetchers/vercel-ai-gateway.ts index 3b6852c28d..2e2514fffc 100644 --- a/src/api/providers/fetchers/vercel-ai-gateway.ts +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -52,12 +52,15 @@ type VercelAiGatewayModelsResponse = z.infer> { +export async function getVercelAiGatewayModels( + options?: ApiHandlerOptions, + signal?: AbortSignal, +): Promise> { const models: Record = {} const baseURL = "https://ai-gateway.vercel.sh/v1" try { - const response = await axios.get(`${baseURL}/models`) + const response = await axios.get(`${baseURL}/models`, { signal }) const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data) const data = result.success ? result.data.data : response.data.data diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 278161ba2a..a3ef6ff49d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -26,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, isRouterName, toRouterName } from "../../shared/api" +import { type RouterName, type ModelRecord, type RouterModels, isRouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" import { @@ -60,11 +60,6 @@ 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" @@ -771,21 +766,13 @@ export const webviewMessageHandler = async ( await flushModels(routerNameFlush) break 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 - // 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 = { + const routerModels: Partial> = { openrouter: {}, "vercel-ai-gateway": {}, huggingface: {}, @@ -804,9 +791,8 @@ export const webviewMessageHandler = async ( try { return await getModels(options) } catch (error) { - console.error( - `Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}:`, - error, + provider.log( + `Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}: ${error instanceof Error ? error.message : String(error)}`, ) throw error } @@ -835,14 +821,14 @@ export const webviewMessageHandler = async ( }, }, { - key: "roo" as RouterName, + key: "roo", options: { - provider: "roo" as any, + provider: "roo", baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", apiKey: CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined, - } as GetModelsOptions, + }, }, ] @@ -870,7 +856,10 @@ export const webviewMessageHandler = async ( // If nothing matched (edge case), still post empty structure for stability if (modelFetchPromises.length === 0) { - await provider.postMessageToWebview({ type: "routerModels", routerModels }) + await provider.postMessageToWebview({ + type: "routerModels", + routerModels: routerModels as RouterModels, + }) break } @@ -887,7 +876,7 @@ export const webviewMessageHandler = async ( 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) + provider.log(`Error fetching models for ${routerName}: ${errorMessage}`) routerModels[routerName] = {} provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", @@ -898,22 +887,14 @@ export const webviewMessageHandler = async ( } }) - provider.postMessageToWebview({ type: "routerModels", routerModels }) + provider.postMessageToWebview({ type: "routerModels", routerModels: routerModels as 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 = { + const routerModels: Partial> = { openrouter: {}, "vercel-ai-gateway": {}, huggingface: {}, @@ -932,9 +913,8 @@ export const webviewMessageHandler = async ( try { return await getModels(options) } catch (error) { - console.error( - `Failed to fetch models in webviewMessageHandler requestRouterModelsAll for ${options.provider}:`, - error, + provider.log( + `Failed to fetch models in webviewMessageHandler requestRouterModelsAll for ${options.provider}: ${error instanceof Error ? error.message : String(error)}`, ) throw error } @@ -962,14 +942,14 @@ export const webviewMessageHandler = async ( }, }, { - key: "roo" as RouterName, + key: "roo", options: { - provider: "roo" as any, + provider: "roo", baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", apiKey: CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined, - } as GetModelsOptions, + }, }, ] @@ -1022,7 +1002,7 @@ export const webviewMessageHandler = async ( } else { // Handle rejection: Post a specific error message for this provider. const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) - console.error(`Error fetching models for ${routerName}:`, result.reason) + provider.log(`Error fetching models for ${routerName}: ${errorMessage}`) routerModels[routerName] = {} @@ -1035,7 +1015,7 @@ export const webviewMessageHandler = async ( } }) - provider.postMessageToWebview({ type: "routerModels", routerModels }) + provider.postMessageToWebview({ type: "routerModels", routerModels: routerModels as RouterModels }) break } case "requestOllamaModels": { @@ -1055,7 +1035,8 @@ export const webviewMessageHandler = async ( provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels }) } } catch (error) { - // Silently fail - user hasn't configured Ollama yet + // Silently fail - user hasn't configured Ollama yet (debug level only) + // Using console.debug since this is expected when Ollama isn't configured console.debug("Ollama models fetch failed:", error) } break @@ -1079,7 +1060,8 @@ export const webviewMessageHandler = async ( }) } } catch (error) { - // Silently fail - user hasn't configured LM Studio yet. + // Silently fail - user hasn't configured LM Studio yet (debug level only) + // Using console.debug since this is expected when LM Studio isn't configured console.debug("LM Studio models fetch failed:", error) } break @@ -1088,15 +1070,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" as RouterName) + await flushModels("roo") const rooModels = await getModels({ - provider: "roo" as any, + provider: "roo", 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({ @@ -1144,7 +1126,9 @@ export const webviewMessageHandler = async ( huggingFaceModels: huggingFaceModelsResponse.models, }) } catch (error) { - console.error("Failed to fetch Hugging Face models:", error) + provider.log( + `Failed to fetch Hugging Face models: ${error instanceof Error ? error.message : String(error)}`, + ) provider.postMessageToWebview({ type: "huggingFaceModels", huggingFaceModels: [] }) } break @@ -1463,8 +1447,7 @@ export const webviewMessageHandler = async ( break case "checkpointTimeout": const checkpointTimeout = message.value ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS - // checkpointTimeout is in GlobalSettings but TypeScript inference has issues - await provider.contextProxy.setValue("checkpointTimeout" as any, checkpointTimeout) + await provider.contextProxy.setValue("checkpointTimeout", checkpointTimeout) await provider.postStateToWebview() break case "browserViewportSize": @@ -1849,6 +1832,14 @@ export const webviewMessageHandler = async ( await updateGlobalState("includeTaskHistoryInEnhance", message.bool ?? true) 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 "condensingApiConfigId": await updateGlobalState("condensingApiConfigId", message.text) await provider.postStateToWebview() diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 9e4d585c97..7e958b52ec 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -42,7 +42,7 @@ import { import { vscode } from "@src/utils/vscode" import { validateApiConfigurationExcludingModelErrors, getModelValidationError } from "@src/utils/validate" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" +import { useRouterModelsAll } from "@src/components/ui/hooks/useRouterModelsAll" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { useExtensionState } from "@src/context/ExtensionStateContext" import { @@ -188,7 +188,7 @@ const ApiOptions = ({ info: selectedModelInfo, } = useSelectedModel(apiConfiguration) - const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() + const { data: routerModels, refetch: refetchRouterModels } = useRouterModelsAll() const { data: openRouterModelProviders } = useOpenRouterModelProviders(apiConfiguration?.openRouterModelId, { enabled: diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index 15826d0c0b..de3d906dda 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -100,11 +100,11 @@ export const Unbound = ({ window.addEventListener("message", messageHandler) }) - vscode.postMessage({ type: "requestRouterModels" }) + vscode.postMessage({ type: "requestRouterModelsAll" }) await modelsPromise - await queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + await queryClient.invalidateQueries({ queryKey: ["routerModelsAll"] }) // After refreshing models, check if current model is in the updated list // If not, select the first available model diff --git a/webview-ui/src/components/ui/hooks/useRouterModelsAll.ts b/webview-ui/src/components/ui/hooks/useRouterModelsAll.ts new file mode 100644 index 0000000000..e3d38a4df0 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useRouterModelsAll.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query" + +import { RouterModels } from "@roo/api" +import { ExtensionMessage } from "@roo/ExtensionMessage" + +import { vscode } from "@src/utils/vscode" + +const getRouterModelsAll = async () => + new Promise((resolve, reject) => { + const cleanup = () => { + window.removeEventListener("message", handler) + } + + const timeout = setTimeout(() => { + cleanup() + reject(new Error("Router models (all) request timed out")) + }, 10000) + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === "routerModels") { + clearTimeout(timeout) + cleanup() + + if (message.routerModels) { + resolve(message.routerModels) + } else { + reject(new Error("No router models in response")) + } + } + } + + window.addEventListener("message", handler) + vscode.postMessage({ type: "requestRouterModelsAll" }) + }) + +export const useRouterModelsAll = () => useQuery({ queryKey: ["routerModelsAll"], queryFn: getRouterModelsAll })