Skip to content

Commit fd44f2a

Browse files
committed
fix: address CodeRabbit review comments on litellm model desync
- Refactor getCacheKey to compute URL and key components independently, preventing key-scoped providers on the default server from collapsing to the same cache entry - Replace character-substitution in cacheKeyToFilename with SHA-256 hash for collision-free filesystem-safe filenames - Block AUTH_SCOPED_PROVIDERS (zoo-gateway) in getModelsFromCache to prevent stale user-specific model lists from leaking across sessions - Wrap requestRouterModels switch case body in braces to satisfy Biome noSwitchDeclarations lint rule - Preserve configured modelId in RouterProvider.getModel() last-resort fallback instead of silently defaulting to hardcoded defaultModelId - Add codeql[js/insufficient-password-hash] suppressions on SHA-256 cache key discriminator calls (false positive: not password hashing)
1 parent 4203a2d commit fd44f2a

3 files changed

Lines changed: 43 additions & 18 deletions

File tree

src/api/providers/fetchers/modelCache.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
3636
const 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.
4141
const 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.
6464
const 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
*/
106114
function 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

110123
async function writeModels(cacheKey: string, data: ModelRecord) {
@@ -376,6 +389,14 @@ export const flushModels = async (options: GetModelsOptions, refresh: boolean =
376389
export 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)

src/api/providers/router-provider.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ export abstract class RouterProvider extends BaseProvider {
8383
return { id, info: cachedModels[id] }
8484
}
8585

86-
// Last resort: return default model
87-
return { id: this.defaultModelId, info: this.defaultModelInfo }
86+
// Last resort: preserve the configured model ID (falling back to the default
87+
// only when none is configured) so an as-yet-unfetched model isn't silently
88+
// swapped for the hardcoded default. info still comes from defaults since we
89+
// have no fetched or cached metadata for the configured model at this point.
90+
return { id, info: this.defaultModelInfo }
8891
}
8992

9093
protected supportsTemperature(modelId: string): boolean {

src/core/webview/webviewMessageHandler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1006,7 +1006,7 @@ export const webviewMessageHandler = async (
10061006
// For providers that need credentials, use their specific handlers
10071007
await flushModels({ provider: routerNameFlush } as GetModelsOptions, true)
10081008
break
1009-
case "requestRouterModels":
1009+
case "requestRouterModels": {
10101010
const { apiConfiguration } = await provider.getState()
10111011

10121012
// Optional single provider filter from webview
@@ -1187,6 +1187,7 @@ export const webviewMessageHandler = async (
11871187
values: providerFilter ? { provider: requestedProvider } : undefined,
11881188
})
11891189
break
1190+
}
11901191
case "requestOllamaModels": {
11911192
// Specific handler for Ollama models only.
11921193
const { apiConfiguration: ollamaApiConfig } = await provider.getState()

0 commit comments

Comments
 (0)