@@ -36,7 +36,7 @@ const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
3636const modelRecordSchema = z . record ( z . string ( ) , modelInfoSchema )
3737
3838// 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
39+ // Keyed on the compound cache key (see getCacheKey) so that two different URL-scoped servers never
4040// deduplicate each other's in-flight refreshes.
4141const inFlightRefresh = new Map < string , Promise < ModelRecord > > ( )
4242
@@ -59,7 +59,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set([
5959] )
6060
6161// 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
62+ // allowlists). For these the cache key also includes a short hash of
6363// the API key so that two different keys on the same server never share a cache entry.
6464const KEY_SCOPED_PROVIDERS : ReadonlySet < RouterName > = new Set ( [
6565 "litellm" , // Per-key model allowlists are a first-class LiteLLM proxy feature
@@ -85,26 +85,39 @@ function getCacheKey(options: GetModelsOptions): string {
8585 const isUrlScoped = URL_SCOPED_PROVIDERS . has ( provider as RouterName )
8686 const isKeyScoped = KEY_SCOPED_PROVIDERS . has ( provider as RouterName )
8787
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- }
88+ // Build URL and key components independently so that key-scoped providers
89+ // without a custom baseUrl still get a per-key cache entry (otherwise two
90+ // different keys on the default server would collapse to the same entry).
91+ // Strip trailing slashes so "http://host:4000/" and "http://host:4000" map to the same key.
92+ const urlPart = isUrlScoped && options . baseUrl ? options . baseUrl . replace ( / \/ + $ / , "" ) : undefined
93+ // Short (16-char) sha256 prefix -- enough to make collisions effectively impossible.
94+ // Not a password hash -- SHA-256 is used here purely as a cache key discriminator to
95+ // distinguish between different API keys on the same server. It is never used for
96+ // authentication or stored as a credential.
97+ // codeql[js/insufficient-password-hash]
98+ const keyPart =
99+ isKeyScoped && options . apiKey
100+ ? createHash ( "sha256" ) . update ( options . apiKey ) . digest ( "hex" ) . slice ( 0 , 16 )
101+ : undefined
102+
103+ if ( urlPart && keyPart ) return `${ provider } :${ urlPart } :${ keyPart } `
104+ if ( urlPart ) return `${ provider } :${ urlPart } `
105+ if ( keyPart ) return `${ provider } :${ keyPart } `
99106 return provider
100107}
101108
102109/**
103110 * Convert a cache key to a filesystem-safe filename component.
104- * Replaces characters that are illegal or awkward in filenames with underscores.
111+ * Hashes the full key to guarantee uniqueness while preserving a readable
112+ * provider prefix at the start of the filename.
105113 */
106114function cacheKeyToFilename ( cacheKey : string ) : string {
107- return cacheKey . replace ( / [: / \\ ? # * < > | " \s ] + / g, "_" )
115+ const prefix = cacheKey . split ( ":" ) [ 0 ] // provider name -- always filesystem-safe
116+ // Not a password hash -- SHA-256 is used here to produce a collision-free filename
117+ // component from the compound cache key. It is never used for authentication.
118+ // codeql[js/insufficient-password-hash]
119+ const hash = createHash ( "sha256" ) . update ( cacheKey ) . digest ( "hex" ) . slice ( 0 , 16 )
120+ return `${ prefix } _${ hash } `
108121}
109122
110123async function writeModels ( cacheKey : string , data : ModelRecord ) {
@@ -376,6 +389,14 @@ export const flushModels = async (options: GetModelsOptions, refresh: boolean =
376389export function getModelsFromCache (
377390 options : GetModelsOptions | ProviderName ,
378391) : ModelRecord | undefined {
392+ // Auth-scoped providers (e.g. zoo-gateway) must never be served from cache --
393+ // their model lists are user-specific and a stale file left over from a previous
394+ // session could leak another user's list. Mirror the guards in getModels/refreshModels.
395+ const providerName = typeof options === "string" ? options : options . provider
396+ if ( isAuthScopedProvider ( providerName as RouterName ) ) {
397+ return undefined
398+ }
399+
379400 const cacheKey = typeof options === "string" ? options : getCacheKey ( options )
380401 // Check memory cache first (fast)
381402 const memoryModels = memoryCache . get < ModelRecord > ( cacheKey )
0 commit comments