diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 8585741995a..bc03223722b 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -9,6 +9,7 @@ let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog; let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog; let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest; let augmentCatalogMock: ReturnType; +let ensureOpenClawModelsJsonMock: ReturnType; vi.mock("./model-suppression.runtime.js", () => ({ shouldSuppressBuiltInModel: (params: { provider?: string; id?: string }) => @@ -59,8 +60,9 @@ function mockSingleOpenAiCatalogModel() { describe("loadModelCatalog", () => { beforeAll(async () => { + ensureOpenClawModelsJsonMock = vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }); vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }), + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, })); vi.doMock("./agent-paths.js", () => ({ resolveOpenClawAgentDir: () => "/tmp/openclaw", @@ -81,6 +83,7 @@ describe("loadModelCatalog", () => { beforeEach(() => { resetModelCatalogCacheForTest(); + ensureOpenClawModelsJsonMock.mockClear(); }); afterEach(() => { @@ -146,6 +149,28 @@ describe("loadModelCatalog", () => { } }); + it("does not prepare models.json when loading catalog in read-only mode", async () => { + const discoverAuthStorage = vi.fn(() => ({})); + __setModelCatalogImportForTest( + async () => + ({ + discoverAuthStorage, + AuthStorage: function AuthStorage() {}, + ModelRegistry: class { + getAll() { + return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; + } + }, + }) as unknown as PiSdkModule, + ); + + const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); + + expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]); + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true }); + }); + it("does not synthesize stale openai-codex/gpt-5.3-codex-spark entries from gpt-5.4", async () => { mockPiDiscoveryModels([ { diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 5804826800e..b154db0eb6f 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -81,15 +81,17 @@ function instantiatePiModelRegistry( export async function loadModelCatalog(params?: { config?: OpenClawConfig; useCache?: boolean; + readOnly?: boolean; }): Promise { - if (params?.useCache === false) { + const readOnly = params?.readOnly === true; + if (!readOnly && params?.useCache === false) { modelCatalogPromise = null; } - if (modelCatalogPromise) { + if (!readOnly && modelCatalogPromise) { return modelCatalogPromise; } - modelCatalogPromise = (async () => { + const loadCatalog = async () => { const models: ModelCatalogEntry[] = []; const timingEnabled = shouldLogModelCatalogTiming(); const startMs = timingEnabled ? Date.now() : 0; @@ -110,8 +112,10 @@ export async function loadModelCatalog(params?: { }); try { const cfg = params?.config ?? loadConfig(); - await ensureOpenClawModelsJson(cfg); - logStage("models-json-ready"); + if (!readOnly) { + await ensureOpenClawModelsJson(cfg); + logStage("models-json-ready"); + } // IMPORTANT: keep the dynamic import *inside* the try/catch. // If this fails once (e.g. during a pnpm install that temporarily swaps node_modules), // we must not poison the cache with a rejected promise (otherwise all channel handlers @@ -121,7 +125,10 @@ export async function loadModelCatalog(params?: { const agentDir = resolveOpenClawAgentDir(); const { shouldSuppressBuiltInModel } = await loadModelSuppression(); logStage("catalog-deps-ready"); - const authStorage = piSdk.discoverAuthStorage(agentDir); + const authStorage = piSdk.discoverAuthStorage( + agentDir, + readOnly ? { readOnly: true } : undefined, + ); logStage("auth-storage-ready"); const registry = instantiatePiModelRegistry( piSdk, @@ -182,7 +189,9 @@ export async function loadModelCatalog(params?: { if (models.length === 0) { // If we found nothing, don't cache this result so we can try again. - modelCatalogPromise = null; + if (!readOnly) { + modelCatalogPromise = null; + } } const sorted = sortModels(models); @@ -194,14 +203,21 @@ export async function loadModelCatalog(params?: { log.warn(`Failed to load model catalog: ${String(error)}`); } // Don't poison the cache on transient dependency/filesystem issues. - modelCatalogPromise = null; + if (!readOnly) { + modelCatalogPromise = null; + } if (models.length > 0) { return sortModels(models); } return []; } - })(); + }; + if (readOnly) { + return loadCatalog(); + } + + modelCatalogPromise = loadCatalog(); return modelCatalogPromise; } diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 25d87e35fc6..88e0aa3fd1c 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -193,7 +193,7 @@ export async function appendCatalogSupplementRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { - const catalog = await loadModelCatalog({ config: params.context.cfg }); + const catalog = await loadModelCatalog({ config: params.context.cfg, readOnly: true }); for (const entry of catalog) { if ( params.context.filter.provider &&