Skip to content

Commit b744010

Browse files
authored
fix(gateway): keep models list read-only fast
Fixes #76382
1 parent a6d25c1 commit b744010

8 files changed

Lines changed: 209 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
3636
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
3737
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
38+
- Gateway/models: keep read-only `models.list` fallbacks on persisted/current metadata and configured rows while using static auth checks, so missing `models.json` files no longer runtime-load provider discovery or stall gateway after restart. Fixes #76382; refs #76360 and #75707. Thanks @trojy13, @RayWoo, @AnathemaOfficial, and @vincentkoc.
3839
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
3940
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
4041
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc.

src/agents/model-auth.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export function hasRuntimeAvailableProviderAuth(params: {
324324
cfg?: OpenClawConfig;
325325
workspaceDir?: string;
326326
env?: NodeJS.ProcessEnv;
327+
allowPluginSyntheticAuth?: boolean;
327328
}): boolean {
328329
const provider = normalizeProviderId(params.provider);
329330
const authOverride = resolveProviderAuthOverride(params.cfg, provider);
@@ -347,7 +348,10 @@ export function hasRuntimeAvailableProviderAuth(params: {
347348
if (hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) {
348349
return true;
349350
}
350-
if (resolveSyntheticLocalProviderAuth({ cfg: params.cfg, provider })) {
351+
if (
352+
params.allowPluginSyntheticAuth !== false &&
353+
resolveSyntheticLocalProviderAuth({ cfg: params.cfg, provider })
354+
) {
351355
return true;
352356
}
353357
return false;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import { resolveVisibleModelCatalog } from "./model-catalog-visibility.js";
4+
import type { ModelCatalogEntry } from "./model-catalog.types.js";
5+
import { createProviderAuthChecker } from "./model-provider-auth.js";
6+
7+
vi.mock("./model-provider-auth.js", () => ({
8+
createProviderAuthChecker: vi.fn(),
9+
}));
10+
11+
const createProviderAuthCheckerMock = vi.mocked(createProviderAuthChecker);
12+
13+
describe("resolveVisibleModelCatalog", () => {
14+
beforeEach(() => {
15+
createProviderAuthCheckerMock.mockReset();
16+
});
17+
18+
it("can use static auth checks for gateway read-only model lists", () => {
19+
const authChecker = vi.fn((provider: string) => provider === "openai");
20+
createProviderAuthCheckerMock.mockReturnValue(authChecker);
21+
const catalog: ModelCatalogEntry[] = [
22+
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
23+
{ provider: "openai", id: "gpt-test", name: "GPT Test" },
24+
];
25+
26+
const result = resolveVisibleModelCatalog({
27+
cfg: {} as OpenClawConfig,
28+
catalog,
29+
defaultProvider: "openai",
30+
runtimeAuthDiscovery: false,
31+
});
32+
33+
expect(createProviderAuthCheckerMock).toHaveBeenCalledWith(
34+
expect.objectContaining({
35+
allowPluginSyntheticAuth: false,
36+
discoverExternalCliAuth: false,
37+
}),
38+
);
39+
expect(authChecker).toHaveBeenCalledWith("anthropic");
40+
expect(authChecker).toHaveBeenCalledWith("openai");
41+
expect(result).toEqual([{ provider: "openai", id: "gpt-test", name: "GPT Test" }]);
42+
});
43+
});

src/agents/model-catalog-visibility.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function resolveVisibleModelCatalog(params: {
3535
workspaceDir?: string;
3636
env?: NodeJS.ProcessEnv;
3737
view?: ModelCatalogVisibilityView;
38+
runtimeAuthDiscovery?: boolean;
3839
}): ModelCatalogEntry[] {
3940
if (params.view === "all") {
4041
return params.catalog;
@@ -59,6 +60,8 @@ export function resolveVisibleModelCatalog(params: {
5960
workspaceDir: params.workspaceDir,
6061
agentDir: params.agentDir,
6162
env: params.env,
63+
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
64+
discoverExternalCliAuth: params.runtimeAuthDiscovery,
6265
});
6366
const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider));
6467
return sortModelCatalogEntries(

src/agents/model-catalog.test.ts

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ function mockSingleOpenAiCatalogModel() {
6969
mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]);
7070
}
7171

72+
function emptyPluginMetadataSnapshot() {
73+
return {
74+
policyHash: "test-policy",
75+
configFingerprint: "test-config",
76+
index: {
77+
policyHash: "test-policy",
78+
plugins: [],
79+
},
80+
plugins: [],
81+
};
82+
}
83+
7284
describe("loadModelCatalog", () => {
7385
beforeAll(async () => {
7486
readFileMock = vi.fn();
@@ -117,7 +129,9 @@ describe("loadModelCatalog", () => {
117129
ensureOpenClawModelsJsonMock.mockClear();
118130
augmentCatalogMock.mockClear();
119131
currentPluginMetadataSnapshotMock.mockReset();
132+
currentPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot());
120133
loadPluginMetadataSnapshotMock.mockReset();
134+
loadPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot());
121135
});
122136

123137
afterEach(() => {
@@ -206,26 +220,46 @@ describe("loadModelCatalog", () => {
206220
}
207221
});
208222

209-
it("does not prepare models.json when loading catalog in read-only mode", async () => {
210-
const discoverAuthStorage = vi.fn(() => ({}));
211-
__setModelCatalogImportForTest(
212-
async () =>
213-
({
214-
discoverAuthStorage,
215-
AuthStorage: function AuthStorage() {},
216-
ModelRegistry: class {
217-
getAll() {
218-
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
219-
}
220-
},
221-
}) as unknown as PiSdkModule,
222-
);
223+
it("does not prepare models.json or import provider discovery when loading fallback catalog in read-only mode", async () => {
224+
const importPiSdk = vi.fn(async () => {
225+
throw new Error("provider discovery should not load");
226+
});
227+
__setModelCatalogImportForTest(importPiSdk as unknown as () => Promise<PiSdkModule>);
228+
currentPluginMetadataSnapshotMock.mockReturnValueOnce(undefined);
229+
loadPluginMetadataSnapshotMock.mockImplementationOnce(() => {
230+
throw new Error("metadata scan should not run");
231+
});
223232

224-
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
233+
const result = await loadModelCatalog({
234+
config: {
235+
models: {
236+
providers: {
237+
openai: {
238+
baseUrl: "https://openai.example.com/v1",
239+
models: [
240+
{
241+
id: "gpt-test",
242+
name: "GPT Test",
243+
reasoning: false,
244+
input: ["text"],
245+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
246+
contextWindow: 200_000,
247+
maxTokens: 8192,
248+
},
249+
],
250+
},
251+
},
252+
},
253+
} as OpenClawConfig,
254+
readOnly: true,
255+
});
225256

226-
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
257+
expect(result).toContainEqual(
258+
expect.objectContaining({ id: "gpt-test", name: "GPT Test", provider: "openai" }),
259+
);
227260
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
228-
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
261+
expect(importPiSdk).not.toHaveBeenCalled();
262+
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
229263
});
230264

231265
it("filters suppressed built-ins from persisted read-only catalog rows", async () => {
@@ -279,7 +313,7 @@ describe("loadModelCatalog", () => {
279313
expect(augmentCatalogMock).not.toHaveBeenCalled();
280314
});
281315

282-
it("falls back to the registry when persisted read-only catalog has no model rows", async () => {
316+
it("falls back to manifest catalog rows when persisted read-only catalog has no model rows", async () => {
283317
readFileMock.mockResolvedValueOnce(
284318
JSON.stringify({
285319
providers: {
@@ -293,27 +327,50 @@ describe("loadModelCatalog", () => {
293327
},
294328
}),
295329
);
296-
const discoverAuthStorage = vi.fn(() => ({
297-
getOAuthProviders: () => [],
298-
}));
299-
__setModelCatalogImportForTest(
300-
async () =>
301-
({
302-
discoverAuthStorage,
303-
AuthStorage: function AuthStorage() {},
304-
ModelRegistry: class {
305-
getAll() {
306-
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
307-
}
330+
currentPluginMetadataSnapshotMock.mockReturnValueOnce({
331+
policyHash: "policy",
332+
index: {
333+
policyHash: "policy",
334+
plugins: [
335+
{
336+
pluginId: "external-provider",
337+
enabled: true,
338+
origin: "global",
308339
},
309-
}) as unknown as PiSdkModule,
310-
);
340+
],
341+
},
342+
plugins: [
343+
{
344+
id: "external-provider",
345+
origin: "global",
346+
modelCatalog: {
347+
providers: {
348+
external: {
349+
models: [{ id: "external-fast", name: "External Fast" }],
350+
},
351+
},
352+
},
353+
},
354+
],
355+
});
356+
const importPiSdk = vi.fn(async () => {
357+
throw new Error("provider discovery should not load");
358+
});
359+
__setModelCatalogImportForTest(importPiSdk as unknown as () => Promise<PiSdkModule>);
311360

312361
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
313362

314-
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
363+
expect(result).toEqual([
364+
{
365+
provider: "external",
366+
id: "external-fast",
367+
name: "External Fast",
368+
input: ["text"],
369+
reasoning: false,
370+
},
371+
]);
315372
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
316-
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
373+
expect(importPiSdk).not.toHaveBeenCalled();
317374
});
318375

319376
it("preserves registry defaults for minimal persisted read-only catalog rows", async () => {

src/agents/model-catalog.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type PiRegistryClassLike = {
5353

5454
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;
5555
let hasLoggedModelCatalogError = false;
56+
let hasLoggedReadOnlyStaticCatalogError = false;
5657
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
5758
let importPiSdk = defaultImportPiSdk;
5859
const modelSuppressionLoader = createLazyImportLoader(
@@ -70,6 +71,7 @@ function loadModelSuppression() {
7071
export function resetModelCatalogCache() {
7172
modelCatalogPromise = null;
7273
hasLoggedModelCatalogError = false;
74+
hasLoggedReadOnlyStaticCatalogError = false;
7375
}
7476

7577
export function resetModelCatalogCacheForTest() {
@@ -117,22 +119,29 @@ export function loadManifestModelCatalog(params: {
117119
config: OpenClawConfig;
118120
workspaceDir?: string;
119121
env?: NodeJS.ProcessEnv;
122+
fallbackToMetadataScan?: boolean;
120123
}): ModelCatalogEntry[] {
121-
const snapshot =
122-
getCurrentPluginMetadataSnapshot({
123-
config: params.config,
124-
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
125-
}) ??
126-
loadPluginMetadataSnapshot({
127-
config: params.config,
128-
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
129-
env: params.env ?? process.env,
130-
});
131-
const eligiblePlugins = snapshot.plugins.filter(
124+
const snapshot = getCurrentPluginMetadataSnapshot({
125+
config: params.config,
126+
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
127+
});
128+
const resolvedSnapshot =
129+
snapshot ??
130+
(params.fallbackToMetadataScan === false
131+
? undefined
132+
: loadPluginMetadataSnapshot({
133+
config: params.config,
134+
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
135+
env: params.env ?? process.env,
136+
}));
137+
if (!resolvedSnapshot) {
138+
return [];
139+
}
140+
const eligiblePlugins = resolvedSnapshot.plugins.filter(
132141
(plugin) =>
133142
plugin.modelCatalog &&
134143
isManifestPluginAvailableForControlPlane({
135-
snapshot,
144+
snapshot: resolvedSnapshot,
136145
plugin,
137146
config: params.config,
138147
}),
@@ -250,6 +259,32 @@ async function loadReadOnlyPersistedModelCatalog(params?: {
250259
return sortModelCatalogEntries(models);
251260
}
252261

262+
function loadReadOnlyStaticModelCatalog(params?: { config?: OpenClawConfig }): ModelCatalogEntry[] {
263+
const cfg = params?.config ?? getRuntimeConfig();
264+
const models: ModelCatalogEntry[] = [];
265+
try {
266+
appendCatalogEntriesIfAbsent(
267+
models,
268+
loadManifestModelCatalog({
269+
config: cfg,
270+
env: process.env,
271+
fallbackToMetadataScan: false,
272+
}),
273+
);
274+
} catch (error) {
275+
if (!hasLoggedReadOnlyStaticCatalogError) {
276+
hasLoggedReadOnlyStaticCatalogError = true;
277+
log.warn(`Failed to load read-only manifest model catalog: ${String(error)}`);
278+
}
279+
}
280+
281+
const configuredModels = buildConfiguredModelCatalog({ cfg });
282+
if (configuredModels.length > 0) {
283+
appendCatalogEntriesIfAbsent(models, configuredModels);
284+
}
285+
return sortModelCatalogEntries(models);
286+
}
287+
253288
export async function loadModelCatalog(params?: {
254289
config?: OpenClawConfig;
255290
useCache?: boolean;
@@ -260,7 +295,9 @@ export async function loadModelCatalog(params?: {
260295
try {
261296
return await loadReadOnlyPersistedModelCatalog(params);
262297
} catch {
263-
// fall through to full catalog path
298+
// Keep gateway models.list on side-effect-free sources. The RPC timeout
299+
// cannot fire while provider discovery blocks the event loop.
300+
return loadReadOnlyStaticModelCatalog(params);
264301
}
265302
}
266303
if (!readOnly && params?.useCache === false) {

0 commit comments

Comments
 (0)