fix: keep model catalog reads read-only

This commit is contained in:
Shakker
2026-04-24 00:48:48 +01:00
committed by Shakker
parent 097a81fbe7
commit d289e400d5
3 changed files with 52 additions and 11 deletions

View File

@@ -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<typeof vi.fn>;
let ensureOpenClawModelsJsonMock: ReturnType<typeof vi.fn>;
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([
{

View File

@@ -81,15 +81,17 @@ function instantiatePiModelRegistry(
export async function loadModelCatalog(params?: {
config?: OpenClawConfig;
useCache?: boolean;
readOnly?: boolean;
}): Promise<ModelCatalogEntry[]> {
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;
}

View File

@@ -193,7 +193,7 @@ export async function appendCatalogSupplementRows(params: {
context: RowBuilderContext;
seenKeys: Set<string>;
}): Promise<void> {
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 &&