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.
This commit is contained in:
daniel-lxs 2025-10-30 09:39:26 -05:00
parent 632bbe77db
commit c4ddbf65d0
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
18 changed files with 375 additions and 350 deletions

View file

@ -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<Record<string, ModelInfo>> {
const headers: Record<string, string> = { ...DEFAULT_HEADERS }
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
@ -42,7 +43,7 @@ export async function getDeepInfraModels(
const url = `${baseUrl.replace(/\/$/, "")}/models`
const models: Record<string, ModelInfo> = {}
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 || []

View file

@ -4,11 +4,11 @@ import type { ModelInfo } from "@roo-code/types"
import { parseApiPrice } from "../../../shared/cost"
export async function getGlamaModels(): Promise<Record<string, ModelInfo>> {
export async function getGlamaModels(signal?: AbortSignal): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
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) {

View file

@ -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<ModelRecord> {
export async function getHuggingFaceModels(signal?: AbortSignal): Promise<ModelRecord> {
const now = Date.now()
if (cache && now - cache.timestamp < HUGGINGFACE_CACHE_DURATION) {
@ -128,7 +128,7 @@ export async function getHuggingFaceModels(): Promise<ModelRecord> {
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
timeout: 10000,
signal,
})
const result = huggingFaceApiResponseSchema.safeParse(response.data)
@ -236,7 +236,7 @@ export async function getHuggingFaceModelsWithMetadata(): Promise<HuggingFaceMod
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
timeout: 10000,
signal: AbortSignal.timeout(30000),
})
const models = response.data?.data || []

View file

@ -83,7 +83,7 @@ function parseIOIntelligenceModel(model: IOIntelligenceModel): ModelInfo {
* Fetches available models from IO Intelligence
* <mcreference link="https://docs.io.net/reference/get-started-with-io-intelligence-api" index="1">1</mcreference>
*/
export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRecord> {
export async function getIOIntelligenceModels(apiKey?: string, signal?: AbortSignal): Promise<ModelRecord> {
const now = Date.now()
if (cache && now - cache.timestamp < IO_INTELLIGENCE_CACHE_DURATION) {
@ -108,7 +108,7 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRec
"https://api.intelligence.io.solutions/api/v1/models",
{
headers,
timeout: 10_000,
signal,
},
)

View file

@ -11,7 +11,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 getLiteLLMModels(apiKey: string, baseUrl: string): Promise<ModelRecord> {
export async function getLiteLLMModels(apiKey: string, baseUrl: string, signal?: AbortSignal): Promise<ModelRecord> {
try {
const headers: Record<string, string> = {
"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

View file

@ -49,7 +49,10 @@ export const parseLMStudioModel = (rawModel: LLMInstanceInfo | LLMInfo): ModelIn
return modelInfo
}
export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Promise<Record<string, ModelInfo>> {
export async function getLMStudioModels(
baseUrl = "http://localhost:1234",
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
// 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 })

View file

@ -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<RouterName, Promise<ModelRecord>>()
function withTimeout<T>(p: Promise<T>, ms: number, label = "getModels"): Promise<T> {
return new Promise<T>((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<ModelRecord>
// 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<ModelRecord>
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<ModelRecord> => {
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<ModelRecord>
// 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<ModelRecord> => {
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)
}

View file

@ -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<string, Promise<ModelRecord>>()
function withTimeout<T>(p: Promise<T>, ms: number, label = "getModelEndpoints"): Promise<T> {
return new Promise<T>((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<ModelRecord>(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<ModelRecord> => {
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<ModelRecord> => {
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)
}

View file

@ -56,6 +56,7 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo =
export async function getOllamaModels(
baseUrl = "http://localhost:11434",
apiKey?: string,
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
@ -73,7 +74,7 @@ export async function getOllamaModels(
headers["Authorization"] = `Bearer ${apiKey}`
}
const response = await axios.get<OllamaModelsResponse>(`${baseUrl}/api/tags`, { headers })
const response = await axios.get<OllamaModelsResponse>(`${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)

View file

@ -94,12 +94,15 @@ type OpenRouterModelEndpointsResponse = z.infer<typeof openRouterModelEndpointsR
* getOpenRouterModels
*/
export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise<Record<string, ModelInfo>> {
export async function getOpenRouterModels(
options?: ApiHandlerOptions,
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1"
try {
const response = await axios.get<OpenRouterModelsResponse>(`${baseURL}/models`)
const response = await axios.get<OpenRouterModelsResponse>(`${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<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1"
try {
const response = await axios.get<OpenRouterModelEndpointsResponse>(`${baseURL}/models/${modelId}/endpoints`)
const response = await axios.get<OpenRouterModelEndpointsResponse>(`${baseURL}/models/${modelId}/endpoints`, {
signal,
})
const result = openRouterModelEndpointsResponseSchema.safeParse(response.data)
const data = result.success ? result.data.data : response.data.data

View file

@ -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<Record<string, ModelInfo>> {
export async function getRequestyModels(
baseUrl?: string,
apiKey?: string,
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
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) {

View file

@ -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<ModelRecord> {
export async function getRooModels(baseUrl: string, apiKey?: string, signal?: AbortSignal): Promise<ModelRecord> {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@ -29,87 +29,79 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise<Mo
const normalizedBase = baseUrl.replace(/\/?v1\/?$/, "")
const url = `${normalizedBase}/v1/models`
// Use fetch with AbortController for better timeout handling
const controller = new AbortController()
const timeoutId = setTimeout(() => 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

View file

@ -2,7 +2,10 @@ import axios from "axios"
import type { ModelInfo } from "@roo-code/types"
export async function getUnboundModels(apiKey?: string | null): Promise<Record<string, ModelInfo>> {
export async function getUnboundModels(
apiKey?: string | null,
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
try {
@ -12,7 +15,7 @@ export async function getUnboundModels(apiKey?: string | null): Promise<Record<s
headers["Authorization"] = `Bearer ${apiKey}`
}
const response = await axios.get("https://api.getunbound.ai/models", { headers })
const response = await axios.get("https://api.getunbound.ai/models", { headers, signal })
if (response.data) {
const rawModels: Record<string, any> = response.data

View file

@ -52,12 +52,15 @@ type VercelAiGatewayModelsResponse = z.infer<typeof vercelAiGatewayModelsRespons
* getVercelAiGatewayModels
*/
export async function getVercelAiGatewayModels(options?: ApiHandlerOptions): Promise<Record<string, ModelInfo>> {
export async function getVercelAiGatewayModels(
options?: ApiHandlerOptions,
signal?: AbortSignal,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const baseURL = "https://ai-gateway.vercel.sh/v1"
try {
const response = await axios.get<VercelAiGatewayModelsResponse>(`${baseURL}/models`)
const response = await axios.get<VercelAiGatewayModelsResponse>(`${baseURL}/models`, { signal })
const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data)
const data = result.success ? result.data.data : response.data.data

View file

@ -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<Record<RouterName, ModelRecord>> = {
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<Record<RouterName, ModelRecord>> = {
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()

View file

@ -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:

View file

@ -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

View file

@ -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<RouterModels>((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 })