From 969b23c581ae3ee0e2e50e5ef70ff4ad3c0d394b Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 26 Sep 2026 22:17:42 +0000 Subject: [PATCH 1/4] revert: cache the models.dev directory on disk (#1817) This reverts commit 141e90c77c51d634df40f25c58fccc55db09b74e. The startup auto-sync is already throttled to once a day and the directory arrives gzipped (~490 KB), so the disk cache saved little background traffic while adding a ~5 MB file to every vault's plugin folder, which sync tools, git and backups copy. Keep the directory in memory only, as before. #1817 was never part of a release, so no cleanup of models-dev-cache.json is needed. Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/AIAssistant.md | 6 - src/ai/modelsDirectory.cache.test.ts | 238 ---------------------- src/ai/modelsDirectory.ts | 152 +------------- src/main.ts | 14 -- 4 files changed, 6 insertions(+), 404 deletions(-) delete mode 100644 src/ai/modelsDirectory.cache.test.ts diff --git a/docs/src/content/docs/docs/AIAssistant.md b/docs/src/content/docs/docs/AIAssistant.md index 412c81783..4e1fb9219 100644 --- a/docs/src/content/docs/docs/AIAssistant.md +++ b/docs/src/content/docs/docs/AIAssistant.md @@ -178,12 +178,6 @@ window, output limit, and sampling support where the source reports them. If model import fails, you can still add models manually. Use the provider's exact model id and the model's context-window token count. -QuickAdd saves the models.dev directory (about 5 MB) as `models-dev-cache.json` -in its plugin folder. On later launches it asks models.dev whether the directory -changed and downloads it again only when it did. If models.dev is unreachable, -QuickAdd uses the saved copy. Deleting the file is safe; QuickAdd downloads it -again the next time it needs it. - ### Keep model lists current: Auto-sync {#auto-sync} Each provider has an **Auto-sync models** toggle. While it is on, QuickAdd diff --git a/src/ai/modelsDirectory.cache.test.ts b/src/ai/modelsDirectory.cache.test.ts deleted file mode 100644 index 8a5ef522f..000000000 --- a/src/ai/modelsDirectory.cache.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const storeState = vi.hoisted(() => ({ disableOnlineFeatures: false })); -const requestUrlMock = vi.hoisted(() => vi.fn()); - -vi.mock("obsidian", async (importOriginal) => ({ - ...(await importOriginal()), - requestUrl: requestUrlMock, -})); - -vi.mock("src/settingsStore", () => ({ - settingsStore: { getState: () => storeState }, -})); - -const V1 = { openai: { id: "openai", name: "OpenAI", models: { "gpt-a": { id: "gpt-a" } } } }; -const V2 = { openai: { id: "openai", name: "OpenAI", models: { "gpt-b": { id: "gpt-b" } } } }; - -/** A fake disk that outlives module re-imports, like the plugin folder outlives a restart. */ -function fakeDisk(initial: string | null = null) { - const disk = { - contents: initial, - writes: 0, - read: vi.fn(async () => disk.contents), - write: vi.fn(async (contents: string) => { - disk.contents = contents; - disk.writes += 1; - }), - }; - return disk; -} - -function ok(data: unknown, headers: Record = {}) { - return { status: 200, headers, json: data }; -} - -function notModified() { - return unparsableBody(304); -} - -/** A response whose body is not JSON (empty or truncated): reading .json throws. */ -function unparsableBody(status: number) { - return { - status, - headers: {}, - get json(): unknown { - throw new SyntaxError("Unexpected end of JSON input"); - }, - }; -} - -/** A fresh module instance: empty memory cache, as after an Obsidian restart. */ -async function launch(disk: ReturnType) { - vi.resetModules(); - const mod = await import("./modelsDirectory"); - mod.setModelsDirectoryDiskCache(disk); - return mod; -} - -function sentHeaders(call: number): Record | undefined { - return requestUrlMock.mock.calls[call][0].headers; -} - -describe("fetchModelsDevDirectory disk cache", () => { - beforeEach(() => { - requestUrlMock.mockReset(); - storeState.disableOnlineFeatures = false; - }); - - it("revalidates the saved copy with If-None-Match after a restart and reuses it on 304", async () => { - const disk = fakeDisk(); - - let mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok(V1, { etag: '"v1"' })); - expect(await mod.fetchModelsDevDirectory()).toEqual(V1); - expect(sentHeaders(0)?.["If-None-Match"]).toBeUndefined(); - expect(JSON.parse(disk.contents!)).toEqual({ etag: '"v1"', data: V1 }); - - mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(notModified()); - expect(await mod.fetchModelsDevDirectory()).toEqual(V1); - expect(sentHeaders(1)).toEqual({ "If-None-Match": '"v1"' }); - // A 304 carries no body; the saved copy must not be rewritten. - expect(disk.writes).toBe(1); - }); - - it("replaces the saved copy and its ETag when the directory changed", async () => { - const disk = fakeDisk(JSON.stringify({ etag: '"v1"', data: V1 })); - const mod = await launch(disk); - // Header names are matched case-insensitively. - requestUrlMock.mockResolvedValueOnce(ok(V2, { ETag: '"v2"' })); - - expect(await mod.fetchModelsDevDirectory()).toEqual(V2); - expect(sentHeaders(0)).toEqual({ "If-None-Match": '"v1"' }); - expect(JSON.parse(disk.contents!)).toEqual({ etag: '"v2"', data: V2 }); - - const next = await launch(disk); - requestUrlMock.mockResolvedValueOnce(notModified()); - expect(await next.fetchModelsDevDirectory()).toEqual(V2); - expect(sentHeaders(1)).toEqual({ "If-None-Match": '"v2"' }); - }); - - it("does not go back to the network within a session once loaded", async () => { - const disk = fakeDisk(JSON.stringify({ etag: '"v1"', data: V1 })); - const mod = await launch(disk); - requestUrlMock.mockResolvedValue(notModified()); - - await mod.fetchModelsDevDirectory(); - await mod.fetchModelsDevDirectory(); - expect(requestUrlMock).toHaveBeenCalledTimes(1); - }); - - it("shares one request between concurrent callers", async () => { - const disk = fakeDisk(); - const mod = await launch(disk); - requestUrlMock.mockResolvedValue(ok(V1, { etag: '"v1"' })); - - const [a, b] = await Promise.all([ - mod.fetchModelsDevDirectory(), - mod.fetchModelsDevDirectory(), - ]); - expect(a).toEqual(V1); - expect(b).toEqual(V1); - expect(requestUrlMock).toHaveBeenCalledTimes(1); - }); - - it("falls back to the saved copy when revalidation fails", async () => { - const disk = fakeDisk(JSON.stringify({ etag: '"v1"', data: V1 })); - const mod = await launch(disk); - requestUrlMock.mockRejectedValueOnce(new Error("net::ERR_INTERNET_DISCONNECTED")); - - expect(await mod.fetchModelsDevDirectory()).toEqual(V1); - }); - - it("still throws a network failure when nothing is saved", async () => { - const mod = await launch(fakeDisk()); - requestUrlMock.mockRejectedValueOnce(new Error("net::ERR_INTERNET_DISCONNECTED")); - - await expect(mod.fetchModelsDevDirectory()).rejects.toThrow( - "ERR_INTERNET_DISCONNECTED", - ); - }); - - it("keeps the online-features gate even when a saved copy exists", async () => { - const disk = fakeDisk(JSON.stringify({ etag: '"v1"', data: V1 })); - const mod = await launch(disk); - storeState.disableOnlineFeatures = true; - - await expect(mod.fetchModelsDevDirectory()).rejects.toThrow( - /Online features are turned off/, - ); - expect(requestUrlMock).not.toHaveBeenCalled(); - }); - - it.each([ - ["a non-JSON body", unparsableBody(200)], - ["an array body", ok([])], - ["an empty object body", ok({})], - ["a provider without models", ok({ openai: { id: "openai", name: "OpenAI" } })], - ])("keeps the saved copy when a 200 refresh has %s", async (_label, response) => { - const saved = JSON.stringify({ etag: '"v1"', data: V1 }); - const disk = fakeDisk(saved); - const mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(response); - - expect(await mod.fetchModelsDevDirectory()).toEqual(V1); - expect(disk.contents).toBe(saved); - }); - - it("throws on an unusable 200 when nothing is saved, without saving it", async () => { - const disk = fakeDisk(); - const mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok([])); - - await expect(mod.fetchModelsDevDirectory()).rejects.toThrow( - /unexpected response/, - ); - expect(disk.contents).toBeNull(); - }); - - it.each([ - ["corrupt JSON", "{not json"], - ["missing data", JSON.stringify({ etag: '"v1"' })], - ["empty data", JSON.stringify({ etag: '"v1"', data: {} })], - ["array data", JSON.stringify({ etag: '"v1"', data: [] })], - ["a provider without models", JSON.stringify({ etag: '"v1"', data: { openai: {} } })], - ])("treats a saved copy with %s as a miss", async (_label, contents) => { - const disk = fakeDisk(contents); - const mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok(V2, { etag: '"v2"' })); - - expect(await mod.fetchModelsDevDirectory()).toEqual(V2); - expect(sentHeaders(0)?.["If-None-Match"]).toBeUndefined(); - expect(JSON.parse(disk.contents!)).toEqual({ etag: '"v2"', data: V2 }); - }); - - it("saves a response without an ETag but sends no validator next time", async () => { - const disk = fakeDisk(); - let mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok(V1)); - await mod.fetchModelsDevDirectory(); - - mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok(V2)); - expect(await mod.fetchModelsDevDirectory()).toEqual(V2); - expect(sentHeaders(1)?.["If-None-Match"]).toBeUndefined(); - }); - - it("returns fresh data even when saving it fails", async () => { - const disk = fakeDisk(); - disk.write.mockRejectedValueOnce(new Error("EACCES")); - const mod = await launch(disk); - requestUrlMock.mockResolvedValueOnce(ok(V1, { etag: '"v1"' })); - - expect(await mod.fetchModelsDevDirectory()).toEqual(V1); - }); -}); - -describe("pluginFolderDirectoryCache", () => { - it("stores the directory as a file in the plugin folder, not in data.json", async () => { - const files = new Map(); - const adapter = { - exists: vi.fn(async (path: string) => files.has(path)), - read: vi.fn(async (path: string) => files.get(path)!), - write: vi.fn(async (path: string, data: string) => { - files.set(path, data); - }), - }; - const { pluginFolderDirectoryCache } = await import("./modelsDirectory"); - const cache = pluginFolderDirectoryCache(adapter, ".obsidian/plugins/quickadd/"); - - expect(await cache.read()).toBeNull(); - await cache.write("payload"); - expect([...files.keys()]).toEqual([ - ".obsidian/plugins/quickadd/models-dev-cache.json", - ]); - expect(await cache.read()).toBe("payload"); - }); -}); diff --git a/src/ai/modelsDirectory.ts b/src/ai/modelsDirectory.ts index a53b79cd6..ffa458195 100644 --- a/src/ai/modelsDirectory.ts +++ b/src/ai/modelsDirectory.ts @@ -1,8 +1,6 @@ -import { normalizePath, requestUrl } from "obsidian"; -import type { DataAdapter } from "obsidian"; +import { requestUrl } from "obsidian"; import type { Model } from "./Provider"; import { settingsStore } from "src/settingsStore"; -import { log } from "src/logger/logManager"; export type ModelsDevModel = { id: string; @@ -23,54 +21,9 @@ export type ModelsDevProvider = { export type ModelsDevDirectory = Record; -const MODELS_DEV_URL = "https://models.dev/api.json"; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; - -/** - * Where the last downloaded directory lives between Obsidian launches. - * main.ts backs this with a file in the plugin folder; tests use a fake. - */ -export interface ModelsDirectoryDiskCache { - read(): Promise; - write(contents: string): Promise; -} - -type PersistedDirectory = { etag?: string; data: ModelsDevDirectory }; - -let diskCache: ModelsDirectoryDiskCache | null = null; let cachedDirectory: { data: ModelsDevDirectory; fetchedAt: number } | null = null; -let inFlight: Promise | null = null; - -export const MODELS_DIRECTORY_CACHE_FILE = "models-dev-cache.json"; - -/** - * Disk cache backed by a file in the plugin folder, next to data.json but - * separate from it: the directory is ~5 MB and settings saves rewrite data.json. - */ -export function pluginFolderDirectoryCache( - adapter: Pick, - pluginDir: string, -): ModelsDirectoryDiskCache { - const path = normalizePath(`${pluginDir}/${MODELS_DIRECTORY_CACHE_FILE}`); - return { - read: async () => ((await adapter.exists(path)) ? adapter.read(path) : null), - write: (contents) => adapter.write(path, contents), - }; -} - -export function setModelsDirectoryDiskCache( - cache: ModelsDirectoryDiskCache | null, -): void { - diskCache = cache; - cachedDirectory = null; -} +const ONE_DAY_MS = 24 * 60 * 60 * 1000; -/** - * The models.dev directory (~5 MB). Kept in memory for a day, and on disk - * across launches: the first fetch of a session revalidates the disk copy with - * If-None-Match, so an unchanged directory costs a bodiless 304 instead of a - * full download. If revalidation fails, the disk copy is used as-is. - */ export async function fetchModelsDevDirectory(): Promise { if ( cachedDirectory && @@ -85,109 +38,16 @@ export async function fetchModelsDevDirectory(): Promise { ); } - inFlight ??= revalidateDirectory().finally(() => { - inFlight = null; + const response = await requestUrl({ + url: "https://models.dev/api.json", + method: "GET", }); - return inFlight; -} - -async function revalidateDirectory(): Promise { - const persisted = await readPersistedDirectory(); - - let fresh: PersistedDirectory; - try { - const response = await requestUrl({ - url: MODELS_DEV_URL, - method: "GET", - headers: persisted?.etag ? { "If-None-Match": persisted.etag } : undefined, - }); - if (response.status === 304 && persisted) { - return remember(persisted.data); - } - const data: unknown = response.json; - if (!isDirectory(data)) { - throw new Error("models.dev returned an unexpected response."); - } - fresh = { etag: headerValue(response.headers, "etag"), data }; - } catch (err) { - if (!persisted) throw err; - log.logMessage( - `Could not refresh the models.dev directory; using the saved copy. ${ - (err as Error)?.message ?? String(err) - }`, - ); - return remember(persisted.data); - } - - await writePersistedDirectory(fresh); - return remember(fresh.data); -} - -const isPlainObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -/** - * A directory is a non-empty map of providers, each with a `models` map; - * anything else is unusable (discovery reads `directory[key].models`). - */ -function isDirectory(value: unknown): value is ModelsDevDirectory { - if (!isPlainObject(value)) return false; - const providers = Object.values(value); - return ( - providers.length > 0 && - providers.every( - (provider) => isPlainObject(provider) && isPlainObject(provider.models), - ) - ); -} -function remember(data: ModelsDevDirectory): ModelsDevDirectory { + const data = (await response.json) as ModelsDevDirectory; cachedDirectory = { data, fetchedAt: Date.now() }; return data; } -function headerValue( - headers: Record, - name: string, -): string | undefined { - const match = Object.keys(headers ?? {}).find( - (key) => key.toLowerCase() === name, - ); - return match ? headers[match] : undefined; -} - -async function readPersistedDirectory(): Promise { - if (!diskCache) return null; - try { - const raw = await diskCache.read(); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - if (!isDirectory(parsed.data)) return null; - return { - etag: typeof parsed.etag === "string" ? parsed.etag : undefined, - data: parsed.data, - }; - } catch (err) { - // A corrupt or unreadable cache is just a cache miss. - log.logMessage( - `Ignoring unreadable models.dev cache: ${(err as Error)?.message ?? String(err)}`, - ); - return null; - } -} - -async function writePersistedDirectory(entry: PersistedDirectory): Promise { - if (!diskCache) return; - try { - await diskCache.write(JSON.stringify(entry)); - } catch (err) { - // Failing to persist only costs a re-download next launch. - log.logMessage( - `Could not save the models.dev cache: ${(err as Error)?.message ?? String(err)}`, - ); - } -} - // Extract the lowercased hostname from an endpoint, tolerating a missing // scheme (e.g. "api.openai.com/v1"). Returns "" when it can't be parsed. function endpointHost(endpoint: string): string { diff --git a/src/main.ts b/src/main.ts index d62964df4..10e96047d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,10 +59,6 @@ import { ingestImagesIntoActivePrompt as ingestPromptImages } from "./gui/imageP import { setQuickAddInstance } from "./quickAddInstance"; import { registerQuickAddUri } from "./uri/registerQuickAddUri"; import { registerCoreCommands } from "./plugin/registerCoreCommands"; -import { - pluginFolderDirectoryCache, - setModelsDirectoryDiskCache, -} from "./ai/modelsDirectory"; // The settingsStore subscriber fires on every store change — including high-frequency // ones like folder collapse toggles. Coalesce those full-settings disk writes into one @@ -216,16 +212,6 @@ export default class QuickAdd extends Plugin { this.app.workspace.onLayoutReady(launchStartupMacros); } - // Persist the models.dev directory across launches so model discovery - // revalidates it (ETag/304) instead of re-downloading ~5 MB each start. - setModelsDirectoryDiskCache( - pluginFolderDirectoryCache( - this.app.vault.adapter, - this.manifest.dir ?? - `${this.app.vault.configDir}/plugins/${this.manifest.id}`, - ), - ); - // Keep AI provider model lists current without plugin releases: a quiet, // daily-throttled background sync for providers that opted in. Deferred // past layout-ready so it never competes with startup work. From 675d648d76e264ba7281f01be2f141d86b1a40ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 22:24:24 +0000 Subject: [PATCH 2/4] fix(ai): validate models.dev directory before memory cache Keep the structural isDirectory check from #1817 without restoring the disk cache, so a malformed 200 cannot poison the 24h in-memory directory and break Sync now for the rest of the session. Co-authored-by: Christian Bager Bach Houmann --- src/ai/modelsDirectory.fetch.test.ts | 67 ++++++++++++++++++++++++++++ src/ai/modelsDirectory.ts | 25 ++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/ai/modelsDirectory.fetch.test.ts diff --git a/src/ai/modelsDirectory.fetch.test.ts b/src/ai/modelsDirectory.fetch.test.ts new file mode 100644 index 000000000..00eb91d7d --- /dev/null +++ b/src/ai/modelsDirectory.fetch.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const storeState = vi.hoisted(() => ({ disableOnlineFeatures: false })); +const requestUrlMock = vi.hoisted(() => vi.fn()); + +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal()), + requestUrl: requestUrlMock, +})); + +vi.mock("src/settingsStore", () => ({ + settingsStore: { getState: () => storeState }, +})); + +const VALID = { + openai: { + id: "openai", + name: "OpenAI", + models: { "gpt-a": { id: "gpt-a" } }, + }, +}; + +/** Fresh module = empty memory cache (as after an Obsidian restart). */ +async function loadFetch() { + vi.resetModules(); + const mod = await import("./modelsDirectory"); + return mod.fetchModelsDevDirectory; +} + +describe("fetchModelsDevDirectory validation", () => { + beforeEach(() => { + requestUrlMock.mockReset(); + storeState.disableOnlineFeatures = false; + }); + + it("caches a valid directory for the session", async () => { + const fetch = await loadFetch(); + requestUrlMock.mockResolvedValueOnce({ status: 200, json: VALID }); + + expect(await fetch()).toEqual(VALID); + expect(await fetch()).toEqual(VALID); + expect(requestUrlMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["an array body", []], + ["an empty object", {}], + ["a provider without models", { openai: { id: "openai", name: "OpenAI" } }], + ])("rejects %s without poisoning the memory cache", async (_label, body) => { + const fetch = await loadFetch(); + requestUrlMock + .mockResolvedValueOnce({ status: 200, json: body }) + .mockResolvedValueOnce({ status: 200, json: VALID }); + + await expect(fetch()).rejects.toThrow(/unexpected response/); + expect(await fetch()).toEqual(VALID); + expect(requestUrlMock).toHaveBeenCalledTimes(2); + }); + + it("keeps the online-features gate", async () => { + const fetch = await loadFetch(); + storeState.disableOnlineFeatures = true; + + await expect(fetch()).rejects.toThrow(/Online features are turned off/); + expect(requestUrlMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ai/modelsDirectory.ts b/src/ai/modelsDirectory.ts index ffa458195..9b74b709c 100644 --- a/src/ai/modelsDirectory.ts +++ b/src/ai/modelsDirectory.ts @@ -24,6 +24,24 @@ export type ModelsDevDirectory = Record; let cachedDirectory: { data: ModelsDevDirectory; fetchedAt: number } | null = null; const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A directory is a non-empty map of providers, each with a `models` map; + * anything else is unusable (discovery reads `directory[key].models`). + */ +function isDirectory(value: unknown): value is ModelsDevDirectory { + if (!isPlainObject(value)) return false; + const providers = Object.values(value); + return ( + providers.length > 0 && + providers.every( + (provider) => isPlainObject(provider) && isPlainObject(provider.models), + ) + ); +} + export async function fetchModelsDevDirectory(): Promise { if ( cachedDirectory && @@ -43,7 +61,12 @@ export async function fetchModelsDevDirectory(): Promise { method: "GET", }); - const data = (await response.json) as ModelsDevDirectory; + // Validate before the 24h memory cache so a bad 200 cannot poison Sync now + // for the rest of the session (independent of the reverted disk cache). + const data: unknown = response.json; + if (!isDirectory(data)) { + throw new Error("models.dev returned an unexpected response."); + } cachedDirectory = { data, fetchedAt: Date.now() }; return data; } From b405c268a874b5aa5841df13d82ddbfc952989e5 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 26 Sep 2026 22:28:01 +0000 Subject: [PATCH 3/4] fix(ai): accept a models.dev directory unless no provider is usable Rejecting the whole directory when one provider lacks a models map would break models.dev sync for every provider (there is no saved copy to fall back to anymore), while master only failed the provider actually read. Still rejects [], {} and directories without any usable provider. Co-authored-by: Christian Bager Bach Houmann --- src/ai/modelsDirectory.fetch.test.ts | 8 ++++++++ src/ai/modelsDirectory.ts | 11 +++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/ai/modelsDirectory.fetch.test.ts b/src/ai/modelsDirectory.fetch.test.ts index 00eb91d7d..a1e7803a0 100644 --- a/src/ai/modelsDirectory.fetch.test.ts +++ b/src/ai/modelsDirectory.fetch.test.ts @@ -57,6 +57,14 @@ describe("fetchModelsDevDirectory validation", () => { expect(requestUrlMock).toHaveBeenCalledTimes(2); }); + it("keeps the directory when only some providers are malformed", async () => { + const fetch = await loadFetch(); + const body = { ...VALID, broken: { id: "broken", name: "Broken" } }; + requestUrlMock.mockResolvedValueOnce({ status: 200, json: body }); + + expect(await fetch()).toEqual(body); + }); + it("keeps the online-features gate", async () => { const fetch = await loadFetch(); storeState.disableOnlineFeatures = true; diff --git a/src/ai/modelsDirectory.ts b/src/ai/modelsDirectory.ts index 9b74b709c..d8b6e611a 100644 --- a/src/ai/modelsDirectory.ts +++ b/src/ai/modelsDirectory.ts @@ -28,15 +28,14 @@ const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** - * A directory is a non-empty map of providers, each with a `models` map; - * anything else is unusable (discovery reads `directory[key].models`). + * A usable directory is a map of providers in which at least one provider has + * a `models` map. One malformed entry must not discard the other ~200 + * providers: discovery only fails for the provider it actually reads. */ function isDirectory(value: unknown): value is ModelsDevDirectory { - if (!isPlainObject(value)) return false; - const providers = Object.values(value); return ( - providers.length > 0 && - providers.every( + isPlainObject(value) && + Object.values(value).some( (provider) => isPlainObject(provider) && isPlainObject(provider.models), ) ); From 35341acc052e9241b49830d7cc2b769a898b7d11 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 26 Sep 2026 22:36:15 +0000 Subject: [PATCH 4/4] fix(ai): drop malformed models.dev providers instead of keeping them Filter the directory to providers with a models map before caching, so a bad entry makes discovery report that models.dev doesn't list the provider instead of throwing a TypeError on directory[key].models. Co-authored-by: Christian Bager Bach Houmann --- src/ai/modelsDirectory.fetch.test.ts | 10 +++++++--- src/ai/modelsDirectory.ts | 24 +++++++++++++----------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/ai/modelsDirectory.fetch.test.ts b/src/ai/modelsDirectory.fetch.test.ts index a1e7803a0..6ed6d8bb2 100644 --- a/src/ai/modelsDirectory.fetch.test.ts +++ b/src/ai/modelsDirectory.fetch.test.ts @@ -57,12 +57,16 @@ describe("fetchModelsDevDirectory validation", () => { expect(requestUrlMock).toHaveBeenCalledTimes(2); }); - it("keeps the directory when only some providers are malformed", async () => { + it("drops only the malformed providers and keeps the rest", async () => { const fetch = await loadFetch(); - const body = { ...VALID, broken: { id: "broken", name: "Broken" } }; + const body = { + ...VALID, + broken: { id: "broken", name: "Broken" }, + nullModels: { id: "nullModels", name: "Null", models: null }, + }; requestUrlMock.mockResolvedValueOnce({ status: 200, json: body }); - expect(await fetch()).toEqual(body); + expect(await fetch()).toEqual(VALID); }); it("keeps the online-features gate", async () => { diff --git a/src/ai/modelsDirectory.ts b/src/ai/modelsDirectory.ts index d8b6e611a..6d8240d91 100644 --- a/src/ai/modelsDirectory.ts +++ b/src/ai/modelsDirectory.ts @@ -28,17 +28,19 @@ const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** - * A usable directory is a map of providers in which at least one provider has - * a `models` map. One malformed entry must not discard the other ~200 - * providers: discovery only fails for the provider it actually reads. + * The providers in a models.dev response that have a `models` map, or null + * when there are none. Malformed entries are dropped rather than failing the + * whole directory, so only lookups of that provider miss (with discovery's + * "models.dev does not list a provider" error instead of a TypeError). */ -function isDirectory(value: unknown): value is ModelsDevDirectory { - return ( - isPlainObject(value) && - Object.values(value).some( - (provider) => isPlainObject(provider) && isPlainObject(provider.models), - ) +function usableDirectory(value: unknown): ModelsDevDirectory | null { + if (!isPlainObject(value)) return null; + const usable = Object.entries(value).filter( + ([, provider]) => isPlainObject(provider) && isPlainObject(provider.models), ); + return usable.length > 0 + ? (Object.fromEntries(usable) as ModelsDevDirectory) + : null; } export async function fetchModelsDevDirectory(): Promise { @@ -62,8 +64,8 @@ export async function fetchModelsDevDirectory(): Promise { // Validate before the 24h memory cache so a bad 200 cannot poison Sync now // for the rest of the session (independent of the reverted disk cache). - const data: unknown = response.json; - if (!isDirectory(data)) { + const data = usableDirectory(response.json); + if (!data) { throw new Error("models.dev returned an unexpected response."); } cachedDirectory = { data, fetchedAt: Date.now() };