Skip to content

Commit 57befa4

Browse files
author
ai-hpc
committed
fix(models): refresh persisted catalog cache keys
1 parent 68eac51 commit 57befa4

5 files changed

Lines changed: 195 additions & 31 deletions

File tree

src/agents/model-catalog.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ let currentPluginMetadataSnapshotMock: ReturnType<typeof vi.fn<(...args: unknown
1919
let loadPluginMetadataSnapshotMock: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>;
2020
let readFileMock: ReturnType<typeof vi.fn<(pathname: string) => Promise<string>>>;
2121
let buildAgentModelCatalogCacheKeyMock: ReturnType<typeof vi.fn>;
22+
let buildModelsJsonSourceFingerprintMock: ReturnType<typeof vi.fn>;
2223
let readCachedAgentModelCatalogMock: ReturnType<typeof vi.fn>;
2324
let writeCachedAgentModelCatalogMock: ReturnType<typeof vi.fn>;
2425

@@ -239,10 +240,19 @@ describe("loadModelCatalog", () => {
239240
readFile: readFileMock,
240241
}));
241242
ensureOpenClawModelsJsonMock = vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false });
243+
buildModelsJsonSourceFingerprintMock = vi.fn().mockResolvedValue({
244+
agentDir: "/tmp/openclaw",
245+
fingerprint: "source-fingerprint",
246+
workspaceDir: "/tmp/openclaw-workspace",
247+
});
242248
vi.doMock("./models-config.js", () => ({
249+
buildModelsJsonSourceFingerprint: buildModelsJsonSourceFingerprintMock,
243250
ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock,
244251
}));
245-
buildAgentModelCatalogCacheKeyMock = vi.fn(() => "test-cache-key");
252+
buildAgentModelCatalogCacheKeyMock = vi.fn(
253+
(input: { cacheScope?: { sourceFingerprint?: string } }) =>
254+
`test-cache-key:${input.cacheScope?.sourceFingerprint ?? "none"}`,
255+
);
246256
readCachedAgentModelCatalogMock = vi.fn(() => undefined);
247257
writeCachedAgentModelCatalogMock = vi.fn();
248258
vi.doMock("./model-catalog-state-cache.js", () => ({
@@ -317,6 +327,12 @@ describe("loadModelCatalog", () => {
317327
currentPluginMetadataSnapshotMock.mockReturnValue(undefined);
318328
loadPluginMetadataSnapshotMock.mockReset();
319329
loadPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot());
330+
buildModelsJsonSourceFingerprintMock.mockClear();
331+
buildModelsJsonSourceFingerprintMock.mockResolvedValue({
332+
agentDir: "/tmp/openclaw",
333+
fingerprint: "source-fingerprint",
334+
workspaceDir: "/tmp/openclaw-workspace",
335+
});
320336
buildAgentModelCatalogCacheKeyMock.mockClear();
321337
readCachedAgentModelCatalogMock.mockReset();
322338
readCachedAgentModelCatalogMock.mockReturnValue(undefined);
@@ -407,7 +423,7 @@ describe("loadModelCatalog", () => {
407423
expect(result).toEqual(cached);
408424
expect(readCachedAgentModelCatalogMock).toHaveBeenCalledWith({
409425
agentDir: "/tmp/openclaw",
410-
catalogKey: "test-cache-key",
426+
catalogKey: "test-cache-key:source-fingerprint",
411427
});
412428
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
413429
expect(importAgentDiscoveryModule).not.toHaveBeenCalled();
@@ -426,7 +442,7 @@ describe("loadModelCatalog", () => {
426442
expect(readCachedAgentModelCatalogMock).not.toHaveBeenCalled();
427443
expect(writeCachedAgentModelCatalogMock).toHaveBeenCalledWith({
428444
agentDir: "/tmp/openclaw",
429-
catalogKey: "test-cache-key",
445+
catalogKey: "test-cache-key:source-fingerprint",
430446
entries: result,
431447
});
432448
});
@@ -439,11 +455,49 @@ describe("loadModelCatalog", () => {
439455
expect(result).toEqual([{ id: "runtime-fast", name: "Runtime Fast", provider: "openai" }]);
440456
expect(writeCachedAgentModelCatalogMock).toHaveBeenCalledWith({
441457
agentDir: "/tmp/openclaw",
442-
catalogKey: "test-cache-key",
458+
catalogKey: "test-cache-key:source-fingerprint",
443459
entries: result,
444460
});
445461
});
446462

463+
it("misses the state cached catalog when source freshness changes", async () => {
464+
buildModelsJsonSourceFingerprintMock
465+
.mockResolvedValueOnce({
466+
agentDir: "/tmp/openclaw",
467+
fingerprint: "old-source",
468+
workspaceDir: "/tmp/openclaw-workspace",
469+
})
470+
.mockResolvedValueOnce({
471+
agentDir: "/tmp/openclaw",
472+
fingerprint: "new-source",
473+
workspaceDir: "/tmp/openclaw-workspace",
474+
});
475+
readCachedAgentModelCatalogMock.mockImplementation(({ catalogKey }: { catalogKey: string }) =>
476+
catalogKey.endsWith("old-source")
477+
? [{ id: "cached-stale", name: "Cached Stale", provider: "openai" }]
478+
: undefined,
479+
);
480+
mockAgentDiscoveryModels([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]);
481+
482+
await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toEqual([
483+
{ id: "cached-stale", name: "Cached Stale", provider: "openai" },
484+
]);
485+
resetModelCatalogCacheForTest();
486+
mockAgentDiscoveryModels([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]);
487+
await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toEqual([
488+
{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" },
489+
]);
490+
491+
expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(1, {
492+
agentDir: "/tmp/openclaw",
493+
catalogKey: "test-cache-key:old-source",
494+
});
495+
expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(2, {
496+
agentDir: "/tmp/openclaw",
497+
catalogKey: "test-cache-key:new-source",
498+
});
499+
});
500+
447501
it("reloads dynamic registry entries after clearing the cache", async () => {
448502
const models = [{ id: "existing", name: "Existing", provider: "ollama" }];
449503
mockAgentDiscoveryModels(models);

src/agents/model-catalog.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
buildConfiguredModelCatalog,
4141
hasConfiguredProviderModelRows,
4242
} from "./model-selection-shared.js";
43-
import { ensureOpenClawModelsJson } from "./models-config.js";
43+
import { buildModelsJsonSourceFingerprint, ensureOpenClawModelsJson } from "./models-config.js";
4444
import {
4545
filterGeneratedPluginModelCatalogProviders,
4646
listPluginModelCatalogFiles,
@@ -367,10 +367,18 @@ async function loadReadOnlyPersistedModelCatalog(params?: {
367367
manifestPlugins ??= getMetadataSnapshot().plugins;
368368
return manifestPlugins;
369369
};
370+
const sourceFingerprint = await buildModelsJsonSourceFingerprint(cfg, agentDir, {
371+
pluginMetadataSnapshot: params?.metadataSnapshot,
372+
workspaceDir,
373+
});
370374
const cached = readCachedAgentModelCatalog({
371375
agentDir,
372376
catalogKey: buildAgentModelCatalogCacheKey({
373377
agentDir,
378+
cacheScope: {
379+
source: "load-model-catalog",
380+
sourceFingerprint: sourceFingerprint.fingerprint,
381+
},
374382
config: cfg,
375383
workspaceDir,
376384
}),
@@ -540,8 +548,16 @@ export async function loadModelCatalog(params?: {
540548
return manifestPlugins;
541549
};
542550
const agentDir = resolveDefaultAgentDir(cfg);
551+
const sourceFingerprint = await buildModelsJsonSourceFingerprint(cfg, agentDir, {
552+
pluginMetadataSnapshot: params?.metadataSnapshot,
553+
workspaceDir,
554+
});
543555
const catalogKey = buildAgentModelCatalogCacheKey({
544556
agentDir,
557+
cacheScope: {
558+
source: "load-model-catalog",
559+
sourceFingerprint: sourceFingerprint.fingerprint,
560+
},
545561
config: cfg,
546562
workspaceDir,
547563
});

src/agents/models-config.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -270,27 +270,8 @@ function resolveModelsConfigInput(config?: OpenClawConfig): {
270270
};
271271
}
272272

273-
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
274-
const prior = MODELS_JSON_STATE.writeLocks.get(targetPath) ?? Promise.resolve();
275-
let release: () => void = () => {};
276-
const gate = new Promise<void>((resolve) => {
277-
release = resolve;
278-
});
279-
const pending = prior.then(() => gate);
280-
MODELS_JSON_STATE.writeLocks.set(targetPath, pending);
281-
try {
282-
await prior;
283-
return await run();
284-
} finally {
285-
release();
286-
if (MODELS_JSON_STATE.writeLocks.get(targetPath) === pending) {
287-
MODELS_JSON_STATE.writeLocks.delete(targetPath);
288-
}
289-
}
290-
}
291-
292-
/** Ensures models.json and plugin catalog sidecars are current for an agent. */
293-
export async function ensureOpenClawModelsJson(
273+
/** Builds the canonical source freshness fingerprint for generated model catalogs. */
274+
export async function buildModelsJsonSourceFingerprint(
294275
config?: OpenClawConfig,
295276
agentDirOverride?: string,
296277
options: {
@@ -300,7 +281,7 @@ export async function ensureOpenClawModelsJson(
300281
providerDiscoveryTimeoutMs?: number;
301282
providerDiscoveryEntriesOnly?: boolean;
302283
} = {},
303-
): Promise<{ agentDir: string; wrote: boolean }> {
284+
): Promise<{ agentDir: string; fingerprint: string; workspaceDir?: string }> {
304285
const resolved = resolveModelsConfigInput(config);
305286
const cfg = resolved.config;
306287
const workspaceDir =
@@ -318,7 +299,6 @@ export async function ensureOpenClawModelsJson(
318299
...(providerScopedDiscovery ? { preferPersisted: false } : {}),
319300
});
320301
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg);
321-
const targetPath = path.join(agentDir, "models.json");
322302
const fingerprint = await buildModelsJsonFingerprint({
323303
config: cfg,
324304
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
@@ -335,6 +315,63 @@ export async function ensureOpenClawModelsJson(
335315
? { providerDiscoveryEntriesOnly: true }
336316
: {}),
337317
});
318+
return {
319+
agentDir,
320+
fingerprint,
321+
...(workspaceDir ? { workspaceDir } : {}),
322+
};
323+
}
324+
325+
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
326+
const prior = MODELS_JSON_STATE.writeLocks.get(targetPath) ?? Promise.resolve();
327+
let release: () => void = () => {};
328+
const gate = new Promise<void>((resolve) => {
329+
release = resolve;
330+
});
331+
const pending = prior.then(() => gate);
332+
MODELS_JSON_STATE.writeLocks.set(targetPath, pending);
333+
try {
334+
await prior;
335+
return await run();
336+
} finally {
337+
release();
338+
if (MODELS_JSON_STATE.writeLocks.get(targetPath) === pending) {
339+
MODELS_JSON_STATE.writeLocks.delete(targetPath);
340+
}
341+
}
342+
}
343+
344+
/** Ensures models.json and plugin catalog sidecars are current for an agent. */
345+
export async function ensureOpenClawModelsJson(
346+
config?: OpenClawConfig,
347+
agentDirOverride?: string,
348+
options: {
349+
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
350+
workspaceDir?: string;
351+
providerDiscoveryProviderIds?: readonly string[];
352+
providerDiscoveryTimeoutMs?: number;
353+
providerDiscoveryEntriesOnly?: boolean;
354+
} = {},
355+
): Promise<{ agentDir: string; wrote: boolean }> {
356+
const resolved = resolveModelsConfigInput(config);
357+
const cfg = resolved.config;
358+
const sourceFingerprint = await buildModelsJsonSourceFingerprint(
359+
config,
360+
agentDirOverride,
361+
options,
362+
);
363+
const workspaceDir = sourceFingerprint.workspaceDir;
364+
const pluginMetadataSnapshot =
365+
options.pluginMetadataSnapshot ??
366+
resolvePluginMetadataSnapshot({
367+
config: cfg,
368+
env: createConfigRuntimeEnv(cfg),
369+
...(workspaceDir ? { workspaceDir } : {}),
370+
...(options.providerDiscoveryProviderIds?.length ? { preferPersisted: false } : {}),
371+
});
372+
const agentDir = sourceFingerprint.agentDir;
373+
const targetPath = path.join(agentDir, "models.json");
374+
const fingerprint = sourceFingerprint.fingerprint;
338375
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
339376
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
340377
if (cached) {

src/commands/models/list.provider-catalog.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77

88
const providerDiscoveryMocks = vi.hoisted(() => ({
99
buildAgentModelCatalogCacheKey: vi.fn(),
10+
buildModelsJsonSourceFingerprint: vi.fn(),
1011
loadPluginRegistrySnapshotWithMetadata: vi.fn(),
1112
readCachedAgentModelCatalog: vi.fn(),
1213
resolvePluginContributionOwners: vi.fn(),
@@ -24,6 +25,10 @@ vi.mock("../../agents/model-catalog-state-cache.js", () => ({
2425
writeCachedAgentModelCatalog: providerDiscoveryMocks.writeCachedAgentModelCatalog,
2526
}));
2627

28+
vi.mock("../../agents/models-config.js", () => ({
29+
buildModelsJsonSourceFingerprint: providerDiscoveryMocks.buildModelsJsonSourceFingerprint,
30+
}));
31+
2732
vi.mock("../../plugins/plugin-registry.js", () => ({
2833
loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }),
2934
loadPluginRegistrySnapshotWithMetadata:
@@ -182,7 +187,15 @@ function firstDiscoveryRequest(): {
182187
describe("loadProviderCatalogModelsForList", () => {
183188
beforeEach(() => {
184189
vi.clearAllMocks();
185-
providerDiscoveryMocks.buildAgentModelCatalogCacheKey.mockReturnValue("provider-cache-key");
190+
providerDiscoveryMocks.buildAgentModelCatalogCacheKey.mockImplementation(
191+
(input: { cacheScope?: { sourceFingerprint?: string } }) =>
192+
`provider-cache-key:${input.cacheScope?.sourceFingerprint ?? "none"}`,
193+
);
194+
providerDiscoveryMocks.buildModelsJsonSourceFingerprint.mockResolvedValue({
195+
agentDir: baseParams.agentDir,
196+
fingerprint: "provider-source-fingerprint",
197+
workspaceDir: "/tmp/provider-workspace",
198+
});
186199
providerDiscoveryMocks.readCachedAgentModelCatalog.mockReturnValue(undefined);
187200
providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
188201
source: "persisted",
@@ -254,7 +267,7 @@ describe("loadProviderCatalogModelsForList", () => {
254267
expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["moonshot/cached-kimi"]);
255268
expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenCalledWith({
256269
agentDir: baseParams.agentDir,
257-
catalogKey: "provider-cache-key",
270+
catalogKey: "provider-cache-key:provider-source-fingerprint",
258271
});
259272
expect(providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
260273
expect(providerDiscoveryMocks.writeCachedAgentModelCatalog).not.toHaveBeenCalled();
@@ -269,11 +282,47 @@ describe("loadProviderCatalogModelsForList", () => {
269282
expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["moonshot/kimi-k2.6"]);
270283
expect(providerDiscoveryMocks.writeCachedAgentModelCatalog).toHaveBeenCalledWith({
271284
agentDir: baseParams.agentDir,
272-
catalogKey: "provider-cache-key",
285+
catalogKey: "provider-cache-key:provider-source-fingerprint",
273286
entries: rows,
274287
});
275288
});
276289

290+
it("misses cached provider catalog rows when source freshness changes", async () => {
291+
providerDiscoveryMocks.buildModelsJsonSourceFingerprint
292+
.mockResolvedValueOnce({
293+
agentDir: baseParams.agentDir,
294+
fingerprint: "old-provider-source",
295+
workspaceDir: "/tmp/provider-workspace",
296+
})
297+
.mockResolvedValueOnce({
298+
agentDir: baseParams.agentDir,
299+
fingerprint: "new-provider-source",
300+
workspaceDir: "/tmp/provider-workspace",
301+
});
302+
providerDiscoveryMocks.readCachedAgentModelCatalog.mockImplementation(
303+
({ catalogKey }: { catalogKey: string }) =>
304+
catalogKey.endsWith("old-provider-source")
305+
? [{ provider: "moonshot", id: "cached-stale", name: "Cached Stale" }]
306+
: undefined,
307+
);
308+
309+
await expect(loadProviderCatalogModelsForList({ ...baseParams })).resolves.toEqual([
310+
{ provider: "moonshot", id: "cached-stale", name: "Cached Stale" },
311+
]);
312+
await expect(loadProviderCatalogModelsForList({ ...baseParams })).resolves.toEqual([
313+
expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }),
314+
]);
315+
316+
expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenNthCalledWith(1, {
317+
agentDir: baseParams.agentDir,
318+
catalogKey: "provider-cache-key:old-provider-source",
319+
});
320+
expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenNthCalledWith(2, {
321+
agentDir: baseParams.agentDir,
322+
catalogKey: "provider-cache-key:new-provider-source",
323+
});
324+
});
325+
277326
it("requires complete discovery-entry coverage for static-only loads", async () => {
278327
await loadProviderCatalogModelsForList({
279328
...baseParams,

src/commands/models/list.provider-catalog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
readCachedAgentModelCatalog,
88
writeCachedAgentModelCatalog,
99
} from "../../agents/model-catalog-state-cache.js";
10+
import { buildModelsJsonSourceFingerprint } from "../../agents/models-config.js";
1011
import {
1112
createProviderApiKeyResolver,
1213
createProviderAuthResolver,
@@ -260,12 +261,19 @@ export async function loadProviderCatalogModelsForList(params: {
260261
return [];
261262
}
262263

264+
const sourceFingerprint = await buildModelsJsonSourceFingerprint(params.cfg, params.agentDir, {
265+
pluginMetadataSnapshot: params.metadataSnapshot,
266+
providerDiscoveryEntriesOnly: params.staticOnly === true,
267+
providerDiscoveryProviderIds: scopedPluginIds,
268+
workspaceDir: params.metadataSnapshot?.workspaceDir,
269+
});
263270
const catalogKey = buildAgentModelCatalogCacheKey({
264271
agentDir: params.agentDir,
265272
cacheScope: {
266273
source: "models-list-provider-catalog",
267274
providerFilter,
268275
scopedPluginIds,
276+
sourceFingerprint: sourceFingerprint.fingerprint,
269277
staticOnly: params.staticOnly === true,
270278
},
271279
config: params.cfg,

0 commit comments

Comments
 (0)