Skip to content

Commit 3b8ee40

Browse files
authored
refactor(api): use canonical model cache provider identifiers (#1020)
1 parent c52f118 commit 3b8ee40

2 files changed

Lines changed: 116 additions & 35 deletions

File tree

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock("../../../core/config/ContextProxy", () => ({
5959

6060
// Then imports
6161
import type { Mock } from "vitest"
62+
import { providerIdentifiers } from "@roo-code/types"
6263
import * as fsSync from "fs"
6364
import NodeCache from "node-cache"
6465
import { getModels, getModelsFromCache } from "../modelCache"
@@ -119,6 +120,31 @@ describe("getModels with new GetModelsOptions", () => {
119120
expect(result).toEqual(mockModels)
120121
})
121122

123+
it("dispatches OpenRouter through its canonical provider identifier", async () => {
124+
const identifiers = providerIdentifiers as Record<string, string>
125+
const originalIdentifier = identifiers.openrouter
126+
const canonicalIdentifier = "canonical-openrouter"
127+
const mockModels = {
128+
"openrouter/canonical-model": {
129+
maxTokens: 8192,
130+
contextWindow: 128000,
131+
supportsPromptCache: false,
132+
},
133+
}
134+
135+
try {
136+
identifiers.openrouter = canonicalIdentifier
137+
mockGetOpenRouterModels.mockResolvedValue(mockModels)
138+
139+
const result = await getModels({ provider: canonicalIdentifier as any })
140+
141+
expect(mockGetOpenRouterModels).toHaveBeenCalled()
142+
expect(result).toEqual(mockModels)
143+
} finally {
144+
identifiers.openrouter = originalIdentifier
145+
}
146+
})
147+
122148
it("calls getRequestyModels with optional API key", async () => {
123149
const mockModels = {
124150
"requesty/model": {
@@ -136,6 +162,35 @@ describe("getModels with new GetModelsOptions", () => {
136162
expect(result).toEqual(mockModels)
137163
})
138164

165+
it("dispatches credentialed fetchers through canonical provider identifiers", async () => {
166+
const identifiers = providerIdentifiers as Record<string, string>
167+
const originalIdentifier = identifiers.requesty
168+
const canonicalIdentifier = "canonical-requesty"
169+
const mockModels = {
170+
"requesty/canonical-model": {
171+
maxTokens: 4096,
172+
contextWindow: 8192,
173+
supportsPromptCache: false,
174+
},
175+
}
176+
177+
try {
178+
identifiers.requesty = canonicalIdentifier
179+
mockGetRequestyModels.mockResolvedValue(mockModels)
180+
181+
const result = await getModels({
182+
provider: canonicalIdentifier as any,
183+
apiKey: DUMMY_REQUESTY_KEY,
184+
baseUrl: "https://router.requesty.ai/v1",
185+
})
186+
187+
expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY)
188+
expect(result).toEqual(mockModels)
189+
} finally {
190+
identifiers.requesty = originalIdentifier
191+
}
192+
})
193+
139194
it("calls getKenariModels with optional API key", async () => {
140195
const mockModels = {
141196
"glm-5-2": {
@@ -241,6 +296,31 @@ describe("getModelsFromCache disk fallback", () => {
241296
expect(fsSync.existsSync).not.toHaveBeenCalled()
242297
})
243298

299+
it("isolates authenticated users through the canonical Zoo Gateway identifier", () => {
300+
const identifiers = providerIdentifiers as Record<string, string>
301+
const originalIdentifier = identifiers.zooGateway
302+
const canonicalIdentifier = "canonical-zoo-gateway"
303+
const previousUserModels = {
304+
"previous-user/model": {
305+
maxTokens: 4096,
306+
contextWindow: 128000,
307+
supportsPromptCache: false,
308+
},
309+
}
310+
311+
try {
312+
identifiers.zooGateway = canonicalIdentifier
313+
mockCache.get.mockReturnValue(previousUserModels)
314+
315+
const result = getModelsFromCache(canonicalIdentifier as any)
316+
317+
expect(result).toBeUndefined()
318+
expect(mockCache.get).not.toHaveBeenCalled()
319+
} finally {
320+
identifiers.zooGateway = originalIdentifier
321+
}
322+
})
323+
244324
it("returns disk cache data when memory cache misses and context is available", () => {
245325
// Note: This test validates the logic but the ContextProxy mock in test environment
246326
// returns undefined for getCacheDirectoryPathSync, which is expected behavior

src/api/providers/fetchers/modelCache.ts

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import NodeCache from "node-cache"
77
import { z } from "zod"
88

99
import type { ProviderName, ModelRecord } from "@roo-code/types"
10-
import { modelInfoSchema, TelemetryEventName } from "@roo-code/types"
10+
import { modelInfoSchema, providerIdentifiers, TelemetryEventName } from "@roo-code/types"
1111
import { TelemetryService } from "@roo-code/telemetry"
1212

1313
import { safeWriteJson } from "../../../utils/safeWriteJson"
@@ -43,37 +43,32 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema)
4343
// deduplicate each other's in-flight refreshes.
4444
const inFlightRefresh = new Map<string, Promise<ModelRecord>>()
4545

46-
// Providers whose model lists are scoped to the signed-in user (e.g. per-account
47-
// allowlists or org policies). For these we MUST NOT cache results on disk or
48-
// in memory: a sign-in/out cycle could otherwise serve a previous user's model
49-
// list to the next user, and stale data could mask backend allowlist updates.
50-
const AUTH_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set(["zoo-gateway", "kimi-code"])
51-
5246
// Providers whose model list is determined by the server URL, not just by the provider name.
5347
// Each unique baseUrl must be cached independently so that switching endpoints never serves
5448
// stale results from a previously-cached server.
5549
const URL_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set([
56-
"litellm",
57-
"poe",
58-
"deepseek",
59-
"moonshot",
60-
"ollama",
61-
"lmstudio",
62-
"requesty",
50+
providerIdentifiers.litellm,
51+
providerIdentifiers.poe,
52+
providerIdentifiers.deepseek,
53+
providerIdentifiers.moonshot,
54+
providerIdentifiers.ollama,
55+
providerIdentifiers.lmstudio,
56+
providerIdentifiers.requesty,
6357
])
6458

6559
// Providers where the API key itself determines which models are visible (e.g. per-key
6660
// allowlists). For these the cache key also includes a short hash of
6761
// the API key so that two different keys on the same server never share a cache entry.
6862
const KEY_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set([
69-
"litellm", // Per-key model allowlists are a first-class LiteLLM proxy feature
70-
"poe", // Per-account model availability
71-
"requesty", // Per-account custom model policies
72-
"moonshot", // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn)
63+
providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature
64+
providerIdentifiers.poe, // Per-account model availability
65+
providerIdentifiers.requesty, // Per-account custom model policies
66+
providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn)
7367
])
7468

7569
function isAuthScopedProvider(provider: RouterName): boolean {
76-
return AUTH_SCOPED_PROVIDERS.has(provider)
70+
// Signed-in model lists must never cross users through memory, disk, or in-flight caching.
71+
return provider === providerIdentifiers.zooGateway || provider === providerIdentifiers.kimiCode
7772
}
7873

7974
// Memoize derived digests so the deliberately-structureless KDF runs at most once per
@@ -191,47 +186,47 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
191186
let models: ModelRecord
192187

193188
switch (provider) {
194-
case "openrouter":
189+
case providerIdentifiers.openrouter:
195190
models = await getOpenRouterModels()
196191
break
197-
case "requesty":
192+
case providerIdentifiers.requesty:
198193
// Requesty models endpoint requires an API key for per-user custom policies.
199194
models = await getRequestyModels(options.baseUrl, options.apiKey)
200195
break
201-
case "unbound":
196+
case providerIdentifiers.unbound:
202197
models = await getUnboundModels(options.apiKey)
203198
break
204-
case "litellm":
199+
case providerIdentifiers.litellm:
205200
models = await getLiteLLMModels(options.apiKey ?? "", options.baseUrl)
206201
break
207-
case "ollama":
202+
case providerIdentifiers.ollama:
208203
models = await getOllamaModels(options.baseUrl, options.apiKey)
209204
break
210-
case "lmstudio":
205+
case providerIdentifiers.lmstudio:
211206
models = await getLMStudioModels(options.baseUrl)
212207
break
213-
case "vercel-ai-gateway":
208+
case providerIdentifiers.vercelAiGateway:
214209
models = await getVercelAiGatewayModels()
215210
break
216-
case "opencode-go":
211+
case providerIdentifiers.opencodeGo:
217212
models = await getOpencodeGoModels(options.apiKey)
218213
break
219-
case "kenari":
214+
case providerIdentifiers.kenari:
220215
models = await getKenariModels(options.apiKey)
221216
break
222-
case "poe":
217+
case providerIdentifiers.poe:
223218
models = await getPoeModels(options.apiKey, options.baseUrl)
224219
break
225-
case "deepseek":
220+
case providerIdentifiers.deepseek:
226221
models = await getDeepSeekModels(options.baseUrl, options.apiKey)
227222
break
228-
case "moonshot":
223+
case providerIdentifiers.moonshot:
229224
models = await getMoonshotModels(options.baseUrl, options.apiKey)
230225
break
231-
case "zoo-gateway":
226+
case providerIdentifiers.zooGateway:
232227
models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl })
233228
break
234-
case "kimi-code":
229+
case providerIdentifiers.kimiCode:
235230
models = await getKimiCodeModels(options.apiKey)
236231
break
237232
default: {
@@ -399,8 +394,14 @@ export async function initializeModelCacheRefresh(): Promise<void> {
399394
setTimeout(async () => {
400395
// Providers that work without API keys
401396
const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [
402-
{ provider: "openrouter", options: { provider: "openrouter" } },
403-
{ provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
397+
{
398+
provider: providerIdentifiers.openrouter,
399+
options: { provider: providerIdentifiers.openrouter },
400+
},
401+
{
402+
provider: providerIdentifiers.vercelAiGateway,
403+
options: { provider: providerIdentifiers.vercelAiGateway },
404+
},
404405
]
405406

406407
// Refresh each provider in background (fire and forget)

0 commit comments

Comments
 (0)