Skip to content

fix: LiteLLM cache key collision and silent fallback to non-existent default model#647

Open
awschmeder wants to merge 9 commits into
Zoo-Code-Org:mainfrom
awschmeder:fix/litellm-model-desync
Open

fix: LiteLLM cache key collision and silent fallback to non-existent default model#647
awschmeder wants to merge 9 commits into
Zoo-Code-Org:mainfrom
awschmeder:fix/litellm-model-desync

Conversation

@awschmeder

@awschmeder awschmeder commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Closes: #638

Description

Fixes two root bugs causing LiteLLM provider users to lose their selected model:

Bug 1 -- Cache key collision (src/api/providers/fetchers/modelCache.ts, src/api/providers/router-provider.ts):

All URL-scoped providers (LiteLLM, Ollama, LM Studio, Poe, DeepSeek, Requesty) previously shared one cache entry keyed only on the provider name. Switching between profiles backed by different servers silently served the wrong model list, and the stale list persisted across VS Code restarts via the disk cache.

Fix: compound cache key -- URL-scoped providers use provider:baseUrl; key-scoped providers (LiteLLM, Poe, Requesty) additionally include a short, irreversible discriminator derived from the API key (provider:baseUrl:<discriminator>) so that two different API keys on the same server never share a cache entry. The discriminator is a 32-bit value derived via PBKDF2 and truncated, so the value written to the on-disk cache filename cannot be reversed to identify the API key; this also keeps the derivation off CodeQLs js/insufficient-password-hash sink (Nodes crypto.pbkdf2Sync is not a modeled weak-hash operation), resolving that finding at the source rather than dismissing it. The RouterProvider.getModel() cold-start fallback is corrected to pass the full options so it resolves the same compound key that fetchModel() wrote under.

Bug 2 -- Silent fallback to hardcoded default (webview-ui/src/components/ui/hooks/useSelectedModel.ts, src/core/webview/webviewMessageHandler.ts):

When the model list was empty, useSelectedModel reset the configured model ID to claude-3-7-sonnet-20250219 -- a model that typically does not exist on user LiteLLM servers.

Four sub-fixes:

  • Preserve the configured model ID when the list is empty; return "" (no selection) rather than a phantom default when nothing is configured
  • Invalidate the React Query router-models cache after a successful "Sync Models" click so useSelectedModel picks up the refreshed list
  • Pass current LiteLLM credentials in the debounced requestRouterModels message
  • Correct credential priority in webviewMessageHandler.ts -- message.values (current unsaved field state) now takes precedence over saved config via ?? instead of ||, matching the pattern already used for DeepSeek

Bug 3 -- TypeScript check-types failures (src/shared/api.ts, src/api/providers/fetchers/modelCache.ts, src/api/providers/fetchers/__tests__/modelCache.spec.ts):

Three compile errors introduced by the compound cache key work:

  • dynamicProviderExtras.litellm declared apiKey: string (required), but the cache key logic supports the no-key case for LiteLLM (URL-only scoping when no key is supplied). Changed to apiKey?: string and updated fetchModelsFromProvider to pass options.apiKey ?? "" to the underlying fetcher.
  • mockCache.get and mockCache.set in the compound-cache-key test were typed as the original NodeCache overloads rather than Mock; fixed with explicit casts.

Test Procedure

Automated:

  • Updated useSelectedModel.spec.ts: the existing test that asserted fallback to claude-3-7-sonnet-20250219 now asserts preservation of the configured model ID; a new test covers the empty-ID case when no model is configured.
  • Run: cd webview-ui && npx vitest run src/components/ui/hooks/__tests__/useSelectedModel.spec.ts

Manual (Bug 1 -- cache key collision):

  1. Configure Profile A: LiteLLM with baseUrl=http://server-a:4000, select a model.
  2. Configure Profile B: LiteLLM with baseUrl=http://server-b:4000.
  3. Verify Profile B shows its own model list, not server A's.
  4. Restart VS Code; verify both profiles still show the correct lists.

Manual (Bug 2 -- silent fallback):

  1. Configure LiteLLM with a valid server, select my-custom-model.
  2. Trigger a failed sync or switch profiles to empty the model list.
  3. Verify the active model remains my-custom-model rather than resetting to claude-3-7-sonnet-20250219.
  4. Click "Sync Models"; verify the model list repopulates and the selection is preserved.

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
  • Scope: My changes are focused on the linked issue (one major feature/fix per PR).
  • Self-Review: I have performed a thorough self-review of my code.
  • Testing: New and/or updated tests have been added to cover my changes (if applicable).
  • Documentation Impact: I have considered if my changes require documentation updates (see "Documentation Updates" section below).
  • Contribution Guidelines: I have read and agree to the Contributor Guidelines.

