Skip to content

Commit 78e11a7

Browse files
authored
fix: LiteLLM cache key collision and silent fallback to non-existent default model (#647)
* fix: LiteLLM provider model desync after Sync Models or settings navigation - Invalidate React Query router-models cache on successful sync in LiteLLM.tsx so useSelectedModel picks up the refreshed list (mirrors Poe.tsx fix) - Preserve configured litellm model ID when model list is empty/loading in useSelectedModel.ts instead of silently falling back to hardcoded default - Pass litellm credentials in the debounced requestRouterModels message in ApiOptions.tsx to prevent fetches with stale config from clearing the model list * 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) * chore: remove ineffective codeql suppression directives The inline // codeql[js/insufficient-password-hash] comments did not suppress the alert because CodeQL tracks taint flow from source variables in other files. Retaining the explanatory prose comments for human reviewers; the false positive requires maintainer dismissal in the GitHub Security tab. * test(webviewMessageHandler): correct LiteLLM credential priority assertion The test "prefers config values over message values for LiteLLM" was asserting the old || behavior. The handler now uses ?? so message.values takes precedence over saved config, matching the DeepSeek pattern and allowing unsaved settings UI state to be used during Sync Models. * fix(security): derive cache-key discriminator without flagged API-key hashing Replace the SHA-256 hash of the API key in the LiteLLM/key-scoped cache key with a memoized, truncated PBKDF2-derived 32-bit discriminator. This resolves the CodeQL js/insufficient-password-hash alert at the source (Node crypto.pbkdf2Sync is not modeled as a weak-hash sink) rather than dismissing it as a false positive, and the heavy truncation makes the value written to the on-disk cache filename non-reversible to the API key. Add provider-agnostic tests covering per-key separation, determinism, and the non-identifying discriminator shape. * fix(security): derive cache filename digest via KDF to clear residual CodeQL taint The API-key discriminator is embedded in the compound cache key, so CodeQL taint tracking treats the cache key as password-derived and flagged the remaining createHash(sha256) in cacheKeyToFilename. Route the filename digest through the same truncated PBKDF2 helper (deriveCacheDigest) and remove createHash entirely, eliminating the last weak-hash sink the tainted value can reach. Filename width is preserved (64-bit/16-hex). * test: cover all getCacheKey scoping branches in modelCache * fix: resolve check-types errors in modelCache tests * fix: address PR review for LiteLLM cache key collision fix - poe/lm-studio getModel() pass GetModelsOptions so compound cache key matches; bare-string lookups previously always missed and fell back to defaults - router-provider uses || so empty-string modelId falls back to default instead of forwarding "" to the API - LiteLLM Sync invalidates the exact ["routerModels","litellm"] query rather than the broad ["routerModels"] prefix - document in-flight dedup microtask-safety invariant in modelCache - add tests: list non-empty->empty transition preserves selection; key-scoped in-flight dedup (different keys -> separate fetch)
1 parent 83fc6bb commit 78e11a7

13 files changed

