This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathmodelCache.ts
More file actions
365 lines (320 loc) · 12.6 KB
/
Copy pathmodelCache.ts
File metadata and controls
365 lines (320 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import * as path from "path"
import fs from "fs/promises"
import * as fsSync from "fs"
import NodeCache from "node-cache"
import { z } from "zod"
import type { ProviderName, ModelRecord } from "@roo-code/types"
import { modelInfoSchema, TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { safeWriteJson } from "../../../utils/safeWriteJson"
import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../utils/storage"
import type { RouterName } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"
import { getOpenRouterModels } from "./openrouter"
import { getVercelAiGatewayModels } from "./vercel-ai-gateway"
import { getRequestyModels } from "./requesty"
import { getUnboundModels } from "./unbound"
import { getLiteLLMModels } from "./litellm"
import { GetModelsOptions } from "../../../shared/api"
import { getOllamaModels } from "./ollama"
import { getLMStudioModels } from "./lmstudio"
import { getIOIntelligenceModels } from "./io-intelligence"
import { getDeepInfraModels } from "./deepinfra"
import { getHuggingFaceModels } from "./huggingface"
import { getRooModels } from "./roo"
import { getChutesModels } from "./chutes"
import { getKeywordsAiModels } from "./keywordsai"
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
// Zod schema for validating ModelRecord structure from disk cache
const modelRecordSchema = z.record(z.string(), modelInfoSchema)
// Track in-flight refresh requests to prevent concurrent API calls for the same provider
// This prevents race conditions where multiple calls might overwrite each other's results
const inFlightRefresh = new Map<RouterName, Promise<ModelRecord>>()
async function writeModels(router: RouterName, data: ModelRecord) {
const filename = `${router}_models.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
await safeWriteJson(path.join(cacheDir, filename), data)
}
async function readModels(router: RouterName): Promise<ModelRecord | undefined> {
const filename = `${router}_models.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
const filePath = path.join(cacheDir, filename)
const exists = await fileExistsAtPath(filePath)
return exists ? JSON.parse(await fs.readFile(filePath, "utf8")) : undefined
}
/**
* Fetch models from the provider API.
* Extracted to avoid duplication between getModels() and refreshModels().
*
* @param options - Provider options for fetching models
* @returns Fresh models from the provider API
*/
async function fetchModelsFromProvider(options: GetModelsOptions): Promise<ModelRecord> {
const { provider } = options
let models: ModelRecord
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 "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
}
case "chutes":
models = await getChutesModels(options.apiKey)
break
case "keywordsai":
models = await getKeywordsAiModels(options.baseUrl)
break
default: {
// Ensures router is exhaustively checked if RouterName is a strict union.
const exhaustiveCheck: never = provider
throw new Error(`Unknown provider: ${exhaustiveCheck}`)
}
}
return models
}
/**
* Get models from the cache or fetch them from the provider and cache them.
* There are two caches:
* 1. Memory cache - This is a simple in-memory cache that is used to store models for a short period of time.
* 2. File cache - This is a file-based cache that is used to store models for a longer period of time.
*
* @param router - The router to fetch models from.
* @param apiKey - Optional API key for the provider.
* @param baseUrl - Optional base URL for the provider (currently used only for LiteLLM).
* @returns The models from the cache or the fetched models.
*/
export const getModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
const { provider } = options
let models = getModelsFromCache(provider)
if (models) {
return models
}
try {
models = await fetchModelsFromProvider(options)
const modelCount = Object.keys(models).length
// Only cache non-empty results to prevent persisting failed API responses
// Empty results could indicate API failure rather than "no models exist"
if (modelCount > 0) {
memoryCache.set(provider, models)
await writeModels(provider, models).catch((err) =>
console.error(`[MODEL_CACHE] Error writing ${provider} models to file cache:`, err),
)
} else {
TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, {
provider,
context: "getModels",
hasExistingCache: false,
})
}
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)
throw error // Re-throw the original error to be handled by the caller.
}
}
/**
* Force-refresh models from API, bypassing cache.
* Uses atomic writes so cache remains available during refresh.
* This function also prevents concurrent API calls for the same provider using
* in-flight request tracking to avoid race conditions.
*
* @param options - Provider options for fetching models
* @returns Fresh models from API, or existing cache if refresh yields worse data
*/
export const refreshModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
const { provider } = options
// Check if there's already an in-flight refresh for this provider
// This prevents race conditions where multiple concurrent refreshes might
// overwrite each other's results
const existingRequest = inFlightRefresh.get(provider)
if (existingRequest) {
return existingRequest
}
// Create the refresh promise and track it
const refreshPromise = (async (): Promise<ModelRecord> => {
try {
// Force fresh API fetch - skip getModelsFromCache() check
const models = await fetchModelsFromProvider(options)
const modelCount = Object.keys(models).length
// Get existing cached data for comparison
const existingCache = getModelsFromCache(provider)
const existingCount = existingCache ? Object.keys(existingCache).length : 0
if (modelCount === 0) {
TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, {
provider,
context: "refreshModels",
hasExistingCache: existingCount > 0,
existingCacheSize: existingCount,
})
if (existingCount > 0) {
return existingCache!
} else {
return {}
}
}
// Update memory cache first
memoryCache.set(provider, models)
// Atomically write to disk (safeWriteJson handles atomic writes)
await writeModels(provider, models).catch((err) =>
console.error(`[refreshModels] Error writing ${provider} models to disk:`, err),
)
return models
} catch (error) {
// Log the error for debugging, then return existing cache if available (graceful degradation)
console.error(`[refreshModels] Failed to refresh ${provider} models:`, error)
return getModelsFromCache(provider) || {}
} finally {
// Always clean up the in-flight tracking
inFlightRefresh.delete(provider)
}
})()
// Track the in-flight request
inFlightRefresh.set(provider, refreshPromise)
return refreshPromise
}
/**
* Initialize background model cache refresh.
* Refreshes public provider caches without blocking or requiring auth.
* Should be called once during extension activation.
*/
export async function initializeModelCacheRefresh(): Promise<void> {
// Wait for extension to fully activate before refreshing
setTimeout(async () => {
// Providers that work without API keys
const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [
{ provider: "openrouter", options: { provider: "openrouter" } },
{ provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
{ provider: "chutes", options: { provider: "chutes" } },
{
provider: "keywordsai",
options: { provider: "keywordsai", baseUrl: "https://api.keywordsai.co/api/" },
},
]
// Refresh each provider in background (fire and forget)
for (const { options } of publicProviders) {
refreshModels(options).catch(() => {
// Silent fail - old cache remains available
})
// Small delay between refreshes to avoid API rate limits
await new Promise((resolve) => setTimeout(resolve, 500))
}
}, 2000)
}
/**
* Flush models memory cache for a specific router.
*
* @param options - The options for fetching models, including provider, apiKey, and baseUrl
* @param refresh - If true, immediately fetch fresh data from API
*/
export const flushModels = async (options: GetModelsOptions, refresh: boolean = false): Promise<void> => {
const { provider } = options
if (refresh) {
// Don't delete memory cache - let refreshModels atomically replace it
// This prevents a race condition where getModels() might be called
// before refresh completes, avoiding a gap in cache availability
// Await the refresh to ensure the cache is updated before returning
await refreshModels(options)
} else {
// Only delete memory cache when not refreshing
memoryCache.del(provider)
}
}
/**
* Get models from cache, checking memory first, then disk.
* This ensures providers always have access to last known good data,
* preventing fallback to hardcoded defaults on startup.
*
* @param provider - The provider to get models for.
* @returns Models from memory cache, disk cache, or undefined if not cached.
*/
export function getModelsFromCache(provider: ProviderName): ModelRecord | undefined {
// Check memory cache first (fast)
const memoryModels = memoryCache.get<ModelRecord>(provider)
if (memoryModels) {
return memoryModels
}
// Memory cache miss - try to load from disk synchronously
// This is acceptable because it only happens on cold start or after cache expiry
try {
const filename = `${provider}_models.json`
const cacheDir = getCacheDirectoryPathSync()
if (!cacheDir) {
return undefined
}
const filePath = path.join(cacheDir, filename)
// Use synchronous fs to avoid async complexity in getModel() callers
if (fsSync.existsSync(filePath)) {
const data = fsSync.readFileSync(filePath, "utf8")
const models = JSON.parse(data)
// Validate the disk cache data structure using Zod schema
// This ensures the data conforms to ModelRecord = Record<string, ModelInfo>
const validation = modelRecordSchema.safeParse(models)
if (!validation.success) {
console.error(
`[MODEL_CACHE] Invalid disk cache data structure for ${provider}:`,
validation.error.format(),
)
return undefined
}
// Populate memory cache for future fast access
memoryCache.set(provider, validation.data)
return validation.data
}
} catch (error) {
console.error(`[MODEL_CACHE] Error loading ${provider} models from disk:`, error)
}
return undefined
}
/**
* Synchronous version of getCacheDirectoryPath for use in getModelsFromCache.
* Returns the cache directory path without async operations.
*/
function getCacheDirectoryPathSync(): string | undefined {
try {
const globalStoragePath = ContextProxy.instance?.globalStorageUri?.fsPath
if (!globalStoragePath) {
return undefined
}
const cachePath = path.join(globalStoragePath, "cache")
return cachePath
} catch (error) {
console.error(`[MODEL_CACHE] Error getting cache directory path:`, error)
return undefined
}
}