Screenshots / Videos

N/A -- backend cache and hook logic changes; no UI visual changes.

Documentation Updates

  • No documentation updates are required.

Additional Notes

The compound cache key changes the on-disk filename for URL-scoped providers (e.g. litellm_models.json becomes litellm_<hash>.json). Existing single-key cache files are simply ignored on first load and will be replaced on the next successful fetch -- no migration needed.

Get in Touch

Discord: awschmeder

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Fixes two LiteLLM provider bugs: (1) cache key collisions across URL-scoped and key-scoped providers are resolved by incorporating baseUrl and an API-key PBKDF2 hash into a compound cache key throughout modelCache.ts and RouterProvider; (2) the silent fallback to a hardcoded non-existent model ID is eliminated by preserving the configured model ID when the model list is empty, forwarding current credentials on sync, fixing credential priority in the message handler, and invalidating the React Query router-models cache after a successful sync.

Changes

LiteLLM Cache Collision and Model Desync Fix

Layer / File(s) Summary
Compound cache key infrastructure
src/api/providers/fetchers/modelCache.ts, src/api/providers/fetchers/__tests__/modelCache.spec.ts
Adds crypto.pbkdf2Sync import, defines provider scoping sets (AUTH_SCOPED_PROVIDERS, URL_SCOPED_PROVIDERS, KEY_SCOPED_PROVIDERS), implements getCacheKey(options) to embed normalized baseUrl and API-key PBKDF2 discriminator, adds cacheKeyToFilename for filesystem-safe filenames, initializes inFlightRefresh map keyed by compound key, updates disk read/write helpers, and adds comprehensive tests for cache key derivation across scoping dimensions.
getModels compute and cache using compound key
src/api/providers/fetchers/modelCache.ts
Computes compound cacheKey, continues skipping cache for auth-scoped providers, performs initial lookup via getModelsFromCache(options), and writes successful results to memory and disk using the compound cacheKey.
refreshModels in-flight, write, and compare using compound key
src/api/providers/fetchers/modelCache.ts
Uses compound cacheKey for in-flight refresh de-duplication, retrieves existing cache for comparison via getModelsFromCache(options), writes refreshed results to memory and disk, and on failure logs with compound key and falls back via compound-key-based cache retrieval.
flushModels eviction using compound key
src/api/providers/fetchers/modelCache.ts
Evicts memory cache using memoryCache.del(getCacheKey(options)) to ensure URL/key-scoped entries are removed without cross-provider interference.
getModelsFromCache generalization and disk validation
src/api/providers/fetchers/modelCache.ts
Generalizes getModelsFromCache to accept GetModelsOptions | ProviderName; adds auth-scoped guard; derives cacheKey from options; reads/writes memory using compound key; builds disk filenames with cacheKeyToFilename; validates disk contents and includes compound key in error logs.
RouterProvider cold-start cache lookup fix
src/api/providers/router-provider.ts
getModel()'s fallback now passes { provider, baseUrl, apiKey } to getModelsFromCache to resolve the same compound key written by fetchModel(); final fallback preserves the computed model ID instead of returning defaultModelId unconditionally.
Preserve configured model ID when model list is empty
webview-ui/src/components/ui/hooks/useSelectedModel.ts, webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts
Adds hasModels guard in the litellm switch case to return the configured litellmModelId (or "") instead of defaultModelId when models are absent; updates existing test to assert configured ID preservation; adds new test for unconfigured-ID case.
Credential forwarding and message handler priority fix
webview-ui/src/components/settings/ApiOptions.tsx, src/core/webview/webviewMessageHandler.ts, src/core/webview/__tests__/webviewMessageHandler.spec.ts
Splits litellm/poe debounce handler so litellm sends requestRouterModels with { litellmApiKey, litellmBaseUrl } in values; flips credential resolution from || to ?? so message.values takes precedence over persisted config; wraps requestRouterModels case in explicit block scope; updates test to verify message-provided credentials override saved config.
React Query cache invalidation after sync
webview-ui/src/components/settings/providers/LiteLLM.tsx
Adds useQueryClient import and hook call; invalidates ["routerModels"] query cache on successful sync so useSelectedModel re-reads the updated list; updates useEffect dependency array.
Changeset documentation
.changeset/fix-litellm-model-desync.md
Documents the four coordinated bug fixes.

