Skip to content

Commit c9f0bfd

Browse files
fix(agents): isolate invalid plugin model catalogs (#92564)
Keep valid root and plugin models available when one generated plugin catalog is invalid, while retaining and logging the catalog error. Fixes #92553. Co-authored-by: tangtaizong666 <[email protected]>
1 parent ab559a7 commit c9f0bfd

2 files changed

Lines changed: 103 additions & 12 deletions

File tree

src/agents/sessions/model-registry.test.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,59 @@ function writeModelsJsonWithPluginCatalog(params: {
2525
root: unknown;
2626
pluginRelativePath: string;
2727
pluginCatalog: unknown;
28+
}): string {
29+
return writeModelsJsonWithPluginCatalogs({
30+
root: params.root,
31+
pluginCatalogs: [
32+
{
33+
pluginRelativePath: params.pluginRelativePath,
34+
pluginCatalog: params.pluginCatalog,
35+
},
36+
],
37+
});
38+
}
39+
40+
function writeModelsJsonWithPluginCatalogs(params: {
41+
root: unknown;
42+
pluginCatalogs: Array<{
43+
pluginRelativePath: string;
44+
pluginCatalog: unknown;
45+
}>;
2846
}): string {
2947
const dir = mkdtempSync(join(tmpdir(), "openclaw-model-registry-"));
3048
tempDirs.push(dir);
3149
const file = join(dir, "models.json");
32-
const pluginFile = join(dir, params.pluginRelativePath);
33-
mkdirSync(dirname(pluginFile), { recursive: true });
3450
writeFileSync(file, JSON.stringify(params.root, null, 2), "utf-8");
35-
writeFileSync(pluginFile, JSON.stringify(params.pluginCatalog, null, 2), "utf-8");
51+
for (const pluginCatalog of params.pluginCatalogs) {
52+
const pluginFile = join(dir, pluginCatalog.pluginRelativePath);
53+
mkdirSync(dirname(pluginFile), { recursive: true });
54+
writeFileSync(pluginFile, JSON.stringify(pluginCatalog.pluginCatalog, null, 2), "utf-8");
55+
}
3656
return file;
3757
}
3858

3959
function pluginOwnerSnapshot(providerId: string, pluginId: string, enabled = true) {
60+
return pluginOwnerSnapshotEntries([{ providerId, pluginId, enabled }]);
61+
}
62+
63+
function pluginOwnerSnapshotEntries(
64+
entries: Array<{ providerId: string; pluginId: string; enabled?: boolean }>,
65+
) {
4066
// The registry only trusts generated provider shards that are still owned by
4167
// an enabled plugin in the current metadata snapshot.
4268
return {
4369
index: {
44-
plugins: [{ pluginId, enabled }],
70+
plugins: entries.map((entry) => ({
71+
pluginId: entry.pluginId,
72+
enabled: entry.enabled ?? true,
73+
})),
4574
},
4675
normalizePluginId: (id: string) => id,
4776
owners: {
4877
channels: new Map(),
4978
channelConfigs: new Map(),
50-
providers: new Map([[providerId, [pluginId]]]),
51-
modelCatalogProviders: new Map([[providerId, [pluginId]]]),
79+
providers: new Map(entries.map((entry) => [entry.providerId, [entry.pluginId]])),
80+
modelCatalogProviders: new Map(entries.map((entry) => [entry.providerId, [entry.pluginId]])),
5281
cliBackends: new Map(),
5382
setupProviders: new Map(),
5483
commandAliases: new Map(),
@@ -145,6 +174,64 @@ describe("ModelRegistry models.json auth", () => {
145174
expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1");
146175
});
147176

177+
it("isolates invalid generated plugin catalog shards from valid models", () => {
178+
const modelsPath = writeModelsJsonWithPluginCatalogs({
179+
root: {
180+
providers: {
181+
custom: {
182+
baseUrl: "https://models.example/v1",
183+
api: "openai-responses",
184+
apiKey: "CUSTOM_API_KEY",
185+
models: [{ id: "root-model", name: "Root Model" }],
186+
},
187+
},
188+
},
189+
pluginCatalogs: [
190+
{
191+
pluginRelativePath: join("plugins", "google", PLUGIN_MODEL_CATALOG_FILE),
192+
pluginCatalog: {
193+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
194+
providers: {
195+
"google-vertex": {
196+
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",
197+
apiKey: "GOOGLE_API_KEY",
198+
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }],
199+
},
200+
},
201+
},
202+
},
203+
{
204+
pluginRelativePath: join("plugins", "zai", PLUGIN_MODEL_CATALOG_FILE),
205+
pluginCatalog: {
206+
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
207+
providers: {
208+
zai: {
209+
baseUrl: "https://api.z.ai/api/paas/v4",
210+
api: "openai-completions",
211+
apiKey: "ZAI_API_KEY",
212+
models: [{ id: "glm-5.1", name: "GLM 5.1" }],
213+
},
214+
},
215+
},
216+
},
217+
],
218+
});
219+
220+
const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath, {
221+
pluginMetadataSnapshot: pluginOwnerSnapshotEntries([
222+
{ providerId: "google-vertex", pluginId: "google" },
223+
{ providerId: "zai", pluginId: "zai" },
224+
]),
225+
});
226+
227+
expect(registry.getError()).toContain(
228+
'Provider google-vertex, model gemini-3.1-pro-preview: no "api" specified',
229+
);
230+
expect(registry.find("custom", "root-model")?.name).toBe("Root Model");
231+
expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1");
232+
expect(registry.find("google-vertex", "gemini-3.1-pro-preview")).toBeUndefined();
233+
});
234+
148235
it("preserves model params from generated plugin catalog shards", () => {
149236
const modelsPath = writeModelsJsonWithPluginCatalog({
150237
root: { providers: {} },

src/agents/sessions/model-registry.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from "../../llm/types.js";
2222
import { registerOAuthProvider, resetOAuthProviders } from "../../llm/utils/oauth/index.js";
2323
import type { OAuthProviderInterface } from "../../llm/utils/oauth/types.js";
24+
import { createSubsystemLogger } from "../../logging/subsystem.js";
2425
import { getAgentDir } from "../config.js";
2526
import { resolveModelPluginMetadataSnapshot } from "../model-discovery-context.js";
2627
import {
@@ -38,6 +39,8 @@ import {
3839
resolveHeadersOrThrow,
3940
} from "./resolve-config-value.js";
4041

42+
const log = createSubsystemLogger("agents/model-registry");
43+
4144
// Schema for OpenRouter routing preferences
4245
const PercentileCutoffsSchema = Type.Object({
4346
p50: Type.Optional(Type.Number()),
@@ -355,9 +358,7 @@ export class ModelRegistry {
355358
}
356359
}
357360

358-
/**
359-
* Get any error from loading models.json (undefined if no error).
360-
*/
361+
/** Get any root or generated plugin catalog load error. */
361362
getError(): string | undefined {
362363
return this.loadError;
363364
}
@@ -371,7 +372,8 @@ export class ModelRegistry {
371372

372373
if (error) {
373374
this.loadError = error;
374-
// Keep the prior empty/default registry shape when models.json failed to load.
375+
log.warn(`model catalog load issue: ${error}`);
376+
// Plugin catalog failures can return salvaged models; root failures return empty.
375377
}
376378

377379
let combined = customModels;
@@ -444,6 +446,7 @@ export class ModelRegistry {
444446
}
445447

446448
const models = this.parseModels(configForUse);
449+
const pluginCatalogErrors: string[] = [];
447450
if (options.includePluginCatalogs !== false) {
448451
for (const pluginCatalog of listPluginModelCatalogFiles(dirname(modelsJsonPath))) {
449452
const pluginResult = this.loadCustomModels(pluginCatalog.path, {
@@ -452,13 +455,14 @@ export class ModelRegistry {
452455
requireGeneratedCatalog: true,
453456
});
454457
if (pluginResult.error) {
455-
return pluginResult;
458+
pluginCatalogErrors.push(pluginResult.error);
459+
continue;
456460
}
457461
models.push(...pluginResult.models);
458462
}
459463
}
460464

461-
return { models, error: undefined };
465+
return { models, error: pluginCatalogErrors.join("\n\n") || undefined };
462466
} catch (error) {
463467
if (error instanceof SyntaxError) {
464468
if (options.requireGeneratedCatalog === true) {

0 commit comments

Comments
 (0)