diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index c6f4c15c1e..65a83b94bc 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -456,6 +456,40 @@ describe("ZooGatewayHandler", () => { ]) }) + it("yields reasoning chunks from delta.reasoning_content before text", async () => { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { + choices: [{ delta: { reasoning_content: "thinking hard", 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: "thinking hard" }, + { type: "text", text: "answer" }, + ]) + }) + + 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 108aa1827b..e4058cdd6a 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -48,6 +48,7 @@ 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", () => ({ @@ -62,11 +63,21 @@ vi.mock("../../../core/config/ContextProxy", () => ({ // Then imports import type { Mock, Mocked } from "vitest" +import type { ModelRecord } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" +import * as fsPromises from "fs/promises" import NodeCache from "node-cache" import { TelemetryService } from "@roo-code/telemetry" -import { getModels, getModelsFromCache } from "../modelCache" +import { + getModels, + getModelsFromCache, + refreshModels, + flushModels, + clearAuthSessionModelsForProvider, + resetModelCacheTransientStateForTests, + authScopedClearGenerationSizeForTests, +} from "../modelCache" import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" @@ -74,6 +85,7 @@ import { getKenariModels } from "../kenari" import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" import { getZooGatewayModels } from "../zoo-gateway" +import { getKimiCodeModels } from "../kimi-code" const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock @@ -82,6 +94,11 @@ const mockGetKenariModels = getKenariModels as Mock const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock +const mockGetKimiCodeModels = getKimiCodeModels as Mock + +function zooGatewayOk(models: ModelRecord) { + return { kind: "ok" as const, models } +} const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -111,6 +128,60 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("logs disk-cache write failures without rejecting getModels", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(fsPromises.writeFile).mockRejectedValueOnce(new Error("disk full")) + mockGetOpenRouterModels.mockResolvedValue({ + "openrouter/model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + }) + + const result = await getModels({ provider: providerIdentifiers.openrouter }) + + expect(result).toEqual({ + "openrouter/model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("[MODEL_CACHE] Error writing"), + expect.any(Error), + ) + consoleErrorSpy.mockRestore() + }) + + it("logs disk-cache write failures without rejecting refreshModels", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(fsPromises.writeFile).mockRejectedValueOnce(new Error("disk full")) + mockGetOpenRouterModels.mockResolvedValue({ + "openrouter/model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + }) + + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) + + expect(result).toEqual({ + "openrouter/model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("[refreshModels] Error writing"), + expect.any(Error), + ) + consoleErrorSpy.mockRestore() + }) + it("calls getOpenRouterModels for openrouter provider", async () => { const mockModels = { "openrouter/model": { @@ -575,9 +646,9 @@ 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. - mockGetZooGatewayModels.mockResolvedValueOnce({}) + // zoo-gateway uses the auth session cache, not the shared memory/disk cache. + // A later empty response must still be reported when a forced refresh hits the API. + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk({})) await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }) @@ -591,19 +662,17 @@ describe("empty cache protection", () => { description: "Zoo Gateway model", }, } - mockGetZooGatewayModels.mockResolvedValueOnce(mockModels) + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk(mockModels)) await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }) - // Auth-scoped providers never populate the cache. expect(mockSet).not.toHaveBeenCalled() - mockGetZooGatewayModels.mockResolvedValueOnce({}) + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk({})) - await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }) + const { refreshModels } = await import("../modelCache") + await refreshModels({ provider: providerIdentifiers.zooGateway, apiKey: "test-key" }) - // 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(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) }) @@ -793,61 +862,37 @@ describe("empty cache protection", () => { }) describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { - type ModelCacheModule = typeof import("../modelCache") - - let freshGetModels: ModelCacheModule["getModels"] - let freshRefreshModels: ModelCacheModule["refreshModels"] - let freshMockGetOpenRouterModels: Mock - let freshMockGetLiteLLMModels: Mock - let freshMockGetZooGatewayModels: Mock - - beforeEach(async () => { - // The empty-response throttle is deliberately module-level, persistent state (once per - // cache key per session). Reset modules per test so each test starts with a clean gate. - vi.resetModules() + beforeEach(() => { + // Module-level throttle state; reset without vi.resetModules so Stryker sees mutants. + resetModelCacheTransientStateForTests() vi.clearAllMocks() - const modelCacheModule: ModelCacheModule = await import("../modelCache") - const openRouterModule = await import("../openrouter") - const liteLLMModule = await import("../litellm") - const zooGatewayModule = await import("../zoo-gateway") - - freshGetModels = modelCacheModule.getModels - freshRefreshModels = modelCacheModule.refreshModels - freshMockGetOpenRouterModels = openRouterModule.getOpenRouterModels as Mock - freshMockGetLiteLLMModels = liteLLMModule.getLiteLLMModels as Mock - freshMockGetZooGatewayModels = zooGatewayModule.getZooGatewayModels as Mock - - const NodeCacheModule = await import("node-cache") - const MockedNodeCache = vi.mocked(NodeCacheModule.default) + const MockedNodeCache = vi.mocked(NodeCache) const mockCache = vi.mocked(new MockedNodeCache()) mockCache.get.mockReturnValue(undefined) }) it("fires MODEL_CACHE_EMPTY_RESPONSE only once for repeated empty getModels responses from the same provider", async () => { - freshMockGetOpenRouterModels.mockResolvedValue({}) + mockGetOpenRouterModels.mockResolvedValue({}) - await freshGetModels({ provider: providerIdentifiers.openrouter }) - await freshGetModels({ provider: providerIdentifiers.openrouter }) - await freshGetModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.openrouter }) - 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" }), ) }) it("fires again after a non-empty response resets the throttle", async () => { - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - - freshMockGetOpenRouterModels.mockResolvedValue({}) - await freshGetModels({ provider: providerIdentifiers.openrouter }) - await freshGetModels({ provider: providerIdentifiers.openrouter }) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) + mockGetOpenRouterModels.mockResolvedValue({}) + await getModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.openrouter }) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) - freshMockGetOpenRouterModels.mockResolvedValue({ + mockGetOpenRouterModels.mockResolvedValue({ "openrouter/model": { maxTokens: 8192, contextWindow: 128000, @@ -855,36 +900,32 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { description: "OpenRouter model", }, }) - await freshGetModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.openrouter }) - freshMockGetOpenRouterModels.mockResolvedValue({}) - await freshGetModels({ provider: providerIdentifiers.openrouter }) + mockGetOpenRouterModels.mockResolvedValue({}) + await getModels({ provider: providerIdentifiers.openrouter }) - 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") - - freshMockGetOpenRouterModels.mockResolvedValue({}) - freshMockGetLiteLLMModels.mockResolvedValue({}) + mockGetOpenRouterModels.mockResolvedValue({}) + mockGetLiteLLMModels.mockResolvedValue({}) - await freshGetModels({ provider: providerIdentifiers.openrouter }) - await freshGetModels({ provider: providerIdentifiers.litellm, apiKey: "key", baseUrl: "http://localhost:4000" }) + await getModels({ provider: providerIdentifiers.openrouter }) + await getModels({ provider: providerIdentifiers.litellm, apiKey: "key", baseUrl: "http://localhost:4000" }) - 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 () => { - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") + mockGetOpenRouterModels.mockResolvedValue({}) - freshMockGetOpenRouterModels.mockResolvedValue({}) + await refreshModels({ provider: providerIdentifiers.openrouter }) + await refreshModels({ provider: providerIdentifiers.openrouter }) - await freshRefreshModels({ provider: providerIdentifiers.openrouter }) - await freshRefreshModels({ provider: providerIdentifiers.openrouter }) - - 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, @@ -899,27 +940,25 @@ 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") - - freshMockGetLiteLLMModels.mockResolvedValue({}) + mockGetLiteLLMModels.mockResolvedValue({}) - await freshGetModels({ + await getModels({ provider: providerIdentifiers.litellm, apiKey: "key-a", baseUrl: "http://server-a:4000", }) - await freshGetModels({ + await getModels({ provider: providerIdentifiers.litellm, apiKey: "key-a", baseUrl: "http://server-a:4000", }) - await freshGetModels({ + await getModels({ provider: providerIdentifiers.litellm, apiKey: "key-b", baseUrl: "http://server-b:4000", }) - 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 () => { @@ -928,37 +967,33 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { // identity: a sign-out/sign-in cycle to a different account carries a different // session token (apiKey) on the same gateway URL, and must not have its empty-response // signal suppressed by the previous account's throttle entry. - const { TelemetryService: FreshTelemetryService } = await import("@roo-code/telemetry") - - freshMockGetZooGatewayModels.mockResolvedValue({}) + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) - await freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) - await freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) - await freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-b-token" }) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-b-token" }) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) 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({})) - freshMockGetZooGatewayModels.mockResolvedValue({}) - - await freshGetModels({ + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "token", baseUrl: "https://gateway-a.example.com", }) - await freshGetModels({ + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "token", baseUrl: "https://gateway-b.example.com", }) - expect(FreshTelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(2) }) it("never shares results across different zoo-gateway credentials (auth isolation)", async () => { @@ -984,9 +1019,9 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { }, } - let resolveA: (value: typeof accountAModels) => void - let resolveB: (value: typeof accountBModels) => void - freshMockGetZooGatewayModels + let resolveA: (value: Awaited>) => void + let resolveB: (value: Awaited>) => void + mockGetZooGatewayModels .mockImplementationOnce( () => new Promise((resolve) => { @@ -1000,31 +1035,30 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => { }), ) - const promiseA = freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) - const promiseB = freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-b-token" }) + const promiseA = getModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-a-token" }) + const promiseB = getModels({ provider: providerIdentifiers.zooGateway, apiKey: "account-b-token" }) - expect(freshMockGetZooGatewayModels).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. - freshMockGetZooGatewayModels.mockResolvedValue({}) + it("deduplicates concurrent zoo-gateway fetches for the same session token", async () => { + // Auth-scoped providers use inFlightAuthScopedFetch to coalesce concurrent calls + // for the same cache key so two panels opening simultaneously share one fetch. + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk({})) await Promise.all([ - freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }), - freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }), + getModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }), + getModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }), ]) - expect(freshMockGetZooGatewayModels).toHaveBeenCalledTimes(2) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) }) }) @@ -1204,3 +1238,926 @@ describe("compound cache key derivation across scoping dimensions", () => { expect(cacheKey).toBe("openrouter") }) }) + +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) + await getModels(options) + + 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) + vi.advanceTimersByTime(60_000) + await getModels(options) + + 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" }) + await getModels({ provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + expect(mockGetKimiCodeModels).toHaveBeenCalledTimes(1) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "zoo-session" }) + await getModels({ provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }) + + 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) + const refreshed = await refreshModels(options) + + 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) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + await getModels(options) + + 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" }) + + 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) + + // 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) + 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) + + // 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) + + // 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) + 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) + // Advance by 1 second — well within the 5-minute TTL + vi.advanceTimersByTime(1000) + const second = await getModels(options) + + 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) + // Advance to exactly TTL — entry is now stale (< not <=) + vi.advanceTimersByTime(5 * 60 * 1000) + await getModels(options) + + // 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) + + // 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) + + // 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) + + // 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) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options) + + // 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) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options) + + // 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) + // Entry is fresh — without forceRefresh the second call would be served from cache + await refreshModels(options) + + // 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), getModels(options)]) + + 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) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const second = await getModels(options) + + 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) + 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) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + await flushModels(options, 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) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + const second = await getModels(options) + + 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)).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) + + 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) + const refreshed = await refreshModels(options) + + 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) + await getModels(options) + + 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) + await flushModels(options) + await getModels(options) + + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + }) + + it("flushModels for non-auth providers deletes the shared memory cache entry", async () => { + // Kills: ConditionalExpression mutant that always takes the auth-scoped flush branch + const MockedNodeCache = vi.mocked(NodeCache) + const mockCache = vi.mocked(new MockedNodeCache()) + const mockDel = mockCache.del + mockGetOpenRouterModels.mockResolvedValue({ + "openrouter/model": { maxTokens: 8192, contextWindow: 128000, supportsPromptCache: false }, + }) + + await getModels({ provider: providerIdentifiers.openrouter }) + await flushModels({ provider: providerIdentifiers.openrouter }) + + 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) + await flushModels(options) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + const result = await getModels(options) + + 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) + + 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) + await refreshModels(options) + + 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) // seed session + etag + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options) // empty → arm throttle (telemetry #1) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options) // 304 touch must delete throttle via reportedEmptyModelResponse.delete + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await getModels(options) // 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", + }) + await getModels({ + provider: providerIdentifiers.zooGateway, + apiKey: "account-b", + baseUrl: "https://gateway-b.test/v1", + }) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + + await getModels({ + provider: providerIdentifiers.zooGateway, + apiKey: "account-a", + baseUrl: "https://gateway-a.test/v1", + }) + await getModels({ + provider: providerIdentifiers.zooGateway, + apiKey: "account-b", + baseUrl: "https://gateway-b.test/v1", + }) + + 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) + await expect(flushModels(options, true)).resolves.toBeUndefined() + + const afterFailedRefresh = await getModels(options) + 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, 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) + await flushModels(options, true) + + const afterEmptyRefresh = await getModels(options) + 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) + vi.advanceTimersByTime(5 * 60 * 1000 * 2 + 1) + await getModels(second) + + mockGetZooGatewayModels.mockClear() + mockGetZooGatewayModels.mockResolvedValueOnce({ kind: "not_modified" }) + const result = await getModels(first) + + 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) + // Advance to EXACTLY 2x TTL — must be pruned (>= not just >) + vi.advanceTimersByTime(5 * 60 * 1000 * 2) + await getModels(second) + + mockGetZooGatewayModels.mockClear() + // First entry was pruned — a new fetch must fire (not_modified without etag yields {}) + mockGetZooGatewayModels.mockResolvedValueOnce(zooGatewayOk({})) + const result = await getModels(first) + + // 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}` }) + } + // 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" }) + 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}` }) + } + + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-0" }) + 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}` }) + } + + mockGetZooGatewayModels.mockResolvedValue(zooGatewayOk(zooModels)) + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-0" }) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + mockGetZooGatewayModels.mockClear() + await getModels({ provider: providerIdentifiers.zooGateway, apiKey: "session-64" }) + 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) + // Second call must fetch again (nothing was cached from first call) + const second = await getModels(options) + + 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) + + // 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) + + // 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) + + // 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) + + // 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) + + // 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) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) // gen → 1 + // fetch B (gen=1 at start) + const resB = getModels(options) + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) // gen → 2 + // fetch C (gen=2 at start) + const resC = getModels(options) + + // 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) + expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(1) + + clearAuthSessionModelsForProvider(providerIdentifiers.zooGateway) + await getModels(options) + + 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" }) + const kimiPending = getModels({ provider: providerIdentifiers.kimiCode, apiKey: "kimi-session" }) + + 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" }) + 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) + }) +}) 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 50dbe12f6e..550a74ad3f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -44,6 +44,17 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) // deduplicate each other's in-flight refreshes. const inFlightRefresh = new Map>() +// 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 @@ -108,10 +119,245 @@ const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.kimiCode, ]) -function isAuthScopedProvider(provider: RouterName): boolean { +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, + { forceRefresh = false }: { forceRefresh?: boolean } = {}, +): Promise { + const { provider } = options + const cacheKey = getCacheKey(options) + 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() @@ -224,6 +470,15 @@ async function readModels(cacheKey: string): Promise { async function fetchModelsFromProvider(options: GetModelsOptions): Promise { const { provider } = options + // Stryker disable next-line ConditionalExpression,StringLiteral: defensive type-narrowing guard; callers already route via isAuthScopedProvider + if (isAuthScopedProvider(provider)) { + // Stryker disable next-line CallExpression,StringLiteral: defensive throw; auth-scoped callers never reach this helper + throw new Error( + // Stryker disable next-line StringLiteral: defensive error message; auth-scoped callers never reach this helper + `fetchModelsFromProvider must not be called for auth-scoped provider "${provider}" — use resolveAuthScopedModels instead`, + ) + } + let models: ModelRecord switch (provider) { @@ -267,12 +522,6 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise const { provider } = options const cacheKey = getCacheKey(options) - const shouldSkipCache = isAuthScopedProvider(provider) + if (isAuthScopedProvider(provider)) { + return resolveAuthScopedModels(options) + } - const models = shouldSkipCache ? undefined : getModelsFromCache(options) + const models = getModelsFromCache(options) if (models) { return models @@ -318,26 +569,25 @@ export const getModels = async (options: GetModelsOptions): Promise // refreshModels() degrades to cached data doesn't surface as a silent stale result to // getModels(), and a fetch failure joined from refreshModels() still re-throws for // getModels() callers. - const sharedFetch = shouldSkipCache ? fetchModelsFromProvider(options) : dedupedFetch(cacheKey, options) + const sharedFetch = dedupedFetch(cacheKey, options) try { 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", @@ -392,17 +642,27 @@ export const refreshModels = async (options: GetModelsOptions): Promise - 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) || {} } } @@ -488,6 +740,19 @@ export async function initializeModelCacheRefresh(): Promise { * @param refresh - If true, immediately fetch fresh data from API */ export const flushModels = async (options: GetModelsOptions, refresh: boolean = false): Promise => { + if (isAuthScopedProvider(options.provider)) { + if (refresh) { + try { + await resolveAuthScopedModels(options, { forceRefresh: true }) + } catch (error) { + console.error(`[flushModels] Failed to refresh auth-scoped ${getCacheKey(options)} models:`, error) + } + } else { + deleteAuthSessionEntry(getCacheKey(options)) + } + return + } + if (refresh) { // Don't delete memory cache - let refreshModels atomically replace it // This prevents a race condition where getModels() might be called @@ -565,6 +830,28 @@ export function getModelsFromCache(options: GetModelsOptions | ProviderName): Mo 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. + */ +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). */ +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..5f2c80df83 100644 --- a/src/api/providers/fetchers/zoo-gateway.ts +++ b/src/api/providers/fetchers/zoo-gateway.ts @@ -20,29 +20,48 @@ 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 +75,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 +91,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 4ff059df61..d61cab3a20 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -18,6 +18,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, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { NOT_PROVIDED } from "./constants" @@ -233,6 +234,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 393e108645..15012487d4 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 cd28df8b04..df3e0884ca 100644 --- a/src/integrations/kimi-code/oauth.ts +++ b/src/integrations/kimi-code/oauth.ts @@ -232,6 +232,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 c08fcdedf3..79972ea924 100644 --- a/src/services/__tests__/zoo-code-auth.test.ts +++ b/src/services/__tests__/zoo-code-auth.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import * as vscode from "vscode" +import { providerIdentifiers } from "@roo-code/types" +import * as modelCache from "../../api/providers/fetchers/modelCache" import { clearZooCodeToken, clearZooCodeUserInfo, @@ -168,6 +170,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 {