Sequence Diagram

sequenceDiagram
  participant ApiOptions as ApiOptions.tsx
  participant WebviewMsg as webviewMessageHandler
  participant ModelCache as modelCache
  participant LiteLLMUI as LiteLLM.tsx
  participant ReactQuery as React Query<br/>["routerModels"]

  ApiOptions->>WebviewMsg: requestRouterModels<br/>values: {litellmApiKey, baseUrl}
  WebviewMsg->>WebviewMsg: resolve credentials<br/>message.values ?? apiConfiguration
  WebviewMsg->>ModelCache: refreshModels<br/>provider, baseUrl, apiKey
  Note over ModelCache: Compute compound key:<br/>provider + baseUrl + pbkdf2(apiKey)
  ModelCache-->>WebviewMsg: updated model list
  WebviewMsg-->>LiteLLMUI: routerModels (status: success)
  LiteLLMUI->>ReactQuery: invalidateQueries(["routerModels"])
  ReactQuery-->>LiteLLMUI: useSelectedModel re-executes<br/>with fresh cached list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Zoo-Code-Org/Zoo-Code#344: Both PRs modify src/api/providers/fetchers/modelCache.ts caching infrastructure; this PR introduces compound cache keys for scoping, while the retrieved PR may involve skip-cache paths.

Suggested reviewers

  • taltas
  • hannesrudolph
  • edelauna
  • navedmerchant

Poem

