From 9c84758fc940dc69977efe7f108c2ca5f20fd942 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sat, 25 Jul 2026 22:47:32 +0300 Subject: [PATCH 1/3] refactor(api): use canonical model cache provider identifiers --- .../fetchers/__tests__/modelCache.spec.ts | 80 +++++++++++++++++++ src/api/providers/fetchers/modelCache.ts | 71 ++++++++-------- 2 files changed, 116 insertions(+), 35 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 23586b1364..cac3beff3c 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -59,6 +59,7 @@ vi.mock("../../../core/config/ContextProxy", () => ({ // Then imports import type { Mock } from "vitest" +import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" import NodeCache from "node-cache" import { getModels, getModelsFromCache } from "../modelCache" @@ -119,6 +120,31 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches OpenRouter through its canonical provider identifier", async () => { + const identifiers = providerIdentifiers as Record + const originalIdentifier = identifiers.openrouter + const canonicalIdentifier = "canonical-openrouter" + const mockModels = { + "openrouter/canonical-model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + try { + identifiers.openrouter = canonicalIdentifier + mockGetOpenRouterModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: canonicalIdentifier as any }) + + expect(mockGetOpenRouterModels).toHaveBeenCalled() + expect(result).toEqual(mockModels) + } finally { + identifiers.openrouter = originalIdentifier + } + }) + it("calls getRequestyModels with optional API key", async () => { const mockModels = { "requesty/model": { @@ -136,6 +162,35 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches credentialed fetchers through canonical provider identifiers", async () => { + const identifiers = providerIdentifiers as Record + const originalIdentifier = identifiers.requesty + const canonicalIdentifier = "canonical-requesty" + const mockModels = { + "requesty/canonical-model": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + }, + } + + try { + identifiers.requesty = canonicalIdentifier + mockGetRequestyModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: canonicalIdentifier as any, + apiKey: DUMMY_REQUESTY_KEY, + baseUrl: "https://router.requesty.ai/v1", + }) + + expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY) + expect(result).toEqual(mockModels) + } finally { + identifiers.requesty = originalIdentifier + } + }) + it("calls getKenariModels with optional API key", async () => { const mockModels = { "glm-5-2": { @@ -241,6 +296,31 @@ describe("getModelsFromCache disk fallback", () => { expect(fsSync.existsSync).not.toHaveBeenCalled() }) + it("isolates authenticated users through the canonical Zoo Gateway identifier", () => { + const identifiers = providerIdentifiers as Record + const originalIdentifier = identifiers.zooGateway + const canonicalIdentifier = "canonical-zoo-gateway" + const previousUserModels = { + "previous-user/model": { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + try { + identifiers.zooGateway = canonicalIdentifier + mockCache.get.mockReturnValue(previousUserModels) + + const result = getModelsFromCache(canonicalIdentifier as any) + + expect(result).toBeUndefined() + expect(mockCache.get).not.toHaveBeenCalled() + } finally { + identifiers.zooGateway = originalIdentifier + } + }) + it("returns disk cache data when memory cache misses and context is available", () => { // Note: This test validates the logic but the ContextProxy mock in test environment // returns undefined for getCacheDirectoryPathSync, which is expected behavior diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f56ddba34f..696d3c5338 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -7,7 +7,7 @@ import NodeCache from "node-cache" import { z } from "zod" import type { ProviderName, ModelRecord } from "@roo-code/types" -import { modelInfoSchema, TelemetryEventName } from "@roo-code/types" +import { modelInfoSchema, providerIdentifiers, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -43,37 +43,32 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) // deduplicate each other's in-flight refreshes. const inFlightRefresh = new Map>() -// Providers whose model lists are scoped to the signed-in user (e.g. per-account -// allowlists or org policies). For these we MUST NOT cache results on disk or -// in memory: a sign-in/out cycle could otherwise serve a previous user's model -// list to the next user, and stale data could mask backend allowlist updates. -const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set(["zoo-gateway", "kimi-code"]) - // Providers whose model list is determined by the server URL, not just by the provider name. // Each unique baseUrl must be cached independently so that switching endpoints never serves // stale results from a previously-cached server. const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", - "poe", - "deepseek", - "moonshot", - "ollama", - "lmstudio", - "requesty", + providerIdentifiers.litellm, + providerIdentifiers.poe, + providerIdentifiers.deepseek, + providerIdentifiers.moonshot, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.requesty, ]) // Providers where the API key itself determines which models are visible (e.g. per-key // allowlists). For these the cache key also includes a short hash of // the API key so that two different keys on the same server never share a cache entry. const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", // Per-key model allowlists are a first-class LiteLLM proxy feature - "poe", // Per-account model availability - "requesty", // Per-account custom model policies - "moonshot", // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) + providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature + providerIdentifiers.poe, // Per-account model availability + providerIdentifiers.requesty, // Per-account custom model policies + providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) ]) function isAuthScopedProvider(provider: RouterName): boolean { - return AUTH_SCOPED_PROVIDERS.has(provider) + // Signed-in model lists must never cross users through memory, disk, or in-flight caching. + return provider === providerIdentifiers.zooGateway || provider === providerIdentifiers.kimiCode } // Memoize derived digests so the deliberately-structureless KDF runs at most once per @@ -191,47 +186,47 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { setTimeout(async () => { // Providers that work without API keys const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [ - { provider: "openrouter", options: { provider: "openrouter" } }, - { provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { + provider: providerIdentifiers.openrouter, + options: { provider: providerIdentifiers.openrouter }, + }, + { + provider: providerIdentifiers.vercelAiGateway, + options: { provider: providerIdentifiers.vercelAiGateway }, + }, ] // Refresh each provider in background (fire and forget) From 058bb631d55b4a491ae77afbc60221c3970570d3 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 26 Jul 2026 19:33:12 +0300 Subject: [PATCH 2/3] test(api): address model cache review feedback --- .../fetchers/__tests__/modelCache.spec.ts | 154 +++++++++--------- src/eslint-suppressions.json | 5 - 2 files changed, 73 insertions(+), 86 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index cac3beff3c..169e4a0efa 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -58,7 +58,7 @@ vi.mock("../../../core/config/ContextProxy", () => ({ })) // Then imports -import type { Mock } from "vitest" +import type { Mock, Mocked } from "vitest" import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" import NodeCache from "node-cache" @@ -94,7 +94,7 @@ describe("getModels with new GetModelsOptions", () => { mockGetLiteLLMModels.mockResolvedValue(mockModels) const result = await getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }) @@ -114,16 +114,13 @@ describe("getModels with new GetModelsOptions", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(mockGetOpenRouterModels).toHaveBeenCalled() expect(result).toEqual(mockModels) }) it("dispatches OpenRouter through its canonical provider identifier", async () => { - const identifiers = providerIdentifiers as Record - const originalIdentifier = identifiers.openrouter - const canonicalIdentifier = "canonical-openrouter" const mockModels = { "openrouter/canonical-model": { maxTokens: 8192, @@ -132,17 +129,12 @@ describe("getModels with new GetModelsOptions", () => { }, } - try { - identifiers.openrouter = canonicalIdentifier - mockGetOpenRouterModels.mockResolvedValue(mockModels) + mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: canonicalIdentifier as any }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) - expect(mockGetOpenRouterModels).toHaveBeenCalled() - expect(result).toEqual(mockModels) - } finally { - identifiers.openrouter = originalIdentifier - } + expect(mockGetOpenRouterModels).toHaveBeenCalled() + expect(result).toEqual(mockModels) }) it("calls getRequestyModels with optional API key", async () => { @@ -156,16 +148,13 @@ describe("getModels with new GetModelsOptions", () => { } mockGetRequestyModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY }) + const result = await getModels({ provider: providerIdentifiers.requesty, apiKey: DUMMY_REQUESTY_KEY }) expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY) expect(result).toEqual(mockModels) }) it("dispatches credentialed fetchers through canonical provider identifiers", async () => { - const identifiers = providerIdentifiers as Record - const originalIdentifier = identifiers.requesty - const canonicalIdentifier = "canonical-requesty" const mockModels = { "requesty/canonical-model": { maxTokens: 4096, @@ -174,21 +163,16 @@ describe("getModels with new GetModelsOptions", () => { }, } - try { - identifiers.requesty = canonicalIdentifier - mockGetRequestyModels.mockResolvedValue(mockModels) + mockGetRequestyModels.mockResolvedValue(mockModels) - const result = await getModels({ - provider: canonicalIdentifier as any, - apiKey: DUMMY_REQUESTY_KEY, - baseUrl: "https://router.requesty.ai/v1", - }) + const result = await getModels({ + provider: providerIdentifiers.requesty, + apiKey: DUMMY_REQUESTY_KEY, + baseUrl: "https://router.requesty.ai/v1", + }) - expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY) - expect(result).toEqual(mockModels) - } finally { - identifiers.requesty = originalIdentifier - } + expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY) + expect(result).toEqual(mockModels) }) it("calls getKenariModels with optional API key", async () => { @@ -202,7 +186,7 @@ describe("getModels with new GetModelsOptions", () => { } mockGetKenariModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "kenari", apiKey: "kenari-key-for-testing" }) + const result = await getModels({ provider: providerIdentifiers.kenari, apiKey: "kenari-key-for-testing" }) expect(mockGetKenariModels).toHaveBeenCalledWith("kenari-key-for-testing") expect(result).toEqual(mockModels) @@ -214,7 +198,7 @@ describe("getModels with new GetModelsOptions", () => { await expect( getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }), @@ -233,7 +217,7 @@ describe("getModels with new GetModelsOptions", () => { mockGetMoonshotModels.mockResolvedValue(mockModels) const result = await getModels({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "test-key", baseUrl: "https://api.moonshot.ai/v1", }) @@ -245,7 +229,7 @@ describe("getModels with new GetModelsOptions", () => { it("validates exhaustive provider checking with unknown provider", async () => { // This test ensures TypeScript catches unknown providers at compile time // In practice, the discriminated union should prevent this at compile time - const unknownProvider = "unknown" as any + const unknownProvider = "unknown" as typeof providerIdentifiers.openrouter await expect( getModels({ @@ -256,13 +240,13 @@ describe("getModels with new GetModelsOptions", () => { }) describe("getModelsFromCache disk fallback", () => { - let mockCache: any + let mockCache: Mocked beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) // Reset memory cache to always miss mockCache.get.mockReturnValue(undefined) // Reset fs mocks @@ -273,7 +257,7 @@ describe("getModelsFromCache disk fallback", () => { it("returns undefined when both memory and disk cache miss", () => { vi.mocked(fsSync.existsSync).mockReturnValue(false) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() }) @@ -289,7 +273,7 @@ describe("getModelsFromCache disk fallback", () => { mockCache.get.mockReturnValue(memoryModels) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toEqual(memoryModels) // Disk should not be checked when memory cache hits @@ -297,9 +281,6 @@ describe("getModelsFromCache disk fallback", () => { }) it("isolates authenticated users through the canonical Zoo Gateway identifier", () => { - const identifiers = providerIdentifiers as Record - const originalIdentifier = identifiers.zooGateway - const canonicalIdentifier = "canonical-zoo-gateway" const previousUserModels = { "previous-user/model": { maxTokens: 4096, @@ -308,17 +289,12 @@ describe("getModelsFromCache disk fallback", () => { }, } - try { - identifiers.zooGateway = canonicalIdentifier - mockCache.get.mockReturnValue(previousUserModels) + mockCache.get.mockReturnValue(previousUserModels) - const result = getModelsFromCache(canonicalIdentifier as any) + const result = getModelsFromCache(providerIdentifiers.zooGateway) - expect(result).toBeUndefined() - expect(mockCache.get).not.toHaveBeenCalled() - } finally { - identifiers.zooGateway = originalIdentifier - } + expect(result).toBeUndefined() + expect(mockCache.get).not.toHaveBeenCalled() }) it("returns disk cache data when memory cache misses and context is available", () => { @@ -337,7 +313,7 @@ describe("getModelsFromCache disk fallback", () => { vi.mocked(fsSync.existsSync).mockReturnValue(true) vi.mocked(fsSync.readFileSync).mockReturnValue(JSON.stringify(diskModels)) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) // In the test environment, ContextProxy.instance may not be fully initialized, // so getCacheDirectoryPathSync returns undefined and disk cache is not attempted @@ -352,7 +328,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -366,7 +342,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -376,15 +352,15 @@ describe("getModelsFromCache disk fallback", () => { }) describe("empty cache protection", () => { - let mockCache: any - let mockGet: Mock - let mockSet: Mock + let mockCache: Mocked + let mockGet: Mocked["get"] + let mockSet: Mocked["set"] beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockGet = mockCache.get mockSet = mockCache.set // Reset memory cache to always miss by default @@ -396,7 +372,7 @@ describe("empty cache protection", () => { // API returns empty object (simulating failure) mockGetOpenRouterModels.mockResolvedValue({}) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -414,7 +390,7 @@ describe("empty cache protection", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(result).toEqual(mockModels) expect(mockSet).toHaveBeenCalledWith("openrouter", mockModels) @@ -438,7 +414,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache, not empty expect(result).toEqual(existingModels) @@ -468,7 +444,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue(newModels) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return new models expect(result).toEqual(newModels) @@ -490,7 +466,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache on error expect(result).toEqual(existingModels) @@ -501,7 +477,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty when no cache and API fails expect(result).toEqual({}) @@ -514,7 +490,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -542,8 +518,8 @@ describe("empty cache protection", () => { const { refreshModels } = await import("../modelCache") // Start two concurrent refresh calls - const promise1 = refreshModels({ provider: "openrouter" }) - const promise2 = refreshModels({ provider: "openrouter" }) + const promise1 = refreshModels({ provider: providerIdentifiers.openrouter }) + const promise2 = refreshModels({ provider: providerIdentifiers.openrouter }) // API should only be called once (second call reuses in-flight request) expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(1) @@ -575,8 +551,8 @@ describe("empty cache protection", () => { // Different keys -> separate compound keys -> two distinct fetches. const [a, b] = await Promise.all([ - refreshModels({ provider: "requesty", apiKey: "key-one" }), - refreshModels({ provider: "requesty", apiKey: "key-two" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-one" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-two" }), ]) expect(mockGetRequestyModels).toHaveBeenCalledTimes(2) expect(a).toEqual(mockModels) @@ -592,8 +568,8 @@ describe("empty cache protection", () => { }), ) - const shared1 = refreshModels({ provider: "requesty", apiKey: "same-key" }) - const shared2 = refreshModels({ provider: "requesty", apiKey: "same-key" }) + const shared1 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) + const shared2 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) expect(mockGetRequestyModels).toHaveBeenCalledTimes(1) @@ -609,10 +585,10 @@ describe("key-scoped cache key derivation", () => { // Exercises the per-API-key cache discriminator that all KEY_SCOPED_PROVIDERS share. // Requesty is used only because it is a key-scoped provider with a mocked fetcher; the // behavior under test is provider-agnostic. - const keyScopedProvider = "requesty" as const + const keyScopedProvider = providerIdentifiers.requesty - let mockCache: any - let mockSet: Mock + let mockCache: Mocked + let mockSet: Mocked["set"] const mockModels = { "key-scoped/model": { @@ -626,7 +602,7 @@ describe("key-scoped cache key derivation", () => { beforeEach(() => { vi.clearAllMocks() const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockCache.get.mockReturnValue(undefined) mockSet = mockCache.set mockGetRequestyModels.mockResolvedValue(mockModels) @@ -707,7 +683,11 @@ describe("compound cache key derivation across scoping dimensions", () => { } it("includes both the server URL and the key discriminator for url+key-scoped providers", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const cacheKey = writtenCacheKey() // Expected shape: provider:url:keyDiscriminator @@ -715,18 +695,26 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("normalizes trailing slashes in the server URL so equivalent URLs share a cache key", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000/" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000/", + }) const withSlash = writtenCacheKey() mockSet.mockClear() - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const withoutSlash = writtenCacheKey() expect(withSlash).toEqual(withoutSlash) }) it("includes only the server URL when a url-scoped provider has no API key", async () => { - await getModels({ provider: "litellm", baseUrl: "http://host:4000" }) + await getModels({ provider: providerIdentifiers.litellm, baseUrl: "http://host:4000" }) const cacheKey = writtenCacheKey() // No trailing key discriminator when apiKey is absent. @@ -734,7 +722,11 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => { - await getModels({ provider: "openrouter", apiKey: "ignored-key", baseUrl: "http://ignored:4000" }) + await getModels({ + provider: providerIdentifiers.openrouter, + apiKey: "ignored-key", + baseUrl: "http://ignored:4000", + }) const cacheKey = writtenCacheKey() expect(cacheKey).toBe("openrouter") diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index a0f00c04e3..5774c7768d 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -349,11 +349,6 @@ "count": 3 } }, - "api/providers/fetchers/__tests__/modelCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 From 7aa2062ea4c4cf65f93e71c5801767398a13b42b Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 26 Jul 2026 19:38:55 +0300 Subject: [PATCH 3/3] refactor(api): retain auth-scoped provider set --- src/api/providers/fetchers/modelCache.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 696d3c5338..af956548ef 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -66,9 +66,17 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) ]) +// Providers whose model lists are scoped to the signed-in user (e.g. per-account +// allowlists or org policies). For these we MUST NOT cache results on disk or +// in memory: a sign-in/out cycle could otherwise serve a previous user's model +// list to the next user, and stale data could mask backend allowlist updates. +const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set([ + providerIdentifiers.zooGateway, + providerIdentifiers.kimiCode, +]) + function isAuthScopedProvider(provider: RouterName): boolean { - // Signed-in model lists must never cross users through memory, disk, or in-flight caching. - return provider === providerIdentifiers.zooGateway || provider === providerIdentifiers.kimiCode + return AUTH_SCOPED_PROVIDERS.has(provider) } // Memoize derived digests so the deliberately-structureless KDF runs at most once per