diff --git a/knip.json b/knip.json index 5f93aa1438..158ec665e0 100644 --- a/knip.json +++ b/knip.json @@ -16,6 +16,7 @@ "ignoreDependencies": ["lint-staged"], "ignoreBinaries": ["dotenvx"], "ignoreExportsUsedInFile": true, + "tags": ["-@knipignore"], "playwright": false, "playwright-ct": false, "workspaces": { diff --git a/src/api/providers/__tests__/model-catalog-adapters.spec.ts b/src/api/providers/__tests__/model-catalog-adapters.spec.ts index fdcbde7b7f..996cbad3d7 100644 --- a/src/api/providers/__tests__/model-catalog-adapters.spec.ts +++ b/src/api/providers/__tests__/model-catalog-adapters.spec.ts @@ -267,6 +267,7 @@ describe("model catalog adapters", () => { fetcher.mockReset() fetcher.mockResolvedValue(CATALOG) } + fetchers.getZooGatewayModels.mockResolvedValue({ kind: "ok", models: CATALOG }) }) describe.each(cases)("$name", ({ create, fetcher, scope, expectedArgs, forwardsSignal }) => { diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index fe088bb5c9..71785a9ec3 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -456,6 +456,83 @@ describe("ZooGatewayHandler", () => { ]) }) + it("yields reasoning chunks from delta.reasoning and delta.reasoning_content before text", async () => { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { + choices: [{ delta: { reasoning: "Let me think" }, index: 0 }], + }, + { + choices: [{ delta: { reasoning_content: " carefully", content: "answer" }, index: 0 }], + }, + ]), + ) + + const handler = new ZooGatewayHandler(mockOptions) + const chunks = await collectStream(handler.createMessage("prompt", [{ role: "user", content: "Hi" }])) + + expect(chunks).toEqual([ + { type: "reasoning", text: "Let me think" }, + { type: "reasoning", text: " carefully" }, + { type: "text", text: "answer" }, + ]) + }) + + it("preserves reasoning on follow-up requests for non-Anthropic models", () => { + const handler = new ZooGatewayHandler({ ...mockOptions, zooGatewayModelId: "reflection/beam-free" }) + + expect(handler.getModel().info.preserveReasoning).toBe(true) + }) + + it("leaves Anthropic models on their existing thinking path", () => { + const handler = new ZooGatewayHandler(mockOptions) + + expect(handler.getModel().info.preserveReasoning).toBeUndefined() + }) + + it("sends reasoning_content on the follow-up request when history has a reasoning block", async () => { + mockCreate.mockImplementation(async () => asyncStreamFrom([])) + const handler = new ZooGatewayHandler({ ...mockOptions, zooGatewayModelId: "reflection/beam-free" }) + const history = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning" as const, text: "I need to read a file.", summary: [] }, + { type: "tool_use" as const, id: "call_abc", name: "read_file", input: { path: "foo.txt" } }, + ], + }, + { + role: "user" as const, + content: [{ type: "tool_result" as const, tool_use_id: "call_abc", content: "file contents" }], + }, + ] as unknown as Parameters[1] + + await handler.createMessage("prompt", history).next() + + const assistant = mockCreate.mock.calls[0][0].messages.find( + (message: { role?: string }) => message.role === "assistant", + ) + expect(assistant.reasoning_content).toBe("I need to read a file.") + expect(assistant.tool_calls[0].id).toBe("call_abc") + expect(assistant.tool_calls[0].function.name).toBe("read_file") + }) + + it("does not yield a reasoning chunk when the delta has no reasoning text", async () => { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { + choices: [{ delta: { content: "just text" }, index: 0 }], + }, + ]), + ) + + const handler = new ZooGatewayHandler(mockOptions) + const chunks = await collectStream(handler.createMessage("prompt", [{ role: "user", content: "Hi" }])) + + expect(chunks).toEqual([{ type: "text", text: "just text" }]) + expect(chunks.some((chunk) => chunk.type === "reasoning")).toBe(false) + }) + it("throws the upstream reason when the gateway sends an in-stream error chunk", async () => { mockCreate.mockImplementation(async () => asyncStreamFrom([ diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 9df780a170..45d59285e0 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -40,6 +40,16 @@ vi.mock("fs", () => ({ readFileSync: vi.fn().mockReturnValue("{}"), })) +// Mock all the model fetchers +vi.mock("../litellm") +vi.mock("../openrouter") +vi.mock("../requesty") +vi.mock("../kenari") +vi.mock("../nanogpt") +vi.mock("../moonshot") +vi.mock("../zoo-gateway") +vi.mock("../kimi-code") + // Mock ContextProxy with a simple static instance vi.mock("../../../core/config/ContextProxy", () => ({ ContextProxy: { @@ -59,7 +69,24 @@ import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" import NodeCache from "node-cache" import { TelemetryService } from "@roo-code/telemetry" -import { getModels, getModelsFromCache } from "../modelCache" +import { + authScopedClearGenerationSizeForTests, + clearAuthSessionModelsForProvider, + flushModels, + getModels, + getModelsFromCache, + refreshModels, + resetModelCacheTransientStateForTests, +} from "../modelCache" +import { getKimiCodeModels } from "../kimi-code" +import { getZooGatewayModels } from "../zoo-gateway" + +const mockGetZooGatewayModels = getZooGatewayModels as Mock +const mockGetKimiCodeModels = getKimiCodeModels as Mock + +function zooGatewayOk(models: ModelRecord) { + return { kind: "ok" as const, models } +} import type { ApiHandler, ModelCacheScope } from "../../../index" import type { GetModelsOptions } from "../../../../shared/api" @@ -631,15 +658,10 @@ describe("empty cache protection", () => { }) it("re-arms the empty-response throttle after a non-empty response from an auth-scoped provider", async () => { - // zoo-gateway is auth-scoped and skips caching entirely, but a non-empty response - // must still clear the throttle so a later empty response is reported again. - const fetchModels = vi.fn().mockResolvedValueOnce({}) - const handler = makeHandler(ZOO_SCOPE, fetchModels) - - await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }, handler) - - expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) - + // A non-empty session catalog clears the empty-response throttle, so a later + // forced refresh that comes back empty is reported again. + resetModelCacheTransientStateForTests() + const handler = makeHandler(ZOO_SCOPE, vi.fn()) const mockModels = { "zoo-gateway/model": { maxTokens: 8192, @@ -648,19 +670,21 @@ describe("empty cache protection", () => { description: "Zoo Gateway model", }, } - fetchModels.mockResolvedValueOnce(mockModels) + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk({})) + .mockResolvedValueOnce(zooGatewayOk(mockModels)) + .mockResolvedValueOnce(zooGatewayOk({})) await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }, handler) - // Auth-scoped providers never populate the cache. - expect(mockSet).not.toHaveBeenCalled() - - fetchModels.mockResolvedValueOnce({}) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }, handler) - // The throttle should have been re-armed by the non-empty response above, so this - // second empty response is reported again instead of being suppressed. + expect(mockSet).not.toHaveBeenCalled() + + await refreshModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }, handler) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) }) @@ -899,9 +923,8 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { await freshGetModels({ provider: providerIdentifiers.openrouter }, freshOpenRouterHandler) await freshGetModels({ provider: providerIdentifiers.openrouter }, freshOpenRouterHandler) - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledWith( + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledWith( "Model Cache Empty Response", expect.objectContaining({ provider: providerIdentifiers.openrouter, context: "getModels" }), ) @@ -928,12 +951,10 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { freshOpenRouterFetch.mockResolvedValue({}) await freshGetModels({ provider: providerIdentifiers.openrouter }, freshOpenRouterHandler) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) it("throttles independently per provider", async () => { - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - freshOpenRouterFetch.mockResolvedValue({}) freshLiteLLMFetch.mockResolvedValue({}) @@ -943,7 +964,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { freshLiteLLMHandler, ) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) it("throttles empty responses from refreshModels using the same per-key gate", async () => { @@ -970,8 +991,6 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { // Two different LiteLLM servers share the "litellm" provider name but are a different // cache identity (see getCacheKey) -- an empty response from one must not suppress the // signal for the other. - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - freshLiteLLMFetch.mockResolvedValue({}) await freshGetModels( @@ -999,7 +1018,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { freshLiteLLMHandler, ) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) it("throttles zoo-gateway independently per session token, even though caching itself is skipped", async () => { @@ -1010,7 +1029,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { // throttle entry. const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - freshZooGatewayFetch.mockResolvedValue({}) + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) await freshGetModels( { provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }, @@ -1032,7 +1051,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { it("throttles zoo-gateway independently per gateway baseUrl", async () => { // Same session token, different gateway endpoint (e.g. staging vs. production) -- // must also be treated as a distinct identity for throttle purposes. - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) freshZooGatewayFetch.mockResolvedValue({}) @@ -1053,7 +1072,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { freshZooGatewayHandler, ) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) it("never shares results across different zoo-gateway credentials (auth isolation)", async () => { @@ -1079,9 +1098,9 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { }, } - let resolveA: (value: typeof accountAModels) => void - let resolveB: (value: typeof accountBModels) => void - freshZooGatewayFetch + let resolveA: (value: ReturnType) => void + let resolveB: (value: ReturnType) => void + mockGetZooGatewayModels .mockImplementationOnce( () => new Promise((resolve) => { @@ -1104,28 +1123,25 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { freshZooGatewayHandler, ) - expect(freshZooGatewayFetch).toHaveBeenCalledTimes(2) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) - resolveB!(accountBModels) - resolveA!(accountAModels) + resolveB!(zooGatewayOk(accountBModels)) + resolveA!(zooGatewayOk(accountAModels)) const [resultA, resultB] = await Promise.all([promiseA, promiseB]) expect(resultA).toEqual(accountAModels) expect(resultB).toEqual(accountBModels) }) - it("never deduplicates concurrent zoo-gateway fetches, even for the same token", async () => { - // Auth-scoped providers skip dedupedFetch unconditionally, so even two calls carrying - // an identical token each fire their own provider fetch -- there is no in-flight sharing - // to key correctly or incorrectly for these providers. - freshZooGatewayFetch.mockResolvedValue({}) + it("deduplicates concurrent zoo-gateway fetches for the same session token", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) await Promise.all([ freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }, freshZooGatewayHandler), freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }, freshZooGatewayHandler), ]) - expect(freshZooGatewayFetch).toHaveBeenCalledTimes(2) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) }) }) @@ -1350,6 +1366,953 @@ describe("compound cache key derivation across scoping dimensions", () => { }) }) +describe("auth session cache", () => { + let mockSet: Mocked["set"] + + const zooModels: ModelRecord = { + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsPromptCache: true, + }, + } + + beforeEach(() => { + resetModelCacheTransientStateForTests() + vi.clearAllMocks() + + const MockedNodeCache = vi.mocked(NodeCache) + const mockCache = vi.mocked(new MockedNodeCache()) + mockCache.get.mockReturnValue(undefined) + mockSet = mockCache.set + }) + + it("reuses in-memory session cache within TTL without refetching", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + await getModels(options, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + }) + + it("keeps the session catalog fresh one minute into the TTL window", async () => { + // Kills: AUTH_SESSION_TTL_MS arithmetic mutants (5*60/1000, 5/60) that shrink TTL below 1 minute + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + vi.advanceTimersByTime(60_000) + await getModels(options, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it("clearAuthSessionModelsForProvider does not clear a different auth-scoped provider", async () => { + // Kills: matchesProvider mutants that always-match or invert equality across providers + const kimiModels: ModelRecord = { + "kimi-for-coding": { maxTokens: 8192, contextWindow: 128000, supportsPromptCache: false }, + } + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetKimiCodeModels.mockResolvedValue(kimiModels) + + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "zoo-session" }, zooGatewayHandler) + await getModels({ provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + expect(mockGetKimiCodeModels).toHaveBeenCalledTimes(1) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "zoo-session" }, zooGatewayHandler) + await getModels({ provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + expect(mockGetKimiCodeModels).toHaveBeenCalledTimes(1) + }) + + it("keeps prior catalog when a refresh returns empty", async () => { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)).mockResolvedValueOnce(zooGatewayOk({})) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + const refreshed = await refreshModels(options, zooGatewayHandler) + + expect(refreshed).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("clears session cache on sign-out helper so the same identity refetches", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + await getModels(options, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("never writes auth-scoped catalogs to the shared memory cache", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-token" }, zooGatewayHandler) + + expect(mockSet).not.toHaveBeenCalled() + }) + + it("clearAuthSessionModelsForProvider prevents a resolved in-flight fetch from repopulating the cache", async () => { + // Regression guard: an in-flight fetch that resolves after clearAuthSessionModelsForProvider + // must not repopulate the session cache (which would leak a prior session's catalog). + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // Fetch and populate cache + await getModels(options, zooGatewayHandler) + + // Sign out — clears cache and in-flight map + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + // A new getModels call after sign-out must refetch (cache was cleared) + await getModels(options, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("in-flight fetch started before sign-out does not repopulate cache when a new fetch starts after sign-out", async () => { + // Race condition: fetch A starts → sign-out → fetch B starts → fetch A resolves. + // Fetch A must NOT write back its stale pre-sign-out data because the generation counter + // was bumped by sign-out. This test simulates the race using deferred promises. + const staleModels: ModelRecord = { + "stale/model": { maxTokens: 1000, contextWindow: 1000, supportsPromptCache: false }, + } + const freshModelsAfterSignOut: ModelRecord = { + "fresh/model": { maxTokens: 2000, contextWindow: 2000, supportsPromptCache: false }, + } + + type ZooGatewayResult = Awaited> + let resolveFetchA!: (value: ZooGatewayResult) => void + const fetchAPromise = new Promise((resolve) => { + resolveFetchA = resolve + }) + + let resolveFetchB!: (value: ZooGatewayResult) => void + const fetchBPromise = new Promise((resolve) => { + resolveFetchB = resolve + }) + + // First call returns fetchAPromise (hangs until we resolve) + // Second call returns fetchBPromise (hangs until we resolve) + mockGetZooGatewayModels.mockReturnValueOnce(fetchAPromise).mockReturnValueOnce(fetchBPromise) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // 1. Start fetch A (pre-sign-out) + const fetchAResult = getModels(options, zooGatewayHandler) + + // 2. Sign out — clears cache and bumps generation + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + // 3. Start fetch B (post-sign-out) — this registers a NEW in-flight promise + const fetchBResult = getModels(options, zooGatewayHandler) + + // 4. Resolve fetch A with stale data — it should NOT write to cache + resolveFetchA(zooGatewayOk(staleModels)) + await fetchAResult + + // 5. Resolve fetch B with fresh data — it SHOULD write to cache + resolveFetchB(zooGatewayOk(freshModelsAfterSignOut)) + const result = await fetchBResult + + // The result should be the fresh post-sign-out data, not the stale pre-sign-out data + expect(result).toEqual(freshModelsAfterSignOut) + + // Verify a subsequent getModels call returns the fresh data (proving fetch A didn't overwrite) + // Don't set a new mock — the call should be served from cache + const subsequent = await getModels(options, zooGatewayHandler) + expect(subsequent).toEqual(freshModelsAfterSignOut) + // Should be served from cache, not trigger a new fetch (only 2 fetches: A and B) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + + // Clean up — clear cache so subsequent tests don't see stale entries + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + }) + + it("returns cached entry within TTL without hitting the provider", async () => { + // Kills: AUTH_SESSION_TTL_MS arithmetic mutant (5*60/1000 would expire in <1ms) + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + // Advance by 1 second — well within the 5-minute TTL + vi.advanceTimersByTime(1000) + const second = await getModels(options, zooGatewayHandler) + + expect(second).toEqual(zooModels) + // Only one fetch — the second call was served from cache + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it("treats an entry as stale at exactly the TTL boundary", async () => { + // Kills: < vs <= equality-operator mutant on isAuthSessionFresh + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + // Advance to exactly TTL — entry is now stale (< not <=) + vi.advanceTimersByTime(5 * 60 * 1000) + await getModels(options, zooGatewayHandler) + + // Must have refetched — entry was stale at exact TTL + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("staleCutoffMs is 2x AUTH_SESSION_TTL_MS not 1x", async () => { + // Kills: AUTH_SESSION_TTL_MS * 2 → / 2 arithmetic mutant. + // An entry at exactly 1x TTL + 1ms is stale but NOT yet at the 2x prune threshold; + // it must still be in the map (so a concurrent get with a fresh ETag can still + // re-use the old etag for conditional revalidation). The existing + // "prunes session entries older than twice the TTL" test covers the >=2x case. + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const optionsA = { provider: providerIdentifiers.zooGateway, apiKey: "session-a" } + const optionsB = { provider: providerIdentifiers.zooGateway, apiKey: "session-b" } + + // First fetch returns an etag so we can detect it on the revalidation call + mockGetZooGatewayModels.mockResolvedValueOnce({ kind: "ok", models: zooModels, etag: '"v1"' }) + await getModels(optionsA, zooGatewayHandler) + + // Advance to 1x TTL + 1ms — stale but below 2x cutoff + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + // Adding a second entry triggers enforceAuthSessionCacheBound → pruneExpiredAuthSessionEntries + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + await getModels(optionsB, zooGatewayHandler) + + // session-a is below 2x TTL so it is NOT pruned. + // It IS stale (1x TTL elapsed), so the next getModels call will issue a revalidation + // request WITH the etag — confirming the entry is still in the cache. + mockGetZooGatewayModels.mockClear() + mockGetZooGatewayModels.mockResolvedValue({ kind: "ok", models: zooModels, etag: '"v2"' }) + await getModels(optionsA, zooGatewayHandler) + + // The etag from the prior fetch must have been sent (entry was not pruned) + expect(mockGetZooGatewayModels).toHaveBeenCalledWith(expect.objectContaining({ ifNoneMatch: '"v1"' })) + } finally { + vi.useRealTimers() + } + }) + + it("notModified branch returns existing models when current entry is non-empty", async () => { + // Kills: ConditionalExpression mutant (false) on `if (current && Object.keys(current.models).length > 0)` + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce({ kind: "ok", models: zooModels, etag: '"v1"' }) + .mockResolvedValueOnce({ kind: "not_modified" }) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + await getModels(options, zooGatewayHandler) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options, zooGatewayHandler) + + // If the condition were false, touchAuthSessionEntry wouldn't fire and result might differ + expect(result).toEqual(zooModels) + } finally { + vi.useRealTimers() + } + }) + + it("falls back to existing models on empty response after having a prior non-empty catalog", async () => { + // Kills: ConditionalExpression mutant (false) on `if (existing && Object.keys(existing.models).length > 0)` fallback + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk(zooModels)) + .mockResolvedValueOnce(zooGatewayOk({})) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + await getModels(options, zooGatewayHandler) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options, zooGatewayHandler) + + // If condition were false, {} would be returned — but existing catalog should survive + expect(result).toEqual(zooModels) + } finally { + vi.useRealTimers() + } + }) + + it("forceRefresh bypasses the fresh-cache short-circuit", async () => { + // Kills: ConditionalExpression mutant (true) on `!forceRefresh` in cache-hit guard + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + // Entry is fresh — without forceRefresh the second call would be served from cache + await refreshModels(options, zooGatewayHandler) + + // forceRefresh must bypass the cache and issue a second fetch + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("concurrent getModels calls for the same session key share one fetch", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + const [first, second] = await Promise.all([ + getModels(options, zooGatewayHandler), + getModels(options, zooGatewayHandler), + ]) + + expect(first).toEqual(zooModels) + expect(second).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + }) + + it("revalidates with ETag after TTL expires and keeps the prior catalog on 304", async () => { + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce({ kind: "ok", models: zooModels, etag: '"v1"' }) + .mockResolvedValueOnce({ kind: "not_modified" }) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + await getModels(options, zooGatewayHandler) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const second = await getModels(options, zooGatewayHandler) + + expect(second).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + expect(mockGetZooGatewayModels).toHaveBeenLastCalledWith(expect.objectContaining({ ifNoneMatch: '"v1"' })) + + // A third call within the refreshed TTL window must not trigger another fetch, + // proving touchAuthSessionEntry actually updated fetchedAt. + const third = await getModels(options, zooGatewayHandler) + expect(third).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("flushModels clears session cache and can force a refetch", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + await flushModels(options, zooGatewayHandler, true) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("returns the prior session catalog when a fetch throws", async () => { + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk(zooModels)) + .mockRejectedValueOnce(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + const second = await getModels(options, zooGatewayHandler) + + expect(second).toEqual(zooModels) + } finally { + vi.useRealTimers() + } + }) + + it("getModels throws on the first call when the provider fetch fails and there is no prior cache", async () => { + mockGetZooGatewayModels.mockRejectedValue(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await expect(getModels(options, zooGatewayHandler)).rejects.toThrow("network down") + }) + + it("refreshModels returns an empty catalog when refresh throws and no session cache exists", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockGetZooGatewayModels.mockRejectedValue(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + const refreshed = await refreshModels(options, zooGatewayHandler) + + expect(refreshed).toEqual({}) + // Must be the auth-scoped catch (original provider error), not fallthrough into + // fetchModelsFromProvider which throws a different "must not be called" error. + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[refreshModels] Failed to refresh"), + expect.objectContaining({ message: "network down" }), + ) + consoleSpy.mockRestore() + }) + + it("refreshModels keeps the prior session catalog when refresh throws", async () => { + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk(zooModels)) + .mockRejectedValueOnce(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + const refreshed = await refreshModels(options, zooGatewayHandler) + + expect(refreshed).toEqual(zooModels) + }) + + it("caches kimi-code catalogs in the session store", async () => { + const kimiModels: ModelRecord = { + "kimi-for-coding": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + mockGetKimiCodeModels.mockResolvedValue(kimiModels) + const options = { provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" } + + await getModels(options, zooGatewayHandler) + await getModels(options, zooGatewayHandler) + + expect(mockGetKimiCodeModels).toHaveBeenCalledTimes(1) + expect(mockSet).not.toHaveBeenCalled() + }) + + it("flushModels without refresh evicts the session cache until the next fetch", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + await flushModels(options, zooGatewayHandler) + await getModels(options, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("flushModels for non-auth providers deletes the shared memory cache entry", async () => { + const MockedNodeCache = vi.mocked(NodeCache) + const mockCache = vi.mocked(new MockedNodeCache()) + const mockDel = mockCache.del + const fetchModels = vi.fn().mockResolvedValue({ + "openrouter/model": { maxTokens: 8192, contextWindow: 128000, supportsPromptCache: false }, + }) + const handler = makeHandler(OPENROUTER_SCOPE, fetchModels) + + await getModels({ provider: providerIdentifiers.openrouter }, handler) + await flushModels({ provider: providerIdentifiers.openrouter }, handler) + + expect(mockDel).toHaveBeenCalledWith("openrouter") + }) + + it("returns an empty catalog when revalidation is 304 and no session cache exists", async () => { + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk(zooModels)) + .mockResolvedValueOnce({ kind: "not_modified" }) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + await getModels(options, zooGatewayHandler) + await flushModels(options, zooGatewayHandler) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options, zooGatewayHandler) + + expect(result).toEqual({}) + } finally { + vi.useRealTimers() + } + }) + + it("returns an empty catalog on the first empty zoo-gateway response", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + const result = await getModels(options, zooGatewayHandler) + + expect(result).toEqual({}) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledWith( + "Model Cache Empty Response", + expect.objectContaining({ + provider: providerIdentifiers.zooGateway, + context: "getModels", + hasExistingCache: false, + }), + ) + }) + + it("reports empty refreshModels context and existingCacheSize when a prior catalog exists", async () => { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)).mockResolvedValueOnce(zooGatewayOk({})) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + await refreshModels(options, zooGatewayHandler) + + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledWith( + "Model Cache Empty Response", + expect.objectContaining({ + provider: providerIdentifiers.zooGateway, + context: "refreshModels", + hasExistingCache: true, + existingCacheSize: 1, + }), + ) + }) + + it("re-arms empty-response telemetry after a 304 touch clears the throttle", async () => { + vi.useFakeTimers() + try { + mockGetZooGatewayModels + .mockResolvedValueOnce({ kind: "ok", models: zooModels, etag: '"v1"' }) + .mockResolvedValueOnce(zooGatewayOk({})) + .mockResolvedValueOnce({ kind: "not_modified" }) + .mockResolvedValueOnce(zooGatewayOk({})) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + await getModels(options, zooGatewayHandler) // seed session + etag + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options, zooGatewayHandler) // empty → arm throttle (telemetry #1) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options, zooGatewayHandler) // 304 touch must delete throttle via reportedEmptyModelResponse.delete + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options, zooGatewayHandler) // empty again must report (telemetry #2) + + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenLastCalledWith( + "Model Cache Empty Response", + expect.objectContaining({ + provider: providerIdentifiers.zooGateway, + context: "getModels", + hasExistingCache: true, + }), + ) + } finally { + vi.useRealTimers() + } + }) + + it("clearAuthSessionModelsForProvider removes compound cache keys", async () => { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + + await getModels( + { + provider: providerIdentifiers.zooGateway, + apiKey: "account-a", + baseUrl: "https://gateway-a.test/v1", + }, + zooGatewayHandler, + ) + await getModels( + { + provider: providerIdentifiers.zooGateway, + apiKey: "account-b", + baseUrl: "https://gateway-b.test/v1", + }, + zooGatewayHandler, + ) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + await getModels( + { + provider: providerIdentifiers.zooGateway, + apiKey: "account-a", + baseUrl: "https://gateway-a.test/v1", + }, + zooGatewayHandler, + ) + await getModels( + { + provider: providerIdentifiers.zooGateway, + apiKey: "account-b", + baseUrl: "https://gateway-b.test/v1", + }, + zooGatewayHandler, + ) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(4) + }) + + it("flushModels with refresh keeps the prior catalog when the provider fetch fails", async () => { + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk(zooModels)) + .mockRejectedValueOnce(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + await expect(flushModels(options, zooGatewayHandler, true)).resolves.toBeUndefined() + + const afterFailedRefresh = await getModels(options, zooGatewayHandler) + expect(afterFailedRefresh).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("flushModels with refresh logs and does not throw when refresh fails with no session cache", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockGetZooGatewayModels.mockRejectedValue(new Error("network down")) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await expect(flushModels(options, zooGatewayHandler, true)).resolves.toBeUndefined() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[flushModels] Failed to refresh auth-scoped"), + expect.any(Error), + ) + consoleSpy.mockRestore() + }) + + it("flushModels with refresh keeps the prior catalog when refresh returns empty", async () => { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)).mockResolvedValueOnce(zooGatewayOk({})) + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + await getModels(options, zooGatewayHandler) + await flushModels(options, zooGatewayHandler, true) + + const afterEmptyRefresh = await getModels(options, zooGatewayHandler) + expect(afterEmptyRefresh).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("prunes session entries older than twice the TTL when maintaining the cache", async () => { + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const first = { provider: providerIdentifiers.zooGateway, apiKey: "session-a" } + const second = { provider: providerIdentifiers.zooGateway, apiKey: "session-b" } + + await getModels(first, zooGatewayHandler) + vi.advanceTimersByTime(5 * 60 * 1000 * 2 + 1) + await getModels(second, zooGatewayHandler) + + mockGetZooGatewayModels.mockClear() + mockGetZooGatewayModels.mockResolvedValueOnce({ kind: "not_modified" }) + const result = await getModels(first, zooGatewayHandler) + + expect(result).toEqual({}) + } finally { + vi.useRealTimers() + } + }) + + it("staleCutoffMs uses >= so entry at exactly 2x TTL is pruned", async () => { + // Kills: EqualityOperator mutant L159 (>= staleCutoffMs → > staleCutoffMs) + vi.useFakeTimers() + try { + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const first = { provider: providerIdentifiers.zooGateway, apiKey: "session-a" } + const second = { provider: providerIdentifiers.zooGateway, apiKey: "session-b" } + + await getModels(first, zooGatewayHandler) + // Advance to EXACTLY 2x TTL — must be pruned (>= not just >) + vi.advanceTimersByTime(5 * 60 * 1000 * 2) + await getModels(second, zooGatewayHandler) + + mockGetZooGatewayModels.mockClear() + // First entry was pruned — a new fetch must fire (not_modified without etag yields {}) + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk({})) + const result = await getModels(first, zooGatewayHandler) + + // No cache entry → provider returned empty → fallback to {} + expect(result).toEqual({}) + } finally { + vi.useRealTimers() + } + }) + + it("enforces session cache MAX_ENTRIES by evicting the oldest entry", async () => { + // Kills: ConditionalExpression mutants on L167-L179 (while loop and oldestKey guard) + vi.useFakeTimers() + try { + // Fill 65 unique sessions (1 over AUTH_SESSION_MAX_ENTRIES=64) + for (let i = 0; i < 65; i++) { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)) + vi.advanceTimersByTime(1) // ensure fetchedAt differs so oldest is well-defined + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: `session-${i}` }, zooGatewayHandler) + } + // session-0 is the oldest — it must have been evicted. Verify by clearing mock + // and calling with session-0: if it was evicted, a fresh fetch fires. + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-0" }, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it("does not evict when the session cache is exactly at MAX_ENTRIES", async () => { + // Kills: EqualityOperator mutant `size > MAX` → `size >= MAX` (would evict at exactly 64) + vi.useFakeTimers() + try { + for (let i = 0; i < 64; i++) { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)) + vi.advanceTimersByTime(1) + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: `session-${i}` }, zooGatewayHandler) + } + + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-0" }, zooGatewayHandler) + expect(mockGetZooGatewayModels).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("evicts the earliest-inserted entry when fetchedAt timestamps are equal", async () => { + // Kills: EqualityOperator mutant `fetchedAt < oldest` → `<=` (would prefer the newest key) + vi.useFakeTimers() + try { + for (let i = 0; i < 65; i++) { + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(zooModels)) + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: `session-${i}` }, zooGatewayHandler) + } + + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-0" }, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-64" }, zooGatewayHandler) + expect(mockGetZooGatewayModels).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("setAuthSessionEntry does not store an empty model record", async () => { + // Kills: ConditionalExpression mutant L188 (false) — empty models must be rejected + mockGetZooGatewayModels + .mockResolvedValueOnce(zooGatewayOk({})) // first call: empty + .mockResolvedValueOnce(zooGatewayOk(zooModels)) // second call: populated + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // First call returns empty — nothing stored + await getModels(options, zooGatewayHandler) + // Second call must fetch again (nothing was cached from first call) + const second = await getModels(options, zooGatewayHandler) + + expect(second).toEqual(zooModels) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("stale in-flight fetch must not write to cache even when a new fetch registered the key", async () => { + // Kills: LogicalOperator mutant L303 (&&→||): + // With ||, fetch A (stale) would write because inFlightAuthScopedFetch.has(key)==true. + // This test detects that by making a THIRD call immediately after fetch A resolves + // (before fetch B): if stale data was written it's served from cache; if not, the + // third call deduplicates to fetch B and gets fresh data. + type ZooGatewayResult = Awaited> + const staleModels: ModelRecord = { + "stale/model": { maxTokens: 1000, contextWindow: 1000, supportsPromptCache: false }, + } + const freshModelsAfterSignOut2: ModelRecord = { + "fresh2/model": { maxTokens: 3000, contextWindow: 3000, supportsPromptCache: false }, + } + + let resolveFetchA!: (v: ZooGatewayResult) => void + let resolveFetchB!: (v: ZooGatewayResult) => void + const fetchADeferred = new Promise((r) => (resolveFetchA = r)) + const fetchBDeferred = new Promise((r) => (resolveFetchB = r)) + + mockGetZooGatewayModels.mockReturnValueOnce(fetchADeferred).mockReturnValueOnce(fetchBDeferred) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // 1. Start fetch A (pre-sign-out) + const fetchAResult = getModels(options, zooGatewayHandler) + + // 2. Sign-out + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + // 3. Start fetch B (post-sign-out) — registers new in-flight under the same key + const fetchBResult = getModels(options, zooGatewayHandler) + + // 4. Resolve fetch A with stale data + resolveFetchA(zooGatewayOk(staleModels)) + await fetchAResult + + // 5. Third call immediately after A resolves (B still pending). + // If stale data was written (|| bug), it gets served from cache → returns stale. + // If stale data was NOT written (correct &&), it deduplicates to fetch B → blocks. + const thirdCallResult = getModels(options, zooGatewayHandler) + + // 6. Resolve fetch B with fresh data + resolveFetchB(zooGatewayOk(freshModelsAfterSignOut2)) + const [thirdResult] = await Promise.all([thirdCallResult, fetchBResult]) + + // The third call must have gotten fresh data (deduped to B), not stale data from A + expect(thirdResult).toEqual(freshModelsAfterSignOut2) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + }) + + it("clearAuthSessionModelsForProvider also invalidates in-flight fetches not yet in cache", async () => { + // Kills: ConditionalExpression mutants on L216 — the loop over inFlightAuthScopedFetch + // must delete keys even when the cache is empty (fetch hasn't resolved yet). + type ZooGatewayResult = Awaited> + const freshModels2: ModelRecord = { + "fresh2/model": { maxTokens: 3000, contextWindow: 3000, supportsPromptCache: false }, + } + + let resolveFetchA!: (v: ZooGatewayResult) => void + const fetchADeferred = new Promise((r) => (resolveFetchA = r)) + + mockGetZooGatewayModels.mockReturnValueOnce(fetchADeferred).mockResolvedValueOnce(zooGatewayOk(freshModels2)) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // 1. Start fetch A — it's in-flight, cache is EMPTY (hasn't resolved yet) + const fetchAResult = getModels(options, zooGatewayHandler) + + // 2. Sign out before fetch A resolves — must also clear the in-flight entry + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + // 3. Start fetch B — if in-flight was cleared, a NEW fetch fires (not deduped to A) + const fetchBResult = getModels(options, zooGatewayHandler) + + // 4. Resolve A (stale) and B (fresh) + resolveFetchA(zooGatewayOk(zooModels)) // stale pre-sign-out data + await fetchAResult + + const bResult = await fetchBResult + expect(bResult).toEqual(freshModels2) + // Two fetches fired: A and B (if in-flight was cleared correctly) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + }) + + it("generation counter increments by +1 on each sign-out, not by -1", async () => { + // Kills: ArithmeticOperator mutant L224 (+ 1 → - 1). + // After two sign-outs the counter should be 2; with -1 it would be -2. + // We observe this indirectly: two sign-outs → two in-flight fetches complete → only + // the post-second-sign-out fetch should write to cache. + type ZooGatewayResult = Awaited> + const models1: ModelRecord = { "m1/a": { maxTokens: 100, contextWindow: 100, supportsPromptCache: false } } + const models2: ModelRecord = { "m2/b": { maxTokens: 200, contextWindow: 200, supportsPromptCache: false } } + const models3: ModelRecord = { "m3/c": { maxTokens: 300, contextWindow: 300, supportsPromptCache: false } } + + let resolveA!: (v: ZooGatewayResult) => void + let resolveB!: (v: ZooGatewayResult) => void + let resolveC!: (v: ZooGatewayResult) => void + mockGetZooGatewayModels + .mockReturnValueOnce(new Promise((r) => (resolveA = r))) + .mockReturnValueOnce(new Promise((r) => (resolveB = r))) + .mockReturnValueOnce(new Promise((r) => (resolveC = r))) + + const options = { provider: providerIdentifiers.zooGateway, apiKey: "session-token" } + + // fetch A (gen=0 at start) + const resA = getModels(options, zooGatewayHandler) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) // gen → 1 + // fetch B (gen=1 at start) + const resB = getModels(options, zooGatewayHandler) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) // gen → 2 + // fetch C (gen=2 at start) + const resC = getModels(options, zooGatewayHandler) + + // Resolve all with distinct data; only C's data should land in cache + resolveA(zooGatewayOk(models1)) + await resA + resolveB(zooGatewayOk(models2)) + await resB + resolveC(zooGatewayOk(models3)) + const finalResult = await resC + + // C was the last fetch with matching generation — result must be models3 + expect(finalResult).toEqual(models3) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + }) + + it("clearAuthSessionModelsForProvider clears bare provider cache keys (no apiKey)", async () => { + // Kills: ConditionalExpression mutant that replaces `key === provider` with false. + // Without apiKey/baseUrl, getCacheKey returns the bare provider name. + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + const options = { provider: providerIdentifiers.zooGateway } + + await getModels(options, zooGatewayHandler) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + await getModels(options, zooGatewayHandler) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("clearAuthSessionModelsForProvider does not drop in-flight fetches for other auth providers", async () => { + // Kills: ConditionalExpression mutant that always-true matches in-flight keys on clear. + type ZooGatewayResult = Awaited> + type KimiResult = Awaited> + const kimiModels: ModelRecord = { + "kimi-for-coding": { maxTokens: 8192, contextWindow: 128000, supportsPromptCache: false }, + } + + let resolveZoo!: (v: ZooGatewayResult) => void + let resolveKimi!: (v: KimiResult) => void + mockGetZooGatewayModels.mockReturnValueOnce(new Promise((r) => (resolveZoo = r))) + mockGetKimiCodeModels.mockReturnValueOnce(new Promise((r) => (resolveKimi = r))) + + const zooPending = getModels( + { provider: providerIdentifiers.zooGateway, apiKey: "zoo-session" }, + zooGatewayHandler, + ) + const kimiPending = getModels( + { provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }, + zooGatewayHandler, + ) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + // Kimi in-flight must remain; a second kimi getModels should dedupe (still 1 call). + const kimiDeduped = getModels( + { provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }, + zooGatewayHandler, + ) + expect(mockGetKimiCodeModels).toHaveBeenCalledTimes(1) + + resolveZoo(zooGatewayOk(zooModels)) + resolveKimi(kimiModels) + await zooPending + expect(await kimiPending).toEqual(kimiModels) + expect(await kimiDeduped).toEqual(kimiModels) + }) + + it("clearAuthSessionModelsForProvider does not bump generation for non-auth providers", () => { + // Kills: ConditionalExpression mutant that always enters the isAuthScopedProvider branch. + expect(authScopedClearGenerationSizeForTests()).toBe(0) + clearAuthSessionModelsForProvider(providerIdentifiers.openrouter) + expect(authScopedClearGenerationSizeForTests()).toBe(0) + }) +}) + // Single-flight cancellation. Each test below exercises one interleaving of caller aborts, // the per-flight fetch bound, and settlement; the mocks hold the HTTP layer pending so a // rejection that only arrives at abort time (rather than at fetch settle time) is provable. @@ -1812,8 +2775,9 @@ it("ignores the caller signal on the auth-scoped bypass without entering the fli // auth-scoped bypass must never touch that machinery. const boundSpy = vi.spyOn(AbortSignal, "timeout") try { - const fetchModels = vi.fn().mockResolvedValue(cancelledModelsB) - const handler = makeHandler(ZOO_SCOPE, fetchModels) + resetModelCacheTransientStateForTests() + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(cancelledModelsB)) + const handler = makeHandler(ZOO_SCOPE, vi.fn()) const controller = new AbortController() const first = getModels( @@ -1824,17 +2788,9 @@ it("ignores the caller signal on the auth-scoped bypass without entering the fli }, handler, ) - // Auth isolation requires no dedup even for an identical provider+token: each call fires - // its own fetch, so the bypass never shares (or poisons) a flight with anything. const second = getModels({ provider: providerIdentifiers.zooGateway, apiKey: "token-a" }, handler) - expect(fetchModels).toHaveBeenCalledTimes(2) - // The bypass carries no cancellation: the dispatcher forwards no signal on this path, - // so the caller's bound is never threaded to the handler's fetchModels. - expect(fetchModels.mock.calls[0][1]).toBeUndefined() - expect(fetchModels.mock.calls[1][1]).toBeUndefined() + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) - // The caller's signal is ignored on this path: aborting changes nothing for a fetch the - // single-flight never owns, and the fetcher's own request bound remains the stop mechanism. controller.abort() await expect(first).resolves.toEqual(cancelledModelsB) await expect(second).resolves.toEqual(cancelledModelsB) diff --git a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts index ae9bdcc4b1..781b4d3335 100644 --- a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts @@ -2,6 +2,8 @@ import axios from "axios" +import type { ApiHandlerOptions } from "../../../../shared/api" +import type { VercelAiGatewayModel } from "../vercel-ai-gateway" import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway" vitest.mock("axios") @@ -16,7 +18,22 @@ vitest.mock("../../../../services/zoo-code-auth", () => ({ return profileToken || undefined }), })) -const mockedAxios = axios as any + +const mockAxiosGet = vi.mocked(axios.get) + +function modelsFromResult(result: Awaited>) { + return result.kind === "ok" ? result.models : {} +} + +function gatewayOptions( + overrides: Partial = {}, +): ApiHandlerOptions & { ifNoneMatch?: string } { + return { + zooGatewayBaseUrl: "https://example.test/api/gateway/v1", + zooSessionToken: "zoo_ext_test_token", + ...overrides, + } +} describe("Zoo Gateway Fetchers", () => { beforeEach(() => { @@ -28,6 +45,8 @@ describe("Zoo Gateway Fetchers", () => { const token = "zoo_ext_test_token" const mockResponse = { + status: 200, + headers: { etag: '"catalog-abc"' }, data: { object: "list", data: [ @@ -65,46 +84,116 @@ describe("Zoo Gateway Fetchers", () => { } it("forwards the bearer token and timeout, filters non-language models", async () => { - mockedAxios.get.mockResolvedValueOnce(mockResponse) + mockAxiosGet.mockResolvedValueOnce(mockResponse) - const models = await getZooGatewayModels({ - zooGatewayBaseUrl: baseUrl, - zooSessionToken: token, - } as any) + const result = await getZooGatewayModels(gatewayOptions()) - expect(mockedAxios.get).toHaveBeenCalledWith( + const axiosConfig = mockAxiosGet.mock.calls[0]?.[1] + expect(axiosConfig?.validateStatus?.(200)).toBe(true) + expect(axiosConfig?.validateStatus?.(304)).toBe(true) + expect(axiosConfig?.validateStatus?.(500)).toBe(false) + + expect(mockAxiosGet).toHaveBeenCalledWith( `${baseUrl}/models`, expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${token}` }), timeout: expect.any(Number), + validateStatus: expect.any(Function), + }), + ) + expect(result.kind).toBe("ok") + if (result.kind !== "ok") return + expect(Object.keys(result.models)).toHaveLength(1) + expect(result.models["anthropic/claude-sonnet-4"]).toMatchObject({ + maxTokens: expect.any(Number), + contextWindow: expect.any(Number), + }) + expect(result.etag).toBe('"catalog-abc"') + }) + + it("reads etag from the lowercase response header (Node.js normalises headers)", async () => { + mockAxiosGet.mockResolvedValueOnce({ + ...mockResponse, + headers: { etag: '"catalog-lower"' }, + }) + + const result = await getZooGatewayModels(gatewayOptions()) + + expect(result.kind).toBe("ok") + if (result.kind !== "ok") return + expect(result.etag).toBe('"catalog-lower"') + }) + + it("returns undefined etag when only the capitalized ETag header is present (unreachable in real HTTP)", async () => { + // Node.js lowercases all HTTP response headers before they reach userland, + // so response.headers.ETag is always undefined in production. + mockAxiosGet.mockResolvedValueOnce({ + ...mockResponse, + headers: { ETag: '"catalog-capital"' }, + }) + + const result = await getZooGatewayModels(gatewayOptions()) + + expect(result.kind).toBe("ok") + if (result.kind !== "ok") return + expect(result.etag).toBeUndefined() + }) + + it("sends If-None-Match when provided and returns not_modified on 304", async () => { + mockAxiosGet.mockResolvedValueOnce({ status: 304, headers: {}, data: "" }) + + const result = await getZooGatewayModels( + gatewayOptions({ + ifNoneMatch: '"catalog-abc"', + }), + ) + + expect(mockAxiosGet).toHaveBeenCalledWith( + `${baseUrl}/models`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${token}`, + "If-None-Match": '"catalog-abc"', + }), }), ) - expect(Object.keys(models)).toHaveLength(1) - expect(models["anthropic/claude-sonnet-4"]).toBeDefined() + expect(result).toEqual({ kind: "not_modified" }) + + const validateStatus = mockAxiosGet.mock.calls[0]?.[1]?.validateStatus + expect(validateStatus?.(304)).toBe(true) + expect(validateStatus?.(200)).toBe(true) + expect(validateStatus?.(500)).toBe(false) + }) + + it("omits If-None-Match when no etag is provided", async () => { + mockAxiosGet.mockResolvedValueOnce(mockResponse) + + await getZooGatewayModels(gatewayOptions()) + + const headers = mockAxiosGet.mock.calls[0]?.[1]?.headers as Record + expect(headers).toEqual({ Authorization: `Bearer ${token}` }) + expect(headers).not.toHaveProperty("If-None-Match") }) it("skips the request and returns {} when no token is available", async () => { - const models = await getZooGatewayModels({ zooGatewayBaseUrl: baseUrl } as any) + const result = await getZooGatewayModels(gatewayOptions({ zooSessionToken: undefined })) - expect(mockedAxios.get).not.toHaveBeenCalled() - expect(models).toEqual({}) + expect(mockAxiosGet).not.toHaveBeenCalled() + expect(result).toEqual({ kind: "ok", models: {} }) }) it("returns {} and never leaks the error object when the request fails", async () => { const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {}) - const failure: any = new Error("Network error") - // Simulate axios attaching the request config (which contains the bearer token). - failure.config = { headers: { Authorization: "Bearer should-never-be-logged" } } - failure.code = "ECONNRESET" - failure.response = { status: 502, statusText: "Bad Gateway" } - mockedAxios.get.mockRejectedValueOnce(failure) - - const models = await getZooGatewayModels({ - zooGatewayBaseUrl: baseUrl, - zooSessionToken: token, - } as any) - - expect(models).toEqual({}) + const failure = Object.assign(new Error("Network error"), { + config: { headers: { Authorization: "Bearer should-never-be-logged" } }, + code: "ECONNRESET", + response: { status: 502, statusText: "Bad Gateway" }, + }) + mockAxiosGet.mockRejectedValueOnce(failure) + + const result = await getZooGatewayModels(gatewayOptions()) + + expect(result).toEqual({ kind: "ok", models: {} }) const logged = consoleErrorSpy.mock.calls.map((args) => String(args[0])).join("\n") expect(logged).toContain("status=502") expect(logged).toContain("code=ECONNRESET") @@ -114,7 +203,9 @@ describe("Zoo Gateway Fetchers", () => { }) it("accepts gateway catalog models without created or description (e.g. Bedrock)", async () => { - mockedAxios.get.mockResolvedValueOnce({ + mockAxiosGet.mockResolvedValueOnce({ + status: 200, + headers: {}, data: { object: "list", data: [ @@ -135,24 +226,19 @@ describe("Zoo Gateway Fetchers", () => { }, }) - const models = await getZooGatewayModels({ - zooGatewayBaseUrl: baseUrl, - zooSessionToken: token, - } as any) + const result = await getZooGatewayModels(gatewayOptions()) - expect(Object.keys(models)).toEqual(["anthropic/claude-sonnet-4"]) - expect(models["anthropic/claude-sonnet-4"].description).toBe("Claude Sonnet 4") + expect(Object.keys(modelsFromResult(result))).toEqual(["anthropic/claude-sonnet-4"]) + if (result.kind !== "ok") return + expect(result.models["anthropic/claude-sonnet-4"].description).toBe("Claude Sonnet 4") }) it("returns {} on a structurally broken response instead of throwing", async () => { const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {}) - mockedAxios.get.mockResolvedValueOnce({ data: { unexpected: true } }) + mockAxiosGet.mockResolvedValueOnce({ status: 200, headers: {}, data: { unexpected: true } }) - const models = await getZooGatewayModels({ - zooGatewayBaseUrl: baseUrl, - zooSessionToken: token, - } as any) + const result = await getZooGatewayModels(gatewayOptions()) - expect(models).toEqual({}) + expect(result).toEqual({ kind: "ok", models: {} }) expect(consoleErrorSpy).toHaveBeenCalled() consoleErrorSpy.mockRestore() }) @@ -182,25 +268,27 @@ describe("Zoo Gateway Fetchers", () => { }) it("delegates to the vercel-ai-gateway parser", () => { + const model: VercelAiGatewayModel = { + id: "anthropic/claude-sonnet-4", + object: "model", + created: 0, + owned_by: "anthropic", + name: "Claude Sonnet 4", + description: "Sonnet", + context_window: 200000, + max_tokens: 64000, + type: "language", + pricing: { + input: "3.00", + output: "15.00", + input_cache_write: "3.75", + input_cache_read: "0.30", + }, + } + const result = parseZooGatewayModel({ id: "anthropic/claude-sonnet-4", - model: { - id: "anthropic/claude-sonnet-4", - object: "model", - created: 0, - owned_by: "anthropic", - name: "Claude Sonnet 4", - description: "Sonnet", - context_window: 200000, - max_tokens: 64000, - type: "language", - pricing: { - input: "3.00", - output: "15.00", - input_cache_write: "3.75", - input_cache_read: "0.30", - }, - } as any, + model, }) expect(result.contextWindow).toBe(200000) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 0134ec47cb..c6f5e49a15 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -20,6 +20,8 @@ import { mergeAbortSignals, throwIfAborted } from "../utils/abort-signal" import { GetModelsOptions } from "../../../shared/api" import type { ApiHandler } from "../../index" +import { getKimiCodeModels } from "./kimi-code" +import { getZooGatewayModels } from "./zoo-gateway" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -58,6 +60,17 @@ type FlightRecord = { pending: boolean } +// Same single-flight guard for auth-scoped providers. Without this, two concurrent getModels() +// calls (e.g. two panels opening on startup) both miss the session cache and each fire their own +// provider fetch. Keyed on the same compound cache key as authSessionCache. +const inFlightAuthScopedFetch = new Map>() + +// Generation counter per auth-scoped PROVIDER. Bumped on sign-out invalidation so that an +// in-flight fetch that started before sign-out can detect that the session was invalidated +// and avoid re-populating the cache with stale data. Keyed by provider (not cache key) +// because the cache entry may not exist yet when sign-out occurs. +const authScopedClearGeneration = new Map() + // Cache keys (see getCacheKey) for which we've already reported an empty model response this // session. A persistently-empty endpoint (e.g. misconfigured server) would otherwise re-fire this // event on every cache refresh; gate it to at most once per distinct provider+server+key identity @@ -78,6 +91,255 @@ function captureModelCacheEmptyResponseOnce( TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, { provider, ...properties }) } +// 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, +]) + +type AuthScopedProvider = typeof providerIdentifiers.zooGateway | typeof providerIdentifiers.kimiCode + +type AuthScopedGetModelsOptions = Extract + +const AUTH_SESSION_TTL_MS = 300_000 +const AUTH_SESSION_MAX_ENTRIES = 64 + +function authSessionHasModels(models: ModelRecord): boolean { + // Stryker disable next-line EqualityOperator,ConditionalExpression: empty catalogs are never stored in authSessionCache + return Object.keys(models).length > 0 +} + +function authSessionKeyMatchesProvider(key: string, provider: RouterName): boolean { + // Bare equality covers missing apiKey/baseUrl (cache key === provider name). + // Compound keys always use `provider:...` via getCacheKey. + return key === provider || key.startsWith(`${provider}:`) +} + +type AuthSessionCacheEntry = { + models: ModelRecord + etag?: string + fetchedAt: number +} + +// In-memory, per-session catalog cache for auth-scoped providers. Never written to disk +// and keyed by the same compound identity as getCacheKey (provider + baseUrl + token hash). +const authSessionCache = new Map() + +function isAuthScopedProvider(provider: RouterName): provider is AuthScopedProvider { + return AUTH_SCOPED_PROVIDERS.has(provider) +} + +function assertAuthScopedGetModelsOptions(options: GetModelsOptions): AuthScopedGetModelsOptions { + // Stryker disable next-line ConditionalExpression,StringLiteral: defensive type-narrowing guard; callers already route via isAuthScopedProvider + if (!isAuthScopedProvider(options.provider)) { + // Stryker disable next-line CallExpression,StringLiteral: defensive throw; unreachable for typed callers that already passed isAuthScopedProvider + throw new Error(`Expected auth-scoped provider, got ${options.provider}`) + } + // Runtime guard above; TS cannot narrow the GetModelsOptions discriminated union here. + return options as AuthScopedGetModelsOptions +} + +function isAuthSessionFresh(entry: AuthSessionCacheEntry): boolean { + return Date.now() - entry.fetchedAt < AUTH_SESSION_TTL_MS +} + +function pruneExpiredAuthSessionEntries(): void { + const staleCutoffMs = AUTH_SESSION_TTL_MS * 2 + const now = Date.now() + for (const [key, entry] of authSessionCache) { + if (now - entry.fetchedAt >= staleCutoffMs) { + authSessionCache.delete(key) + } + } +} + +function enforceAuthSessionCacheBound(): void { + pruneExpiredAuthSessionEntries() + while (authSessionCache.size > AUTH_SESSION_MAX_ENTRIES) { + let oldestKey: string | undefined + let oldestFetchedAt = Infinity + for (const [key, entry] of authSessionCache) { + if (entry.fetchedAt < oldestFetchedAt) { + oldestFetchedAt = entry.fetchedAt + oldestKey = key + } + } + // Stryker disable next-line ConditionalExpression: Map iteration always yields a key while size > 0; break is a defensive latch + if (!oldestKey) { + break + } + authSessionCache.delete(oldestKey) + } +} + +function getAuthSessionEntry(cacheKey: string): AuthSessionCacheEntry | undefined { + return authSessionCache.get(cacheKey) +} + +function setAuthSessionEntry(cacheKey: string, models: ModelRecord, etag?: string): void { + // Stryker disable next-line ConditionalExpression: callers only pass non-empty catalogs (guarded by modelCount > 0) + if (Object.keys(models).length === 0) { + return + } + authSessionCache.set(cacheKey, { models, etag, fetchedAt: Date.now() }) + enforceAuthSessionCacheBound() +} + +function touchAuthSessionEntry(cacheKey: string, entry: AuthSessionCacheEntry): void { + authSessionCache.set(cacheKey, { ...entry, fetchedAt: Date.now() }) + // Stryker disable next-line CallExpression: touch cannot grow the cache; bound enforcement is redundant here + enforceAuthSessionCacheBound() +} + +function deleteAuthSessionEntry(cacheKey: string): void { + authSessionCache.delete(cacheKey) +} + +export function clearAuthSessionModelsForProvider(provider: RouterName): void { + for (const key of authSessionCache.keys()) { + if (authSessionKeyMatchesProvider(key, provider)) { + authSessionCache.delete(key) + } + } + // Also invalidate any in-flight fetches — the cache entry may not exist yet + // if the fetch hasn't resolved, so we must iterate inFlightAuthScopedFetch + // independently (not just keys already in authSessionCache). + for (const key of inFlightAuthScopedFetch.keys()) { + if (authSessionKeyMatchesProvider(key, provider)) { + inFlightAuthScopedFetch.delete(key) + } + } + // Bump generation so in-flight fetches that started before this clear + // know they should not write back stale data. Keyed by provider (not cache key) + // because the cache entry may not exist yet when sign-out occurs. + if (isAuthScopedProvider(provider)) { + // Stryker disable next-line ArithmeticOperator: write-back only checks inequality vs generationAtStart; +1 and -1 are equivalent + authScopedClearGeneration.set(provider, (authScopedClearGeneration.get(provider) ?? 0) + 1) + } +} + +type AuthScopedFetchResult = { + models: ModelRecord + etag?: string + notModified?: boolean +} + +async function fetchAuthScopedModelsFromProvider( + options: AuthScopedGetModelsOptions, + ifNoneMatch?: string, +): Promise { + switch (options.provider) { + case providerIdentifiers.zooGateway: { + const result = await getZooGatewayModels({ + zooSessionToken: options.apiKey, + zooGatewayBaseUrl: options.baseUrl, + ifNoneMatch, + }) + if (result.kind === "not_modified") { + return { models: {}, notModified: true } + } + return { models: result.models, etag: result.etag } + } + case providerIdentifiers.kimiCode: + return { models: await getKimiCodeModels(options.apiKey) } + } +} + +async function resolveAuthScopedModels( + options: GetModelsOptions, + handler: ApiHandler, + { forceRefresh = false }: { forceRefresh?: boolean } = {}, +): Promise { + const { provider } = options + const cacheKey = getCacheKey(options, handler) + const existing = getAuthSessionEntry(cacheKey) + + // Stryker disable next-line EqualityOperator: empty catalogs are never stored in authSessionCache + if (!forceRefresh && existing && isAuthSessionFresh(existing) && authSessionHasModels(existing.models)) { + return existing.models + } + + // Coalesce concurrent fetches for the same session identity so two panels + // opening simultaneously don't each fire their own provider request. + const existingFlight = inFlightAuthScopedFetch.get(cacheKey) + if (existingFlight && !forceRefresh) { + return existingFlight + } + + // Capture generation before starting the fetch so we can detect if a sign-out occurred + // between when we started and when we try to write back results. + const authProvider = provider as AuthScopedProvider + const generationAtStart = authScopedClearGeneration.get(authProvider) ?? 0 + + const fetchPromise = (async () => { + try { + const fetched = await fetchAuthScopedModelsFromProvider( + assertAuthScopedGetModelsOptions(options), + existing?.etag, + ) + + if (fetched.notModified) { + // Re-read from the Map: a concurrent sign-out could have cleared + // the entry between when we captured `existing` and now. + const current = getAuthSessionEntry(cacheKey) + if (current && authSessionHasModels(current.models)) { + touchAuthSessionEntry(cacheKey, current) + reportedEmptyModelResponse.delete(cacheKey) + return current.models + } + // Sign-out cleared the entry while the 304 was in-flight. + return {} + } else { + const modelCount = Object.keys(fetched.models).length + if (modelCount > 0) { + // Guard against re-populating the cache after a sign-out invalidated + // this key between when we started the fetch and when it resolved. + // Check both: (1) the in-flight promise is still ours, and (2) no sign-out + // bumped the generation while we were fetching. + const generationNow = authScopedClearGeneration.get(authProvider) ?? 0 + if (inFlightAuthScopedFetch.has(cacheKey) && generationNow === generationAtStart) { + setAuthSessionEntry(cacheKey, fetched.models, fetched.etag) + reportedEmptyModelResponse.delete(cacheKey) + } + return fetched.models + } + + captureModelCacheEmptyResponseOnce(provider, cacheKey, { + context: forceRefresh ? "refreshModels" : "getModels", + hasExistingCache: Boolean(existing && authSessionHasModels(existing.models)), + ...(existing ? { existingCacheSize: Object.keys(existing.models).length } : {}), + }) + } + + if (existing && authSessionHasModels(existing.models)) { + return existing.models + } + + return fetched.models + } catch (error) { + if (existing && authSessionHasModels(existing.models)) { + return existing.models + } + throw error + } + })() + + // Wrap cleanup in the promise itself so the stored value IS what callers await — + // a detached .finally() would produce an unhandled rejection when the fetch fails. + // Only remove our own entry: if sign-out cleared us and a new fetch registered a + // fresh promise under the same key, we must not delete that newer entry. + const fetchWithCleanup = fetchPromise.finally(() => { + if (inFlightAuthScopedFetch.get(cacheKey) === fetchWithCleanup) { + inFlightAuthScopedFetch.delete(cacheKey) + } + }) + inFlightAuthScopedFetch.set(cacheKey, fetchWithCleanup) + return fetchWithCleanup +} + // Memoize derived digests so the deliberately-structureless KDF runs at most once per // distinct input per session (getCacheKey / cacheKeyToFilename run on every cache lookup). const cacheDigestCache = new Map() @@ -207,9 +469,11 @@ export const getModels = async (options: GetModelsOptions, handler: ApiHandler): const { provider } = options const cacheKey = getCacheKey(options, handler) - const shouldSkipCache = handler.getModelCacheScope().authScoped + if (handler.getModelCacheScope().authScoped) { + return resolveAuthScopedModels(options, handler) + } - const models = shouldSkipCache ? undefined : getModelsFromCache(options, handler) + const models = getModelsFromCache(options, handler) if (models) { return models @@ -228,30 +492,24 @@ export const getModels = async (options: GetModelsOptions, handler: ApiHandler): // getModels(), and a fetch failure joined from refreshModels() still re-throws for // getModels() callers. try { - // The auth-scoped fetch bypasses the single-flight entirely, so options.signal is - // deliberately not forwarded there: there is no shared entry to release on abort, and - // these fetchers bound their own requests. - const sharedFetch = shouldSkipCache - ? fetchModelsFromProvider(options, handler) - : dedupedFetch(cacheKey, options, handler) + const sharedFetch = dedupedFetch(cacheKey, options, handler) const fetched = await sharedFetch const modelCount = Object.keys(fetched).length // Only cache non-empty results so a failed API response doesn't get persisted - // as if the provider had no models. Auth-scoped providers skip caching entirely. + // as if the provider had no models. if (modelCount > 0) { // Clear the empty-response throttle for any non-empty response, including from - // auth-scoped providers that skip caching, so a later empty response is reported again. + // auth-scoped providers that skip disk/memory cache, so a later empty response + // is reported again. reportedEmptyModelResponse.delete(cacheKey) - if (!shouldSkipCache) { - memoryCache.set(cacheKey, fetched) + memoryCache.set(cacheKey, fetched) - await writeModels(cacheKey, fetched).catch((err) => - console.error(`[MODEL_CACHE] Error writing ${cacheKey} models to file cache:`, err), - ) - } + await writeModels(cacheKey, fetched).catch((err) => + console.error(`[MODEL_CACHE] Error writing ${cacheKey} models to file cache:`, err), + ) } else { captureModelCacheEmptyResponseOnce(provider, cacheKey, { context: "getModels", @@ -418,12 +676,22 @@ export const refreshModels = async (options: GetModelsOptions, handler: ApiHandl const { provider } = options const cacheKey = getCacheKey(options, handler) - const shouldSkipCache = handler.getModelCacheScope().authScoped + if (handler.getModelCacheScope().authScoped) { + // Stryker disable next-line BlockStatement: emptying this try falls through into the non-auth provider fetch path + try { + return await resolveAuthScopedModels(options, handler, { forceRefresh: true }) + // Stryker disable next-line BlockStatement: emptying catch falls through into non-auth refresh; covered by error-message assertion in tests + } catch (error) { + console.error(`[refreshModels] Failed to refresh ${cacheKey} models:`, error) + const existing = getAuthSessionEntry(cacheKey) + // Stryker disable next-line ConditionalExpression,BlockStatement,EqualityOperator: resolveAuthScopedModels only throws when no usable session entry remains + if (existing && authSessionHasModels(existing.models)) { + return existing.models + } + return {} + } + } - // De-duplication is skipped for auth-scoped providers because two concurrent calls may - // carry different tokens (e.g., after a sign-out/sign-in within the same session) and we - // must not return the first caller's results to the second caller. - // // Shares the same underlying fetch getModels() uses (see dedupedFetch) so a refreshModels() // call racing a getModels() cache-miss for the same key converges on one provider fetch -- // but each function still applies its own success/failure contract on the result below @@ -431,19 +699,13 @@ export const refreshModels = async (options: GetModelsOptions, handler: ApiHandl // The fetch call is created inside the try: a pre-aborted caller signal makes dedupedFetch() // throw synchronously, and refreshModels() must still degrade to cache/{} rather than reject. try { - // The auth-scoped fetch bypasses the single-flight entirely, so options.signal is - // deliberately not forwarded there: there is no shared entry to release on abort, and - // these fetchers bound their own requests. - const sharedFetch = shouldSkipCache - ? fetchModelsFromProvider(options, handler) - : dedupedFetch(cacheKey, options, handler) + const sharedFetch = dedupedFetch(cacheKey, options, handler) // Force fresh API fetch - skip getModelsFromCache() check const models = await sharedFetch const modelCount = Object.keys(models).length - // Get existing cached data for comparison - const existingCache = shouldSkipCache ? undefined : getModelsFromCache(options, handler) + const existingCache = getModelsFromCache(options, handler) const existingCount = existingCache ? Object.keys(existingCache).length : 0 if (modelCount === 0) { @@ -457,23 +719,15 @@ export const refreshModels = async (options: GetModelsOptions, handler: ApiHandl reportedEmptyModelResponse.delete(cacheKey) - if (!shouldSkipCache) { - memoryCache.set(cacheKey, models) + memoryCache.set(cacheKey, models) - await writeModels(cacheKey, models).catch((err) => - console.error(`[refreshModels] Error writing ${cacheKey} models to disk:`, err), - ) - } + await writeModels(cacheKey, models).catch((err) => + console.error(`[refreshModels] Error writing ${cacheKey} models to disk:`, err), + ) return models } catch (error) { - // Log the error for debugging, then return existing cache if available (graceful degradation). - // For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior - // session, since they could belong to a different user -- return empty instead. console.error(`[refreshModels] Failed to refresh ${cacheKey} models:`, error) - if (shouldSkipCache) { - return {} - } return getModelsFromCache(options, handler) || {} } } @@ -529,6 +783,20 @@ export const flushModels = async ( handler: ApiHandler, refresh: boolean = false, ): Promise => { + if (handler.getModelCacheScope().authScoped) { + const cacheKey = getCacheKey(options, handler) + if (refresh) { + try { + await resolveAuthScopedModels(options, handler, { forceRefresh: true }) + } catch (error) { + console.error(`[flushModels] Failed to refresh auth-scoped ${cacheKey} models:`, error) + } + } else { + deleteAuthSessionEntry(cacheKey) + } + return + } + if (refresh) { // Don't delete memory cache - let refreshModels atomically replace it // This prevents a race condition where getModels() might be called @@ -606,6 +874,34 @@ export function getModelsFromCache(options: GetModelsOptions, handler: ApiHandle return undefined } +/** + * Clears auth-session maps and the empty-response throttle for test isolation. + * Prefer this over `vi.resetModules()` so mutation testing instruments the same module instance. + * + * @knipignore + */ +export function resetModelCacheTransientStateForTests(): void { + // Stryker disable next-line CallExpression: test-only reset; per-mutant runs do not require cross-test isolation + authSessionCache.clear() + // Stryker disable next-line CallExpression: test-only reset; per-mutant runs do not require cross-test isolation + inFlightAuthScopedFetch.clear() + // Stryker disable next-line CallExpression: test-only reset; per-mutant runs do not require cross-test isolation + authScopedClearGeneration.clear() + // Stryker disable next-line CallExpression: test-only reset; per-mutant runs do not require cross-test isolation + reportedEmptyModelResponse.clear() + // Stryker disable next-line CallExpression: test-only reset; per-mutant runs do not require cross-test isolation + inFlightRefresh.clear() +} + +/** + * Test-only: observe clear-generation map size (non-auth clears must not insert entries). + * + * @knipignore + */ +export function authScopedClearGenerationSizeForTests(): number { + return authScopedClearGeneration.size +} + /** * Synchronous version of getCacheDirectoryPath for use in getModelsFromCache. * Returns the cache directory path without async operations. diff --git a/src/api/providers/fetchers/zoo-gateway.ts b/src/api/providers/fetchers/zoo-gateway.ts index 074ec1dfae..b848d151d2 100644 --- a/src/api/providers/fetchers/zoo-gateway.ts +++ b/src/api/providers/fetchers/zoo-gateway.ts @@ -20,29 +20,47 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 15_000 * Fetches models from the Zoo Gateway API. Requires authentication via the zoo_ext_ token. */ -export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise> { +export type ZooGatewayModelsFetchResult = + { kind: "ok"; models: Record; etag?: string } | { kind: "not_modified" } + +export async function getZooGatewayModels( + options?: ApiHandlerOptions & { ifNoneMatch?: string }, +): Promise { const models: Record = {} const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` const sessionToken = resolveZooGatewaySessionToken(options?.zooSessionToken) if (!sessionToken) { - return models + return { kind: "ok", models } } const headers: Record = { Authorization: `Bearer ${sessionToken}`, } + // Stryker disable next-line OptionalChaining,ConditionalExpression: options is defined whenever sessionToken resolved from it + if (options?.ifNoneMatch) { + headers["If-None-Match"] = options.ifNoneMatch + } try { const response = await axios.get(`${baseURL}/models`, { headers, timeout: MODEL_DISCOVERY_TIMEOUT_MS, + validateStatus: (status) => status === 200 || status === 304, }) + + if (response.status === 304) { + return { kind: "not_modified" } + } + + // Stryker disable next-line ConditionalExpression: Node lowercases header names; non-string etag values are ignored + const etag = typeof response.headers.etag === "string" ? response.headers.etag : undefined + const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data) if (!result.success) { console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`) - return models + return { kind: "ok", models } } for (const model of result.data.data) { @@ -56,6 +74,8 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise< models[id] = parseZooGatewayModel({ id, model }) } + + return { kind: "ok", models, etag } } catch (error) { // Log only safe fields; never serialize the full error object because it // includes request config/headers which carry the bearer session token. @@ -70,7 +90,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise< ) } - return models + return { kind: "ok", models } } /** diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts index 66ccac5067..fb8b760773 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -19,6 +19,7 @@ import { t } from "../../i18n" import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" import type { SingleCompletionHandler, @@ -181,8 +182,22 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio return { urlScoped: true, keyScoped: true, authScoped: true } } + override getModel() { + const model = super.getModel() + // Anthropic thinking is signature-based. Other gateway models reject the next + // turn unless the previous assistant reasoning is still on the request. + if (model.id.startsWith("anthropic/")) { + return model + } + return { ...model, info: { ...model.info, preserveReasoning: true } } + } + async fetchModels(options: GetModelsOptions): Promise { - return getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl }) + const result = await getZooGatewayModels({ + zooSessionToken: options.apiKey, + zooGatewayBaseUrl: options.baseUrl, + }) + return result.kind === "ok" ? result.models : {} } private ensureAuthenticated(): void { @@ -248,6 +263,12 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio } const delta = chunk.choices[0]?.delta + + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + if (delta?.content) { yield { type: "text", diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0dd477ab2d..7a7705eb36 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -349,11 +349,6 @@ "count": 1 } }, - "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, "api/providers/fetchers/litellm.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/integrations/kimi-code/__tests__/oauth.spec.ts b/src/integrations/kimi-code/__tests__/oauth.spec.ts index 960777e69c..cdcdbb1105 100644 --- a/src/integrations/kimi-code/__tests__/oauth.spec.ts +++ b/src/integrations/kimi-code/__tests__/oauth.spec.ts @@ -1,3 +1,6 @@ +import { providerIdentifiers } from "@roo-code/types" + +import * as modelCache from "../../../api/providers/fetchers/modelCache" import { KIMI_CODE_OAUTH_CONFIG, KimiCodeOAuthManager } from "../oauth" const createContext = () => { @@ -95,6 +98,7 @@ describe("KimiCodeOAuthManager", () => { }) it("clears credentials and cancels authorization", async () => { + const clearSessionSpy = vi.spyOn(modelCache, "clearAuthSessionModelsForProvider") const { context, values } = createContext() values.set( "kimi-code-oauth-credentials", @@ -105,6 +109,8 @@ describe("KimiCodeOAuthManager", () => { await manager.clearCredentials() expect(values.has("kimi-code-oauth-credentials")).toBe(false) expect(await manager.isAuthenticated()).toBe(false) + expect(clearSessionSpy).toHaveBeenCalledWith(providerIdentifiers.kimiCode) + clearSessionSpy.mockRestore() }) it("does not restore credentials when sign-out races an in-flight refresh", async () => { diff --git a/src/integrations/kimi-code/oauth.ts b/src/integrations/kimi-code/oauth.ts index 0eb9581512..fc5c118069 100644 --- a/src/integrations/kimi-code/oauth.ts +++ b/src/integrations/kimi-code/oauth.ts @@ -233,6 +233,9 @@ export class KimiCodeOAuthManager { await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) this.credentials = null this.state = { status: "idle" } + const { clearAuthSessionModelsForProvider } = await import("../../api/providers/fetchers/modelCache") + const { providerIdentifiers } = await import("@roo-code/types") + clearAuthSessionModelsForProvider(providerIdentifiers.kimiCode) } async isAuthenticated(): Promise { diff --git a/src/services/__tests__/zoo-code-auth.test.ts b/src/services/__tests__/zoo-code-auth.test.ts index 78b81b1e67..f7c21b83bd 100644 --- a/src/services/__tests__/zoo-code-auth.test.ts +++ b/src/services/__tests__/zoo-code-auth.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { providerIdentifiers } from "@roo-code/types" +import * as modelCache from "../../api/providers/fetchers/modelCache" import { clearZooCodeToken, clearZooCodeUserInfo, @@ -167,6 +169,17 @@ describe("zoo-code-auth", () => { expect(getCachedZooCodeToken()).toBe("") }) + + it("clears the zoo-gateway session model cache", async () => { + const clearSessionSpy = vi.spyOn(modelCache, "clearAuthSessionModelsForProvider") + await initZooCodeAuth(mockContext) + await setZooCodeToken("zoo_ext_test_token") + + await clearZooCodeToken() + + expect(clearSessionSpy).toHaveBeenCalledWith(providerIdentifiers.zooGateway) + clearSessionSpy.mockRestore() + }) }) describe("getZooCodeBaseUrl", () => { diff --git a/src/services/zoo-code-auth.ts b/src/services/zoo-code-auth.ts index 709cb805c3..be714c596a 100644 --- a/src/services/zoo-code-auth.ts +++ b/src/services/zoo-code-auth.ts @@ -154,6 +154,9 @@ export async function clearZooCodeToken(): Promise { await secretStorage.delete(ZOO_CODE_TOKEN_KEY) _cachedToken = undefined _sessionCleared = true + const { clearAuthSessionModelsForProvider } = await import("../api/providers/fetchers/modelCache") + const { providerIdentifiers } = await import("@roo-code/types") + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) } export function getZooCodeBaseUrl(): string {