🐇 The cache had one key, a collision-prone thing,
Now baseUrl and pbkdf2 each get their own ring.
The model stays put when the list comes back bare,
Credentials fly fresh through the message with care.
"Sync Models!" the rabbit cries — React Query's refreshed,
No phantom claude-3-7 shall leave models unmeshed! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and concisely summarizes the two main bugs fixed: cache key collision for URL-scoped providers and silent fallback to non-existent default model.
Linked Issues check ✅ Passed All code changes directly address the two primary objectives in issue #638: compound cache keys for URL/key-scoped providers with PBKDF2 discriminator [Bug 1], proper cold-start fallback [Bug 1], preserving configured model IDs [Bug 2], React Query cache invalidation [Bug 2], passing current credentials in debounced messages [Bug 2], and correcting credential priority using nullish coalescing [Bug 2].
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the two identified bugs: cache key collision (modelCache.ts, router-provider.ts) and silent fallback (useSelectedModel.ts, LiteLLM.tsx, ApiOptions.tsx, webviewMessageHandler.ts) with corresponding test updates.
Description check ✅ Passed The PR description comprehensively addresses the template structure with all required sections completed: Related GitHub Issue (Closes: #638), detailed Description covering both bugs and implementation details, Test Procedure with manual and automated steps, completed Pre-Submission Checklist, Screenshots/Videos note, Documentation Updates indication, Additional Notes, and Get in Touch information.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/api/providers/fetchers/modelCache.ts Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/providers/router-provider.ts (1)

85-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the configured model ID on the final fallback.

Line 64 already computes id from this.modelId ?? this.defaultModelId, but the last resort returns defaultModelId again. If the model list/cache is empty, this still silently switches a configured LiteLLM model to the hardcoded default instead of only falling back the metadata.

Suggested fix
 		// Last resort: return default model
-		return { id: this.defaultModelId, info: this.defaultModelInfo }
+		return { id, info: this.defaultModelInfo }
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/router-provider.ts` around lines 85 - 86, The last resort
fallback return statement is using this.defaultModelId directly instead of
preserving the configured model ID. Change the return statement on line 86 to
use the same fallback logic as line 64, which computes `this.modelId ??
this.defaultModelId` for the id field. This ensures that if a model is
explicitly configured via this.modelId, it is preserved even when the model list
or cache is empty, rather than being silently switched to the hardcoded default.
🧹 Nitpick comments (2)
webview-ui/src/components/settings/providers/LiteLLM.tsx (1)

60-63: ⚡ Quick win

Add a local test for cache invalidation on LiteLLM refresh success.

This introduces a key behavior change (invalidateQueries({ queryKey: ["routerModels"] })) in response to webview messages. A focused component test should assert it runs on success and is skipped on provider-specific error.

As per coding guidelines, webview-ui/src/**/*.{ts,tsx} requires preferring local webview-ui tests for React/webview behavior and adding/updating Vitest coverage under webview-ui/src/**/__tests__.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/settings/providers/LiteLLM.tsx` around lines 60 -
63, Add a focused Vitest component test for the LiteLLM.tsx component that
verifies the cache invalidation behavior when the refresh succeeds. The test
should assert that queryClient.invalidateQueries is called with the queryKey
["routerModels"] upon successful refresh, and separately verify that the cache
invalidation is skipped when a provider-specific error occurs. Place this test
in webview-ui/src/__tests__/ following the coding guidelines for React/webview
component testing.

Source: Coding guidelines

webview-ui/src/components/settings/ApiOptions.tsx (1)

225-233: ⚡ Quick win

Add a local test for LiteLLM credential forwarding in the debounced request.

This branch changes webview message wiring (requestRouterModels payload shape) but there’s no adjacent Vitest coverage for the debounce path and payload precedence. A focused test here would lock in the regression fix.

As per coding guidelines, webview-ui/src/**/*.{ts,tsx} requires preferring local webview-ui tests for React/webview behavior and adding/updating Vitest coverage under webview-ui/src/**/__tests__.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/settings/ApiOptions.tsx` around lines 225 - 233,
Add a Vitest test file under `webview-ui/src/components/settings/__tests__` that
covers the LiteLLM credential forwarding behavior in the debounced request
handler within ApiOptions component. The test should verify that when
selectedProvider is "litellm", the requestRouterModels message payload correctly
includes litellmApiKey and litellmBaseUrl from the apiConfiguration, and should
validate both the debounce timing and payload precedence to ensure the
regression fix is locked in.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/providers/fetchers/modelCache.ts`:
- Around line 106-107: The cacheKeyToFilename function uses simple character
replacement which can cause multiple distinct cache keys to map to the same
filename, resulting in stale cache collisions after restarts. Replace the
current regex-based replacement approach with a hash-based solution that hashes
the full cacheKey string to guarantee uniqueness, while preserving a safe
provider prefix at the beginning of the filename for readability purposes.
- Around line 376-379: The getModelsFromCache function allows auth-scoped
providers like zoo-gateway to read from cache files, which can violate auth
isolation if stale cache files exist from previous versions. Add a check at the
beginning of getModelsFromCache that blocks auth-scoped providers from reading
cache by returning undefined early, similar to the blocking logic already
implemented in getModels and refreshModels. Extract the provider name from the
options parameter and verify it is not an auth-scoped provider before proceeding
with cache retrieval.
- Around line 88-99: The issue is that the key hash generation for
KEY_SCOPED_PROVIDERS is nested inside the isUrlScoped && options.baseUrl
conditional block, causing calls with different API keys to the default server
to incorrectly collapse to the same cache key. Refactor the logic by building
the URL and key components independently: first check if isUrlScoped and
options.baseUrl exist to include the normalizedUrl, then separately check if
isKeyScoped and options.apiKey exist to compute and include the keyHash.
Construct the final cache key by combining the provider with whichever optional
components are applicable, rather than nesting the key hash computation inside
the URL scope branch.

In `@src/core/webview/webviewMessageHandler.ts`:
- Around line 990-995: The case clause for requestRouterModels contains multiple
const declarations (litellmApiKey and litellmBaseUrl) directly within the switch
statement, which violates the Biome noSwitchDeclarations rule. Wrap all the
content of the requestRouterModels case clause (starting from where the const
declarations begin) in curly braces to create a proper block scope that
satisfies the linting rule and ensures correct variable scoping.

---

Outside diff comments:
In `@src/api/providers/router-provider.ts`:
- Around line 85-86: The last resort fallback return statement is using
this.defaultModelId directly instead of preserving the configured model ID.
Change the return statement on line 86 to use the same fallback logic as line
64, which computes `this.modelId ?? this.defaultModelId` for the id field. This
ensures that if a model is explicitly configured via this.modelId, it is
preserved even when the model list or cache is empty, rather than being silently
switched to the hardcoded default.

---

Nitpick comments:
In `@webview-ui/src/components/settings/ApiOptions.tsx`:
- Around line 225-233: Add a Vitest test file under
`webview-ui/src/components/settings/__tests__` that covers the LiteLLM
credential forwarding behavior in the debounced request handler within
ApiOptions component. The test should verify that when selectedProvider is
"litellm", the requestRouterModels message payload correctly includes
litellmApiKey and litellmBaseUrl from the apiConfiguration, and should validate
both the debounce timing and payload precedence to ensure the regression fix is
locked in.

In `@webview-ui/src/components/settings/providers/LiteLLM.tsx`:
- Around line 60-63: Add a focused Vitest component test for the LiteLLM.tsx
component that verifies the cache invalidation behavior when the refresh
succeeds. The test should assert that queryClient.invalidateQueries is called
with the queryKey ["routerModels"] upon successful refresh, and separately
verify that the cache invalidation is skipped when a provider-specific error
occurs. Place this test in webview-ui/src/__tests__/ following the coding
guidelines for React/webview component testing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 308935c5-a61a-45fe-b09e-cea31b5613c9

📥 Commits

Reviewing files that changed from the base of the PR and between e80039b and 08397b4.

📒 Files selected for processing (8)
  • .changeset/fix-litellm-model-desync.md
  • src/api/providers/fetchers/modelCache.ts
  • src/api/providers/router-provider.ts
  • src/core/webview/webviewMessageHandler.ts
  • webview-ui/src/components/settings/ApiOptions.tsx
  • webview-ui/src/components/settings/providers/LiteLLM.tsx
  • webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts
  • webview-ui/src/components/ui/hooks/useSelectedModel.ts

Comment thread src/api/providers/fetchers/modelCache.ts Outdated
Comment thread src/api/providers/fetchers/modelCache.ts Outdated
Comment thread src/api/providers/fetchers/modelCache.ts
Comment thread src/core/webview/webviewMessageHandler.ts
Comment thread src/api/providers/fetchers/modelCache.ts Fixed
@awschmeder

awschmeder commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

The CodeQL js/insufficient-password-hash alert (alert #17) is a false positive. The two createHash("sha256") calls in modelCache.ts are not password hashing -- they are used purely as cache key discriminators:

  1. In getCacheKey(): a 16-char SHA-256 prefix of the API key is included in the compound cache key so that two different API keys on the same server get separate cache entries (relevant for LiteLLM, Poe, and Requesty which enforce per-key model allowlists). The hash is never stored as a credential or used for authentication.

  2. In cacheKeyToFilename(): SHA-256 of the full compound cache key produces a collision-free, filesystem-safe filename component. Again, no authentication involved.

CodeQL's taint-flow analysis traces the API key variables (litellmApiKey, poeApiKey, etc.) through to the createHash sink and flags it as insufficient password hashing. The inline // codeql[...] suppression does not work here because the alert sources are in other files. A maintainer dismissal of alert #17 as "False positive" in the GitHub Security tab would resolve the CodeQL check.

Replaced with new implementation to avoid the advanced security flagging.

@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.59701% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/api/providers/fetchers/modelCache.ts 85.18% 5 Missing and 3 partials ⚠️
webview-ui/src/components/settings/ApiOptions.tsx 0.00% 3 Missing ⚠️
...w-ui/src/components/settings/providers/LiteLLM.tsx 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…gation

- 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
- 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)
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.
…rtion

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.
… 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.
@awschmeder awschmeder force-pushed the fix/litellm-model-desync branch from 86470d6 to a28a834 Compare June 23, 2026 17:52
@awschmeder

Copy link
Copy Markdown
Contributor Author

Resolved the CodeQL js/insufficient-password-hash finding at the source.

The key-scoped cache discriminator no longer hashes the API key with SHA-256. It now uses a memoized PBKDF2 derivation truncated to 32 bits (deriveApiKeyDiscriminator in modelCache.ts). This:

  • Removes the CodeQL sink entirely -- Node's crypto.pbkdf2Sync is not modeled as a weak-hash CryptographicOperation, so the source-to-sink path no longer exists and the alert is genuinely resolved rather than dismissed as a false positive.
  • Keeps the on-disk value non-reversible to the key -- the heavy truncation means any preimage search against the cache filename yields an astronomically large set of candidate keys, so the API key cannot be identified; PBKDF2 adds a per-guess cost on top.
  • Preserves behavior -- distinct keys map to distinct cache entries; the derivation is deterministic and memoized (runs at most once per key per session).

Added provider-agnostic tests covering per-key separation, determinism, and the non-identifying discriminator shape. modelCache.spec.ts is 21/21 green. The changeset and PR description are updated to describe the PBKDF2/truncation derivation.

Comment thread src/api/providers/fetchers/modelCache.ts Fixed
… 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).
@awschmeder

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@edelauna edelauna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks for adding caching