Lines changed: 517 additions & 56 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"zoo-code": patch
3+
---
4+
5+
Fix LiteLLM provider cache key collision, credential priority, and model-selection fallback to non-existent default.
6+
7+
Two bugs are addressed:
8+
9+
1. **Cache key collision**: All URL-scoped providers (LiteLLM, Ollama, LM Studio, Poe, DeepSeek,
10+
Requesty) previously shared one cache entry keyed only on the provider name. Switching between
11+
profiles backed by different servers silently served the wrong model list and the stale list
12+
persisted across VS Code restarts via the disk cache. Fixed with a compound cache key:
13+
URL-scoped providers use `provider:baseUrl`; key-scoped providers (LiteLLM, Poe, Requesty)
14+
additionally include a short, irreversible discriminator derived from the API key
15+
(`provider:baseUrl:<discriminator>`) so that two different API keys on the same server never share
16+
a cache entry (relevant when the server enforces per-key model allowlists). Both the discriminator
17+
and the on-disk filename digest are derived via truncated PBKDF2 so neither can be reversed to
18+
identify the API key written to the cache filename. The `RouterProvider.getModel()` cold-start
19+
fallback is also corrected to pass the full options so it resolves the same compound key.
20+
21+
2. **Silent fallback to hardcoded default**: When the LiteLLM model list was empty (due to the
22+
collision above, a failed sync, or a transient error), `useSelectedModel` reset the configured
23+
model ID to `claude-3-7-sonnet-20250219` -- a model that typically does not exist on user
24+
LiteLLM servers. Four sub-fixes: preserve the configured model ID when the list is empty;
25+
invalidate the React Query router-models cache after a successful "Sync Models" click; pass the
26+
current LiteLLM credentials in the debounced `requestRouterModels` message; and correct the
27+
credential priority in `webviewMessageHandler.ts` so that message values (current unsaved field
28+
state) take precedence over stale saved config, matching the pattern already used for DeepSeek.

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,5 +432,187 @@ describe("empty cache protection", () => {
432432
expect(result1).toEqual(mockModels)
433433
expect(result2).toEqual(mockModels)
434434
})
435+
436+
it("scopes in-flight dedup by API key for key-scoped providers", async () => {
437+
// In-flight dedup is keyed on the compound cache key, so concurrent refreshes for a
438+
// key-scoped provider must dedup only when the API key matches. Two different keys
439+
// (different compound keys) each trigger their own fetch; the same key shares one.
440+
const mockModels = {
441+
"requesty/model": {
442+
maxTokens: 4096,
443+
contextWindow: 200000,
444+
supportsPromptCache: false,
445+
description: "Requesty model",
446+
},
447+
}
448+
mockGetRequestyModels.mockResolvedValue(mockModels)
449+
450+
const { refreshModels } = await import("../modelCache")
451+
452+
// Different keys -> separate compound keys -> two distinct fetches.
453+
const [a, b] = await Promise.all([
454+
refreshModels({ provider: "requesty", apiKey: "key-one" }),
455+
refreshModels({ provider: "requesty", apiKey: "key-two" }),
456+
])
457+
expect(mockGetRequestyModels).toHaveBeenCalledTimes(2)
458+
expect(a).toEqual(mockModels)
459+
expect(b).toEqual(mockModels)
460+
461+
mockGetRequestyModels.mockClear()
462+
463+
// Same key -> same compound key -> a single shared in-flight fetch.
464+
let resolveShared: (value: typeof mockModels) => void
465+
mockGetRequestyModels.mockReturnValue(
466+
new Promise<typeof mockModels>((resolve) => {
467+
resolveShared = resolve
468+
}),
469+
)
470+
471+
const shared1 = refreshModels({ provider: "requesty", apiKey: "same-key" })
472+
const shared2 = refreshModels({ provider: "requesty", apiKey: "same-key" })
473+
474+
expect(mockGetRequestyModels).toHaveBeenCalledTimes(1)
475+
476+
resolveShared!(mockModels)
477+
const [s1, s2] = await Promise.all([shared1, shared2])
478+
expect(s1).toEqual(mockModels)
479+
expect(s2).toEqual(mockModels)
480+
})
481+
})
482+
})
483+
484+
describe("key-scoped cache key derivation", () => {
485+
// Exercises the per-API-key cache discriminator that all KEY_SCOPED_PROVIDERS share.
486+
// Requesty is used only because it is a key-scoped provider with a mocked fetcher; the
487+
// behavior under test is provider-agnostic.
488+
const keyScopedProvider = "requesty" as const
489+
490+
let mockCache: any
491+
let mockSet: Mock
492+
493+
const mockModels = {
494+
"key-scoped/model": {
495+
maxTokens: 4096,
496+
contextWindow: 200000,
497+
supportsPromptCache: false,
498+
description: "Key-scoped provider model",
499+
},
500+
}
501+
502+
beforeEach(() => {
503+
vi.clearAllMocks()
504+
const MockedNodeCache = vi.mocked(NodeCache)
505+
mockCache = new MockedNodeCache()
506+
mockCache.get.mockReturnValue(undefined)
507+
mockSet = mockCache.set
508+
mockGetRequestyModels.mockResolvedValue(mockModels)
509+
})
510+
511+
// Returns the cache key the result was written under (first arg of the matching set call).
512+
const writtenCacheKey = (): string => {
513+
const call = mockSet.mock.calls.find((c) => c[1] === mockModels)
514+
return call?.[0] as string
515+
}
516+
517+
it("writes different cache keys for different API keys", async () => {
518+
await getModels({ provider: keyScopedProvider, apiKey: "key-one" })
519+
const firstKey = writtenCacheKey()
520+
521+
mockSet.mockClear()
522+
await getModels({ provider: keyScopedProvider, apiKey: "key-two" })
523+
const secondKey = writtenCacheKey()
524+
525+
expect(firstKey).toBeDefined()
526+
expect(secondKey).toBeDefined()
527+
expect(firstKey).not.toEqual(secondKey)
528+
})
529+
530+
it("writes the same cache key for repeated calls with the same API key", async () => {
531+
await getModels({ provider: keyScopedProvider, apiKey: "stable-key" })
532+
const firstKey = writtenCacheKey()
533+
534+
mockSet.mockClear()
535+
await getModels({ provider: keyScopedProvider, apiKey: "stable-key" })
536+
const secondKey = writtenCacheKey()
537+
538+
expect(firstKey).toEqual(secondKey)
539+
})
540+
541+
it("does not embed the raw API key in the cache key and truncates the discriminator", async () => {
542+
const apiKey = "super-secret-api-key-value"
543+
await getModels({ provider: keyScopedProvider, apiKey })
544+
const cacheKey = writtenCacheKey()
545+
546+
// The raw secret must never appear in the on-disk-bound cache key.
547+
expect(cacheKey).not.toContain(apiKey)
548+
// The discriminator is the trailing key-component: an 8-char (32-bit) hex string.
549+
const discriminator = cacheKey.split(":").pop() as string
550+
expect(discriminator).toMatch(/^[0-9a-f]{8}$/)
551+
})
552+
})
553+
554+
describe("compound cache key derivation across scoping dimensions", () => {
555+
// Exercises every branch of getCacheKey via the public getModels() entry point.
556+
// litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare
557+
// provider fallback. The fetcher mocks let us observe the cache key the result is
558+
// written under (first arg of the matching memoryCache.set call).
559+
const mockModels = {
560+
"compound/model": {
561+
maxTokens: 4096,
562+
contextWindow: 200000,
563+
supportsPromptCache: false,
564+
description: "Compound cache key model",
565+
},
566+
}
567+
568+
let mockSet: Mock
569+
570+
beforeEach(() => {
571+
vi.clearAllMocks()
572+
const MockedNodeCache = vi.mocked(NodeCache)
573+
const mockCache = new MockedNodeCache()
574+
;(mockCache.get as Mock).mockReturnValue(undefined)
575+
mockSet = mockCache.set as unknown as Mock
576+
mockGetLiteLLMModels.mockResolvedValue(mockModels)
577+
mockGetOpenRouterModels.mockResolvedValue(mockModels)
578+
})
579+
580+
const writtenCacheKey = (): string => {
581+
const call = mockSet.mock.calls.find((c) => c[1] === mockModels)
582+
return call?.[0] as string
583+
}
584+
585+
it("includes both the server URL and the key discriminator for url+key-scoped providers", async () => {
586+
await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" })
587+
const cacheKey = writtenCacheKey()
588+
589+
// Expected shape: provider:url:keyDiscriminator
590+
expect(cacheKey).toMatch(/^litellm:http:\/\/host:4000:[0-9a-f]{8}$/)
591+
})
592+
593+
it("normalizes trailing slashes in the server URL so equivalent URLs share a cache key", async () => {
594+
await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000/" })
595+
const withSlash = writtenCacheKey()
596+
597+
mockSet.mockClear()
598+
await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" })
599+
const withoutSlash = writtenCacheKey()
600+
601+
expect(withSlash).toEqual(withoutSlash)
602+
})
603+
604+
it("includes only the server URL when a url-scoped provider has no API key", async () => {
605+
await getModels({ provider: "litellm", baseUrl: "http://host:4000" })
606+
const cacheKey = writtenCacheKey()
607+
608+
// No trailing key discriminator when apiKey is absent.
609+
expect(cacheKey).toBe("litellm:http://host:4000")
610+
})
611+
612+
it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => {
613+
await getModels({ provider: "openrouter", apiKey: "ignored-key", baseUrl: "http://ignored:4000" })
614+
const cacheKey = writtenCacheKey()
615+
616+
expect(cacheKey).toBe("openrouter")
435617
})
436618
})

0 commit comments

Comments
 (0)