Skip to content

Commit 2b21070

Browse files
authored
fix(models): cache models.json readiness for embedded runs (#52077)
* fix(models): cache models.json readiness for embedded runs * fix(models): harden readiness cache inputs
1 parent 432e894 commit 2b21070

4 files changed

Lines changed: 140 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai
9393
- Agents/Telegram: avoid rebuilding the full model catalog on ordinary inbound replies so Telegram message handling no longer pays multi-second core startup latency before reply generation. Thanks @vincentkoc.
9494
- Agents/inbound: lazy-load media and link understanding for plain-text turns and cache synced auth stores by auth-file state so ordinary inbound replies avoid unnecessary startup churn. Thanks @vincentkoc.
9595
- Telegram/polling: hard-timeout stuck `getUpdates` requests so wedged network paths fail over sooner instead of waiting for the polling stall watchdog. Thanks @vincentkoc.
96+
- Agents/models: cache `models.json` readiness by config and auth-file state so embedded runner turns stop paying repeated model-catalog startup work before replies. Thanks @vincentkoc.
9697
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
9798
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
9899
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc.

src/agents/models-config.file-mode.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { afterEach, describe, expect, it } from "vitest";
44
import { resolveOpenClawAgentDir } from "./agent-paths.js";
55
import {
66
CUSTOM_PROXY_MODELS_CONFIG,
77
installModelsConfigTestHooks,
88
withModelsTempHome as withTempHome,
99
} from "./models-config.e2e-harness.js";
10-
import { ensureOpenClawModelsJson } from "./models-config.js";
10+
import { ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } from "./models-config.js";
1111

1212
installModelsConfigTestHooks();
1313

14+
afterEach(() => {
15+
resetModelsJsonReadyCacheForTest();
16+
});
17+
1418
describe("models-config file mode", () => {
1519
it("writes models.json with mode 0600", async () => {
1620
if (process.platform === "win32") {

src/agents/models-config.runtime-source-snapshot.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
33
import {
44
clearConfigCache,
@@ -11,11 +11,15 @@ import {
1111
installModelsConfigTestHooks,
1212
withModelsTempHome as withTempHome,
1313
} from "./models-config.e2e-harness.js";
14-
import { ensureOpenClawModelsJson } from "./models-config.js";
14+
import { ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } from "./models-config.js";
1515
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
1616

1717
installModelsConfigTestHooks();
1818

19+
afterEach(() => {
20+
resetModelsJsonReadyCacheForTest();
21+
});
22+
1923
function createOpenAiApiKeySourceConfig(): OpenClawConfig {
2024
return {
2125
models: {
@@ -215,6 +219,55 @@ describe("models-config runtime source snapshot", () => {
215219
});
216220
});
217221

222+
it("invalidates cached readiness when projected config changes under the same runtime snapshot", async () => {
223+
await withTempHome(async () => {
224+
const sourceConfig = createOpenAiApiKeySourceConfig();
225+
const runtimeConfig = createOpenAiApiKeyRuntimeConfig();
226+
const firstCandidate: OpenClawConfig = {
227+
...runtimeConfig,
228+
models: {
229+
providers: {
230+
openai: {
231+
...runtimeConfig.models!.providers!.openai,
232+
baseUrl: "https://api.openai.com/v1",
233+
},
234+
},
235+
},
236+
};
237+
const secondCandidate: OpenClawConfig = {
238+
...runtimeConfig,
239+
models: {
240+
providers: {
241+
openai: {
242+
...runtimeConfig.models!.providers!.openai,
243+
baseUrl: "https://mirror.example/v1",
244+
},
245+
},
246+
},
247+
};
248+
249+
try {
250+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
251+
await ensureOpenClawModelsJson(firstCandidate);
252+
let parsed = await readGeneratedModelsJson<{
253+
providers: Record<string, { baseUrl?: string; apiKey?: string }>;
254+
}>();
255+
expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1");
256+
expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
257+
258+
await ensureOpenClawModelsJson(secondCandidate);
259+
parsed = await readGeneratedModelsJson<{
260+
providers: Record<string, { baseUrl?: string; apiKey?: string }>;
261+
}>();
262+
expect(parsed.providers.openai?.baseUrl).toBe("https://mirror.example/v1");
263+
expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
264+
} finally {
265+
clearRuntimeConfigSnapshot();
266+
clearConfigCache();
267+
}
268+
});
269+
});
270+
218271
it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => {
219272
await withGeneratedModelsFromRuntimeSource(
220273
{

src/agents/models-config.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,53 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js";
1111
import { planOpenClawModelsJson } from "./models-config.plan.js";
1212

1313
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
14+
const MODELS_JSON_READY_CACHE = new Map<
15+
string,
16+
Promise<{ fingerprint: string; result: { agentDir: string; wrote: boolean } }>
17+
>();
18+
19+
async function readFileMtimeMs(pathname: string): Promise<number | null> {
20+
try {
21+
const stat = await fs.stat(pathname);
22+
return Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : null;
23+
} catch {
24+
return null;
25+
}
26+
}
27+
28+
function stableStringify(value: unknown): string {
29+
if (value === null || typeof value !== "object") {
30+
return JSON.stringify(value);
31+
}
32+
if (Array.isArray(value)) {
33+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
34+
}
35+
const entries = Object.entries(value as Record<string, unknown>).toSorted(([a], [b]) =>
36+
a.localeCompare(b),
37+
);
38+
return `{${entries
39+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
40+
.join(",")}}`;
41+
}
42+
43+
async function buildModelsJsonFingerprint(params: {
44+
config: OpenClawConfig;
45+
sourceConfigForSecrets: OpenClawConfig;
46+
agentDir: string;
47+
}): Promise<string> {
48+
const authProfilesMtimeMs = await readFileMtimeMs(
49+
path.join(params.agentDir, "auth-profiles.json"),
50+
);
51+
const modelsFileMtimeMs = await readFileMtimeMs(path.join(params.agentDir, "models.json"));
52+
const envShape = createConfigRuntimeEnv(params.config, {});
53+
return stableStringify({
54+
config: params.config,
55+
sourceConfigForSecrets: params.sourceConfigForSecrets,
56+
envShape,
57+
authProfilesMtimeMs,
58+
modelsFileMtimeMs,
59+
});
60+
}
1461

1562
async function readExistingModelsFile(pathname: string): Promise<{
1663
raw: string;
@@ -96,8 +143,21 @@ export async function ensureOpenClawModelsJson(
96143
const cfg = resolved.config;
97144
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
98145
const targetPath = path.join(agentDir, "models.json");
146+
const fingerprint = await buildModelsJsonFingerprint({
147+
config: cfg,
148+
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
149+
agentDir,
150+
});
151+
const cached = MODELS_JSON_READY_CACHE.get(targetPath);
152+
if (cached) {
153+
const settled = await cached;
154+
if (settled.fingerprint === fingerprint) {
155+
await ensureModelsFileMode(targetPath);
156+
return settled.result;
157+
}
158+
}
99159

100-
return await withModelsJsonWriteLock(targetPath, async () => {
160+
const pending = withModelsJsonWriteLock(targetPath, async () => {
101161
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
102162
// are available to provider discovery without mutating process.env.
103163
const env = createConfigRuntimeEnv(cfg);
@@ -112,17 +172,31 @@ export async function ensureOpenClawModelsJson(
112172
});
113173

114174
if (plan.action === "skip") {
115-
return { agentDir, wrote: false };
175+
return { fingerprint, result: { agentDir, wrote: false } };
116176
}
117177

118178
if (plan.action === "noop") {
119179
await ensureModelsFileMode(targetPath);
120-
return { agentDir, wrote: false };
180+
return { fingerprint, result: { agentDir, wrote: false } };
121181
}
122182

123183
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
124184
await writeModelsFileAtomic(targetPath, plan.contents);
125185
await ensureModelsFileMode(targetPath);
126-
return { agentDir, wrote: true };
186+
return { fingerprint, result: { agentDir, wrote: true } };
127187
});
188+
MODELS_JSON_READY_CACHE.set(targetPath, pending);
189+
try {
190+
const settled = await pending;
191+
return settled.result;
192+
} catch (error) {
193+
if (MODELS_JSON_READY_CACHE.get(targetPath) === pending) {
194+
MODELS_JSON_READY_CACHE.delete(targetPath);
195+
}
196+
throw error;
197+
}
198+
}
199+
200+
export function resetModelsJsonReadyCacheForTest(): void {
201+
MODELS_JSON_READY_CACHE.clear();
128202
}

0 commit comments

Comments
 (0)