Just a note on poe and lm-studio providers:

Two providers listed as fixed still have broken cache lookups after this PR.

poe.ts:41 and lm-studio.ts:173 both call getModelsFromCache with a bare string ("poe" and "lmstudio"). Since both are now declared in URL_SCOPED_PROVIDERS (and Poe also in KEY_SCOPED_PROVIDERS), their cache entries are written under compound keys like poe:<url>:<discriminator> — but the bare-string lookup in getModelsFromCache bypasses getCacheKey and will always miss. These getModel() implementations silently fall back to hardcoded defaults on every call.

*/
export function getModelsFromCache(provider: ProviderName): ModelRecord | undefined {
export function getModelsFromCache(
options: GetModelsOptions | ProviderName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The | ProviderName string branch sets cacheKey = options verbatim, bypassing getCacheKey. For any URL/key-scoped provider (poe, lmstudio) this produces a bare key that never matches what getModels/refreshModels wrote. The existing callers in poe.ts:41 and lm-studio.ts:173 both pass a bare string today, so those providers always miss the cache despite being listed as fixed in the changeset. Could this overload be dropped and those two call sites updated to pass GetModelsOptions?

// Track the in-flight request (auth-scoped providers are excluded; see above).
if (!shouldSkipCache) {
inFlightRefresh.set(provider, refreshPromise)
inFlightRefresh.set(cacheKey, refreshPromise)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IIFE runs synchronously up to its first await. If fetchModelsFromProvider resolves via a microtask before this line executes, the finally at line 359 deletes cacheKey from the map before it has ever been inserted — then this line inserts an already-resolved promise that is never evicted. Could inFlightRefresh.set(cacheKey, refreshPromise) be placed just before the IIFE is invoked rather than after?

// only when none is configured) so an as-yet-unfetched model isn't silently
// swapped for the hardcoded default. info still comes from defaults since we
// have no fetched or cached metadata for the configured model at this point.
return { id, info: this.defaultModelInfo }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous code returned { id: this.defaultModelId, info: this.defaultModelInfo } here, guaranteeing a non-empty ID. Now it returns the computed id from line 65, which is this.modelId ?? this.defaultModelId. If this.modelId is "" (empty string), ?? doesn't fall through — id stays "" and gets forwarded to the API. Should line 65 use || instead of ?? to also guard against an empty string?

// nothing is configured we return an empty ID so the picker shows "no
// selection" rather than a phantom model that does not exist on the server.
const hasModels = routerModels.litellm && Object.keys(routerModels.litellm).length > 0
const id = hasModels

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When hasModels is true but the configured model ID is absent from the list, getValidatedModelId falls back to defaultModelId. Is there a test covering this case? A mutation that removes the hasModels branch (always returning apiConfiguration.litellmModelId ?? "") would pass the current tests but silently break this validated-fallback path.

setRefreshStatus("success")
// Invalidate the react-query router models cache so
// useSelectedModel picks up the refreshed list.
queryClient.invalidateQueries({ queryKey: ["routerModels"] })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query key in useRouterModels is ["routerModels", provider || "all"] (two elements). Passing ["routerModels"] here uses React Query v5 prefix matching, which invalidates all provider subkeys — not just LiteLLM. Should this target ["routerModels", "all"] instead?


it("should use litellmDefaultModelInfo when selected model not found in routerModels", () => {
mockUseRouterModels.mockReturnValue({
data: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a test for the dynamic transition — render with a non-empty litellm map and a selected model, then re-render with an empty map and assert the selection is preserved? That seems like the primary user-visible scenario the PR is fixing (selection held during a sync that momentarily empties the list).

@@ -434,3 +434,139 @@ describe("empty cache protection", () => {
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-flight dedup test uses openrouter, whose cache key is the bare provider name. Could a companion test with a key-scoped provider (e.g. requesty) verify that two concurrent calls with different API keys each trigger their own fetch, while two calls with the same key share a single in-flight promise?

@github-actions github-actions Bot added the awaiting-author PR is waiting for the author to address requested changes label Jun 25, 2026
- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-author PR is waiting for the author to address requested changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] LiteLLM provider loses model selection and falls back to non-existent model after "Sync Models"

3 participants