11import * as path from "path"
22import fs from "fs/promises"
33import * as fsSync from "fs"
4+ import { createHash } from "crypto"
45
56import NodeCache from "node-cache"
67import { z } from "zod"
@@ -34,28 +35,86 @@ const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
3435// Zod schema for validating ModelRecord structure from disk cache
3536const modelRecordSchema = z . record ( z . string ( ) , modelInfoSchema )
3637
37- // Track in-flight refresh requests to prevent concurrent API calls for the same provider
38- // This prevents race conditions where multiple calls might overwrite each other's results
39- const inFlightRefresh = new Map < RouterName , Promise < ModelRecord > > ( )
38+ // Track in-flight refresh requests to prevent concurrent API calls for the same provider+url.
39+ // Keyed on the compound cache key (see getCacheKey) so that two different LiteLLM servers never
40+ // deduplicate each other's in-flight refreshes.
41+ const inFlightRefresh = new Map < string , Promise < ModelRecord > > ( )
4042
4143// Providers whose model lists are scoped to the signed-in user (e.g. per-account
4244// allowlists or org policies). For these we MUST NOT cache results on disk or
4345// in memory: a sign-in/out cycle could otherwise serve a previous user's model
4446// list to the next user, and stale data could mask backend allowlist updates.
4547const AUTH_SCOPED_PROVIDERS : ReadonlySet < RouterName > = new Set ( [ "zoo-gateway" ] )
4648
49+ // Providers whose model list is determined by the server URL, not just by the provider name.
50+ // Each unique baseUrl must be cached independently so that switching endpoints never serves
51+ // stale results from a previously-cached server.
52+ const URL_SCOPED_PROVIDERS : ReadonlySet < RouterName > = new Set ( [
53+ "litellm" ,
54+ "poe" ,
55+ "deepseek" ,
56+ "ollama" ,
57+ "lmstudio" ,
58+ "requesty" ,
59+ ] )
60+
61+ // Providers where the API key itself determines which models are visible (e.g. per-key
62+ // allowlists on a shared proxy). For these the cache key also includes a short hash of
63+ // the API key so that two different keys on the same server never share a cache entry.
64+ const KEY_SCOPED_PROVIDERS : ReadonlySet < RouterName > = new Set ( [
65+ "litellm" , // Per-key model allowlists are a first-class LiteLLM proxy feature
66+ "poe" , // Per-account model availability
67+ "requesty" , // Per-account custom model policies
68+ ] )
69+
4770function isAuthScopedProvider ( provider : RouterName ) : boolean {
4871 return AUTH_SCOPED_PROVIDERS . has ( provider )
4972}
5073
51- async function writeModels ( router : RouterName , data : ModelRecord ) {
52- const filename = `${ router } _models.json`
74+ /**
75+ * Build a cache key that is unique per provider+server+key combination.
76+ *
77+ * - URL-scoped providers include the normalized baseUrl so that two different servers
78+ * of the same provider type never share a cache entry.
79+ * - Key-scoped providers additionally fold in a short sha256 hash of the API key so that
80+ * two different API keys on the same server never share a cache entry (relevant when
81+ * the server enforces per-key model allowlists, e.g. LiteLLM, Poe, Requesty).
82+ */
83+ function getCacheKey ( options : GetModelsOptions ) : string {
84+ const { provider } = options
85+ const isUrlScoped = URL_SCOPED_PROVIDERS . has ( provider as RouterName )
86+ const isKeyScoped = KEY_SCOPED_PROVIDERS . has ( provider as RouterName )
87+
88+ if ( isUrlScoped && options . baseUrl ) {
89+ // Strip trailing slashes so "http://host:4000/" and "http://host:4000" map to the same key.
90+ const normalizedUrl = options . baseUrl . replace ( / \/ + $ / , "" )
91+ if ( isKeyScoped && options . apiKey ) {
92+ // Short (16-char) sha256 prefix -- enough to make collisions effectively impossible
93+ // while keeping filenames readable. We do not need the full digest here.
94+ const keyHash = createHash ( "sha256" ) . update ( options . apiKey ) . digest ( "hex" ) . slice ( 0 , 16 )
95+ return `${ provider } :${ normalizedUrl } :${ keyHash } `
96+ }
97+ return `${ provider } :${ normalizedUrl } `
98+ }
99+ return provider
100+ }
101+
102+ /**
103+ * Convert a cache key to a filesystem-safe filename component.
104+ * Replaces characters that are illegal or awkward in filenames with underscores.
105+ */
106+ function cacheKeyToFilename ( cacheKey : string ) : string {
107+ return cacheKey . replace ( / [: / \\ ? # * < > | " \s ] + / g, "_" )
108+ }
109+
110+ async function writeModels ( cacheKey : string , data : ModelRecord ) {
111+ const filename = `${ cacheKeyToFilename ( cacheKey ) } _models.json`
53112 const cacheDir = await getCacheDirectoryPath ( ContextProxy . instance . globalStorageUri . fsPath )
54113 await safeWriteJson ( path . join ( cacheDir , filename ) , data )
55114}
56115
57- async function readModels ( router : RouterName ) : Promise < ModelRecord | undefined > {
58- const filename = `${ router } _models.json`
116+ async function readModels ( cacheKey : string ) : Promise < ModelRecord | undefined > {
117+ const filename = `${ cacheKeyToFilename ( cacheKey ) } _models.json`
59118 const cacheDir = await getCacheDirectoryPath ( ContextProxy . instance . globalStorageUri . fsPath )
60119 const filePath = path . join ( cacheDir , filename )
61120 const exists = await fileExistsAtPath ( filePath )
@@ -133,10 +192,11 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
133192 */
134193export const getModels = async ( options : GetModelsOptions ) : Promise < ModelRecord > => {
135194 const { provider } = options
195+ const cacheKey = getCacheKey ( options )
136196
137197 const shouldSkipCache = isAuthScopedProvider ( provider )
138198
139- let models = shouldSkipCache ? undefined : getModelsFromCache ( provider )
199+ let models = shouldSkipCache ? undefined : getModelsFromCache ( options )
140200
141201 if ( models ) {
142202 return models
@@ -149,10 +209,10 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
149209 // Only cache non-empty results so a failed API response doesn't get persisted
150210 // as if the provider had no models. Auth-scoped providers skip caching entirely.
151211 if ( modelCount > 0 && ! shouldSkipCache ) {
152- memoryCache . set ( provider , models )
212+ memoryCache . set ( cacheKey , models )
153213
154- await writeModels ( provider , models ) . catch ( ( err ) =>
155- console . error ( `[MODEL_CACHE] Error writing ${ provider } models to file cache:` , err ) ,
214+ await writeModels ( cacheKey , models ) . catch ( ( err ) =>
215+ console . error ( `[MODEL_CACHE] Error writing ${ cacheKey } models to file cache:` , err ) ,
156216 )
157217 } else if ( modelCount === 0 ) {
158218 TelemetryService . instance . captureEvent ( TelemetryEventName . MODEL_CACHE_EMPTY_RESPONSE , {
@@ -182,17 +242,18 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
182242 */
183243export const refreshModels = async ( options : GetModelsOptions ) : Promise < ModelRecord > => {
184244 const { provider } = options
245+ const cacheKey = getCacheKey ( options )
185246
186247 const shouldSkipCache = isAuthScopedProvider ( provider )
187248
188- // Check if there's already an in-flight refresh for this provider.
249+ // Check if there's already an in-flight refresh for this provider+url combination .
189250 // This prevents race conditions where multiple concurrent refreshes might
190251 // overwrite each other's results. Skip de-duplication for auth-scoped
191252 // providers because two concurrent calls may carry different tokens
192253 // (e.g., after a sign-out/sign-in within the same session) and we must
193254 // not return the first caller's results to the second caller.
194255 if ( ! shouldSkipCache ) {
195- const existingRequest = inFlightRefresh . get ( provider )
256+ const existingRequest = inFlightRefresh . get ( cacheKey )
196257 if ( existingRequest ) {
197258 return existingRequest
198259 }
@@ -206,7 +267,7 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
206267 const modelCount = Object . keys ( models ) . length
207268
208269 // Get existing cached data for comparison
209- const existingCache = shouldSkipCache ? undefined : getModelsFromCache ( provider )
270+ const existingCache = shouldSkipCache ? undefined : getModelsFromCache ( options )
210271 const existingCount = existingCache ? Object . keys ( existingCache ) . length : 0
211272
212273 if ( modelCount === 0 ) {
@@ -224,34 +285,34 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
224285 }
225286
226287 if ( ! shouldSkipCache ) {
227- memoryCache . set ( provider , models )
288+ memoryCache . set ( cacheKey , models )
228289
229- await writeModels ( provider , models ) . catch ( ( err ) =>
230- console . error ( `[refreshModels] Error writing ${ provider } models to disk:` , err ) ,
290+ await writeModels ( cacheKey , models ) . catch ( ( err ) =>
291+ console . error ( `[refreshModels] Error writing ${ cacheKey } models to disk:` , err ) ,
231292 )
232293 }
233294
234295 return models
235296 } catch ( error ) {
236297 // Log the error for debugging, then return existing cache if available (graceful degradation).
237298 // For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior
238- // session, since they could belong to a different user — return empty instead.
239- console . error ( `[refreshModels] Failed to refresh ${ provider } models:` , error )
299+ // session, since they could belong to a different user -- return empty instead.
300+ console . error ( `[refreshModels] Failed to refresh ${ cacheKey } models:` , error )
240301 if ( shouldSkipCache ) {
241302 return { }
242303 }
243- return getModelsFromCache ( provider ) || { }
304+ return getModelsFromCache ( options ) || { }
244305 } finally {
245306 // Always clean up the in-flight tracking
246307 if ( ! shouldSkipCache ) {
247- inFlightRefresh . delete ( provider )
308+ inFlightRefresh . delete ( cacheKey )
248309 }
249310 }
250311 } ) ( )
251312
252313 // Track the in-flight request (auth-scoped providers are excluded; see above).
253314 if ( ! shouldSkipCache ) {
254- inFlightRefresh . set ( provider , refreshPromise )
315+ inFlightRefresh . set ( cacheKey , refreshPromise )
255316 }
256317
257318 return refreshPromise
@@ -290,16 +351,17 @@ export async function initializeModelCacheRefresh(): Promise<void> {
290351 * @param refresh - If true, immediately fetch fresh data from API
291352 */
292353export const flushModels = async ( options : GetModelsOptions , refresh : boolean = false ) : Promise < void > => {
293- const { provider } = options
294354 if ( refresh ) {
295355 // Don't delete memory cache - let refreshModels atomically replace it
296356 // This prevents a race condition where getModels() might be called
297357 // before refresh completes, avoiding a gap in cache availability
298358 // Await the refresh to ensure the cache is updated before returning
299359 await refreshModels ( options )
300360 } else {
301- // Only delete memory cache when not refreshing
302- memoryCache . del ( provider )
361+ // Only delete memory cache when not refreshing. Use the compound cache key so that
362+ // URL-scoped providers (litellm, poe, etc.) actually evict the per-server entry rather
363+ // than a bare provider-name entry that was never written.
364+ memoryCache . del ( getCacheKey ( options ) )
303365 }
304366}
305367
@@ -311,17 +373,20 @@ export const flushModels = async (options: GetModelsOptions, refresh: boolean =
311373 * @param provider - The provider to get models for.
312374 * @returns Models from memory cache, disk cache, or undefined if not cached.
313375 */
314- export function getModelsFromCache ( provider : ProviderName ) : ModelRecord | undefined {
376+ export function getModelsFromCache (
377+ options : GetModelsOptions | ProviderName ,
378+ ) : ModelRecord | undefined {
379+ const cacheKey = typeof options === "string" ? options : getCacheKey ( options )
315380 // Check memory cache first (fast)
316- const memoryModels = memoryCache . get < ModelRecord > ( provider )
381+ const memoryModels = memoryCache . get < ModelRecord > ( cacheKey )
317382 if ( memoryModels ) {
318383 return memoryModels
319384 }
320385
321386 // Memory cache miss - try to load from disk synchronously
322387 // This is acceptable because it only happens on cold start or after cache expiry
323388 try {
324- const filename = `${ provider } _models.json`
389+ const filename = `${ cacheKeyToFilename ( cacheKey ) } _models.json`
325390 const cacheDir = getCacheDirectoryPathSync ( )
326391 if ( ! cacheDir ) {
327392 return undefined
@@ -339,19 +404,19 @@ export function getModelsFromCache(provider: ProviderName): ModelRecord | undefi
339404 const validation = modelRecordSchema . safeParse ( models )
340405 if ( ! validation . success ) {
341406 console . error (
342- `[MODEL_CACHE] Invalid disk cache data structure for ${ provider } :` ,
407+ `[MODEL_CACHE] Invalid disk cache data structure for ${ cacheKey } :` ,
343408 validation . error . format ( ) ,
344409 )
345410 return undefined
346411 }
347412
348413 // Populate memory cache for future fast access
349- memoryCache . set ( provider , validation . data )
414+ memoryCache . set ( cacheKey , validation . data )
350415
351416 return validation . data
352417 }
353418 } catch ( error ) {
354- console . error ( `[MODEL_CACHE] Error loading ${ provider } models from disk:` , error )
419+ console . error ( `[MODEL_CACHE] Error loading ${ cacheKey } models from disk:` , error )
355420 }
356421
357422 return undefined
0 commit comments