Skip to content

Commit 58ec401

Browse files
committed
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 2f37af8 commit 58ec401

7 files changed

Lines changed: 123 additions & 7 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,52 @@ 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+
})
435481
})
436482
})
437483

src/api/providers/fetchers/modelCache.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,13 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
310310
}
311311
}
312312

313-
// Create the refresh promise and track it
313+
// Create the refresh promise and track it.
314+
//
315+
// The `finally` cleanup below runs only after the first `await` inside this async
316+
// function yields, which cannot happen until the current synchronous run -- including
317+
// the `inFlightRefresh.set(cacheKey, ...)` registration below -- has completed. So the
318+
// entry is always present in the map before `finally` can delete it; the registration
319+
// can never be lost to a microtask race even if the fetch resolves immediately.
314320
const refreshPromise = (async (): Promise<ModelRecord> => {
315321
try {
316322
// Force fresh API fetch - skip getModelsFromCache() check

src/api/providers/lm-studio.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
170170
}
171171

172172
override getModel(): { id: string; info: ModelInfo } {
173-
const models = getModelsFromCache("lmstudio")
173+
const models = getModelsFromCache({
174+
provider: "lmstudio",
175+
baseUrl: this.options.lmStudioBaseUrl,
176+
})
174177
if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) {
175178
return {
176179
id: this.options.lmStudioModelId,

src/api/providers/poe.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler
3838

3939
override getModel() {
4040
const id = this.options.apiModelId ?? poeDefaultModelId
41-
const cached = getModelsFromCache("poe")
41+
const cached = getModelsFromCache({
42+
provider: "poe",
43+
apiKey: this.options.poeApiKey,
44+
baseUrl: this.options.poeBaseUrl,
45+
})
4246
const info: ModelInfo = cached?.[id] ?? getPoeDefaultModelInfo()
4347
return { id, info }
4448
}

src/api/providers/router-provider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,13 @@ export abstract class RouterProvider extends BaseProvider {
6262
}
6363

6464
override getModel(): { id: string; info: ModelInfo } {
65-
const id = this.modelId ?? this.defaultModelId
65+
// Use `||` (not `??`) so an empty-string modelId also falls back to the default,
66+
// guaranteeing a non-empty id rather than forwarding "" to the API as an invalid
67+
// request. Note this guarantees non-empty, not viable: defaultModelId is provider-
68+
// supplied and may not be a model that actually exists on the user's server (e.g.
69+
// OpenAI-compatible have no inherent default), so a configured-but-empty selection
70+
// can still resolve to a model the server rejects.
71+
const id = this.modelId || this.defaultModelId
6672

6773
// First check instance models (populated by fetchModel)
6874
if (this.models[id]) {

webview-ui/src/components/settings/providers/LiteLLM.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ export const LiteLLM = ({
5757
if (refreshStatus === "loading") {
5858
if (!litellmErrorJustReceived.current) {
5959
setRefreshStatus("success")
60-
// Invalidate the react-query router models cache so
61-
// useSelectedModel picks up the refreshed list.
62-
queryClient.invalidateQueries({ queryKey: ["routerModels"] })
60+
// Invalidate only the LiteLLM router-models query so useSelectedModel
61+
// picks up the refreshed list. useSelectedModel reads LiteLLM under the
62+
// compound key ["routerModels", "litellm"] (see useRouterModels), so we
63+
// target that exact key rather than the bare ["routerModels"] prefix,
64+
// which would needlessly invalidate every other provider's query too.
65+
queryClient.invalidateQueries({ queryKey: ["routerModels", "litellm"] })
6366
}
6467
// If litellmErrorJustReceived.current is true, status is already (or will be) "error".
6568
}

webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,54 @@ describe("useSelectedModel", () => {
602602
expect(result.current.info).toEqual(litellmDefaultModelInfo)
603603
})
604604

605+
it("preserves the selected model when the list transitions from populated to empty", () => {
606+
// Primary user-visible scenario: a "Sync Models" click momentarily empties the
607+
// router-models list before the refreshed list arrives. The selection must be held
608+
// across that transition rather than reset.
609+
mockUseRouterModels.mockReturnValue({
610+
data: {
611+
openrouter: {},
612+
requesty: {},
613+
litellm: {
614+
"my-custom-model": {
615+
maxTokens: 4096,
616+
contextWindow: 8192,
617+
supportsImages: false,
618+
supportsPromptCache: false,
619+
},
620+
},
621+
},
622+
isLoading: false,
623+
isError: false,
624+
} as any)
625+
626+
const apiConfiguration: ProviderSettings = {
627+
apiProvider: "litellm",
628+
litellmModelId: "my-custom-model",
629+
}
630+
631+
const wrapper = createWrapper()
632+
const { result, rerender } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
633+
634+
// Initially the configured model resolves from the populated list.
635+
expect(result.current.id).toBe("my-custom-model")
636+
637+
// Simulate the list emptying mid-sync.
638+
mockUseRouterModels.mockReturnValue({
639+
data: {
640+
openrouter: {},
641+
requesty: {},
642+
litellm: {},
643+
},
644+
isLoading: false,
645+
isError: false,
646+
} as any)
647+
rerender()
648+
649+
// Selection is preserved through the empty window.
650+
expect(result.current.id).toBe("my-custom-model")
651+
})
652+
605653
it("should use litellmDefaultModelInfo when selected model not found in routerModels", () => {
606654
mockUseRouterModels.mockReturnValue({
607655
data: {

0 commit comments

Comments
 (0)