|
@@ -1,5 +1,14 @@
|
|
|
// Mocks must come first, before imports
|
|
// Mocks must come first, before imports
|
|
|
|
|
|
|
|
|
|
+// Mock TelemetryService
|
|
|
|
|
+vi.mock("@roo-code/telemetry", () => ({
|
|
|
|
|
+ TelemetryService: {
|
|
|
|
|
+ instance: {
|
|
|
|
|
+ captureEvent: vi.fn(),
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
// Mock NodeCache to allow controlling cache behavior
|
|
// Mock NodeCache to allow controlling cache behavior
|
|
|
vi.mock("node-cache", () => {
|
|
vi.mock("node-cache", () => {
|
|
|
const mockGet = vi.fn().mockReturnValue(undefined)
|
|
const mockGet = vi.fn().mockReturnValue(undefined)
|
|
@@ -301,3 +310,187 @@ describe("getModelsFromCache disk fallback", () => {
|
|
|
consoleErrorSpy.mockRestore()
|
|
consoleErrorSpy.mockRestore()
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
|
|
+describe("empty cache protection", () => {
|
|
|
|
|
+ let mockCache: any
|
|
|
|
|
+ let mockGet: Mock
|
|
|
|
|
+ let mockSet: Mock
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ vi.clearAllMocks()
|
|
|
|
|
+ // Get the mock cache instance
|
|
|
|
|
+ const MockedNodeCache = vi.mocked(NodeCache)
|
|
|
|
|
+ mockCache = new MockedNodeCache()
|
|
|
|
|
+ mockGet = mockCache.get
|
|
|
|
|
+ mockSet = mockCache.set
|
|
|
|
|
+ // Reset memory cache to always miss by default
|
|
|
|
|
+ mockGet.mockReturnValue(undefined)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe("getModels", () => {
|
|
|
|
|
+ it("does not cache empty API responses", async () => {
|
|
|
|
|
+ // API returns empty object (simulating failure)
|
|
|
|
|
+ mockGetOpenRouterModels.mockResolvedValue({})
|
|
|
|
|
+
|
|
|
|
|
+ const result = await getModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return empty but NOT cache it
|
|
|
|
|
+ expect(result).toEqual({})
|
|
|
|
|
+ expect(mockSet).not.toHaveBeenCalled()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("caches non-empty API responses", async () => {
|
|
|
|
|
+ const mockModels = {
|
|
|
|
|
+ "openrouter/model": {
|
|
|
|
|
+ maxTokens: 8192,
|
|
|
|
|
+ contextWindow: 128000,
|
|
|
|
|
+ supportsPromptCache: false,
|
|
|
|
|
+ description: "OpenRouter model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ mockGetOpenRouterModels.mockResolvedValue(mockModels)
|
|
|
|
|
+
|
|
|
|
|
+ const result = await getModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toEqual(mockModels)
|
|
|
|
|
+ expect(mockSet).toHaveBeenCalledWith("openrouter", mockModels)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe("refreshModels", () => {
|
|
|
|
|
+ it("keeps existing cache when API returns empty response", async () => {
|
|
|
|
|
+ const existingModels = {
|
|
|
|
|
+ "openrouter/existing-model": {
|
|
|
|
|
+ maxTokens: 8192,
|
|
|
|
|
+ contextWindow: 128000,
|
|
|
|
|
+ supportsPromptCache: false,
|
|
|
|
|
+ description: "Existing cached model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Memory cache has existing data
|
|
|
|
|
+ mockGet.mockReturnValue(existingModels)
|
|
|
|
|
+ // API returns empty (failure)
|
|
|
|
|
+ mockGetOpenRouterModels.mockResolvedValue({})
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+ const result = await refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return existing cache, not empty
|
|
|
|
|
+ expect(result).toEqual(existingModels)
|
|
|
|
|
+ // Should NOT update cache with empty data
|
|
|
|
|
+ expect(mockSet).not.toHaveBeenCalled()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("updates cache when API returns valid non-empty response", async () => {
|
|
|
|
|
+ const existingModels = {
|
|
|
|
|
+ "openrouter/old-model": {
|
|
|
|
|
+ maxTokens: 4096,
|
|
|
|
|
+ contextWindow: 64000,
|
|
|
|
|
+ supportsPromptCache: false,
|
|
|
|
|
+ description: "Old model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ const newModels = {
|
|
|
|
|
+ "openrouter/new-model": {
|
|
|
|
|
+ maxTokens: 8192,
|
|
|
|
|
+ contextWindow: 128000,
|
|
|
|
|
+ supportsPromptCache: true,
|
|
|
|
|
+ description: "New model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mockGet.mockReturnValue(existingModels)
|
|
|
|
|
+ mockGetOpenRouterModels.mockResolvedValue(newModels)
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+ const result = await refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return new models
|
|
|
|
|
+ expect(result).toEqual(newModels)
|
|
|
|
|
+ // Should update cache with new data
|
|
|
|
|
+ expect(mockSet).toHaveBeenCalledWith("openrouter", newModels)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("returns existing cache on API error", async () => {
|
|
|
|
|
+ const existingModels = {
|
|
|
|
|
+ "openrouter/cached-model": {
|
|
|
|
|
+ maxTokens: 8192,
|
|
|
|
|
+ contextWindow: 128000,
|
|
|
|
|
+ supportsPromptCache: false,
|
|
|
|
|
+ description: "Cached model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mockGet.mockReturnValue(existingModels)
|
|
|
|
|
+ mockGetOpenRouterModels.mockRejectedValue(new Error("API error"))
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+ const result = await refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return existing cache on error
|
|
|
|
|
+ expect(result).toEqual(existingModels)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("returns empty object when API errors and no cache exists", async () => {
|
|
|
|
|
+ mockGet.mockReturnValue(undefined)
|
|
|
|
|
+ mockGetOpenRouterModels.mockRejectedValue(new Error("API error"))
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+ const result = await refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return empty when no cache and API fails
|
|
|
|
|
+ expect(result).toEqual({})
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not cache empty response when no existing cache", async () => {
|
|
|
|
|
+ // Both memory and disk cache are empty (initial state)
|
|
|
|
|
+ mockGet.mockReturnValue(undefined)
|
|
|
|
|
+ // API returns empty (failure/rate limit)
|
|
|
|
|
+ mockGetOpenRouterModels.mockResolvedValue({})
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+ const result = await refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // Should return empty but NOT cache it
|
|
|
|
|
+ expect(result).toEqual({})
|
|
|
|
|
+ expect(mockSet).not.toHaveBeenCalled()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("reuses in-flight request for concurrent calls to same provider", async () => {
|
|
|
|
|
+ const mockModels = {
|
|
|
|
|
+ "openrouter/model": {
|
|
|
|
|
+ maxTokens: 8192,
|
|
|
|
|
+ contextWindow: 128000,
|
|
|
|
|
+ supportsPromptCache: false,
|
|
|
|
|
+ description: "OpenRouter model",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create a delayed response to simulate API latency
|
|
|
|
|
+ let resolvePromise: (value: typeof mockModels) => void
|
|
|
|
|
+ const delayedPromise = new Promise<typeof mockModels>((resolve) => {
|
|
|
|
|
+ resolvePromise = resolve
|
|
|
|
|
+ })
|
|
|
|
|
+ mockGetOpenRouterModels.mockReturnValue(delayedPromise)
|
|
|
|
|
+ mockGet.mockReturnValue(undefined)
|
|
|
|
|
+
|
|
|
|
|
+ const { refreshModels } = await import("../modelCache")
|
|
|
|
|
+
|
|
|
|
|
+ // Start two concurrent refresh calls
|
|
|
|
|
+ const promise1 = refreshModels({ provider: "openrouter" })
|
|
|
|
|
+ const promise2 = refreshModels({ provider: "openrouter" })
|
|
|
|
|
+
|
|
|
|
|
+ // API should only be called once (second call reuses in-flight request)
|
|
|
|
|
+ expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(1)
|
|
|
|
|
+
|
|
|
|
|
+ // Resolve the API call
|
|
|
|
|
+ resolvePromise!(mockModels)
|
|
|
|
|
+
|
|
|
|
|
+ // Both promises should resolve to the same result
|
|
|
|
|
+ const [result1, result2] = await Promise.all([promise1, promise2])
|
|
|
|
|
+ expect(result1).toEqual(mockModels)
|
|
|
|
|
+ expect(result2).toEqual(mockModels)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+})
|