Skip to content

Commit 0e5f4ea

Browse files
authored
perf: reuse manifest metadata for read-only model catalogs
1 parent b04e428 commit 0e5f4ea

5 files changed

Lines changed: 141 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
5151
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
5252
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.
53+
- Agents/model catalog: reuse manifest model-id normalization metadata while loading persisted read-only catalog rows, avoiding repeated metadata scans.
5354
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
5455
- Agents/replies: strip workflow `<function_response>` scaffolding from user-visible sanitizer paths so raw tool output does not leak into chat history, transcript mirrors, or channel replies. Fixes #47444. Thanks @5toCode.
5556
- Agents/media: deliver generated image, music, and video results through structured attachments, keep message-tool-only Codex completions on the message tool, and fail completion handoff when expected media is not actually sent.

src/agents/model-catalog.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,29 @@ function emptyPluginMetadataSnapshot() {
105105
};
106106
}
107107

108+
function modelIdNormalizationSnapshot() {
109+
return {
110+
...emptyPluginMetadataSnapshot(),
111+
configFingerprint: "model-id-normalizers",
112+
plugins: [
113+
{
114+
id: "external-normalizer",
115+
modelIdNormalization: {
116+
providers: {
117+
custom: {
118+
aliases: {
119+
latest: "modern-model",
120+
},
121+
stripPrefixes: ["legacy/"],
122+
prefixWhenBare: "vendor",
123+
},
124+
},
125+
},
126+
},
127+
],
128+
};
129+
}
130+
108131
type ModelCatalogEntry = Awaited<
109132
ReturnType<typeof import("./model-catalog.js").loadModelCatalog>
110133
>[number];
@@ -487,6 +510,52 @@ describe("loadModelCatalog", () => {
487510
expect(augmentCatalogMock).not.toHaveBeenCalled();
488511
});
489512

513+
it("normalizes persisted read-only catalog rows with manifest model id policies", async () => {
514+
currentPluginMetadataSnapshotMock.mockReturnValueOnce(modelIdNormalizationSnapshot());
515+
readFileMock.mockResolvedValueOnce(
516+
JSON.stringify({
517+
providers: {
518+
custom: {
519+
models: [
520+
{ id: "latest", name: "Latest Alias" },
521+
{ id: "legacy/trimmed" },
522+
{ id: "vendor/already-prefixed" },
523+
],
524+
},
525+
},
526+
}),
527+
);
528+
529+
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
530+
531+
expect(requireCatalogEntry(result, "custom", "vendor/modern-model").name).toBe("Latest Alias");
532+
expect(requireCatalogEntry(result, "custom", "vendor/trimmed").name).toBe("vendor/trimmed");
533+
expect(requireCatalogEntry(result, "custom", "vendor/already-prefixed").name).toBe(
534+
"vendor/already-prefixed",
535+
);
536+
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
537+
});
538+
539+
it("loads manifest model id policies once for persisted read-only catalog rows", async () => {
540+
currentPluginMetadataSnapshotMock.mockReturnValue(undefined);
541+
loadPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot());
542+
readFileMock.mockResolvedValueOnce(
543+
JSON.stringify({
544+
providers: {
545+
custom: {
546+
models: [{ id: "model-a" }, { id: "model-b" }, { id: "model-c" }, { id: "model-d" }],
547+
},
548+
},
549+
}),
550+
);
551+
552+
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
553+
554+
expect(requireCatalogEntry(result, "custom", "vendor/model-a").name).toBe("vendor/model-a");
555+
expect(requireCatalogEntry(result, "custom", "vendor/model-d").name).toBe("vendor/model-d");
556+
expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1);
557+
});
558+
490559
it("preserves provider context defaults for persisted read-only catalog rows", async () => {
491560
readFileMock.mockResolvedValueOnce(
492561
JSON.stringify({

src/agents/model-catalog.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
55
import { createSubsystemLogger } from "../logging/subsystem.js";
66
import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js";
77
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
8-
import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js";
8+
import {
9+
isManifestPluginAvailableForControlPlane,
10+
loadManifestMetadataSnapshot,
11+
} from "../plugins/manifest-contract-eligibility.js";
912
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
1013
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js";
1114
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -16,7 +19,10 @@ import {
1619
import { resolveDefaultAgentDir } from "./agent-scope.js";
1720
import { modelSupportsInput as modelCatalogEntrySupportsInput } from "./model-catalog-lookup.js";
1821
import type { ModelCatalogEntry, ModelInputType } from "./model-catalog.types.js";
19-
import { normalizeConfiguredProviderCatalogModelId } from "./model-ref-shared.js";
22+
import {
23+
normalizeConfiguredProviderCatalogModelId,
24+
type ProviderModelIdNormalizationOptions,
25+
} from "./model-ref-shared.js";
2026
import { buildConfiguredModelCatalog } from "./model-selection-shared.js";
2127
import { ensureOpenClawModelsJson } from "./models-config.js";
2228
import { normalizeProviderId } from "./provider-id.js";
@@ -196,6 +202,9 @@ function normalizePersistedModelCatalogEntry(
196202
contextWindow?: number;
197203
contextTokens?: number;
198204
},
205+
options: {
206+
manifestPlugins?: ProviderModelIdNormalizationOptions["manifestPlugins"];
207+
} = {},
199208
): ModelCatalogEntry | undefined {
200209
const rawId = normalizeOptionalString(entry.id) ?? "";
201210
if (!rawId) {
@@ -205,7 +214,7 @@ function normalizePersistedModelCatalogEntry(
205214
if (!provider) {
206215
return undefined;
207216
}
208-
const id = normalizeConfiguredProviderCatalogModelId(provider, rawId);
217+
const id = normalizeConfiguredProviderCatalogModelId(provider, rawId, options);
209218
const name = normalizeOptionalString(entry.name ?? id) || id;
210219
const contextWindow =
211220
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
@@ -252,6 +261,14 @@ async function loadReadOnlyPersistedModelCatalog(params?: {
252261
const models: ModelCatalogEntry[] = [];
253262
const { buildShouldSuppressBuiltInModel } = await loadModelSuppression();
254263
const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg });
264+
let manifestPlugins: ProviderModelIdNormalizationOptions["manifestPlugins"];
265+
const getManifestPlugins = () => {
266+
manifestPlugins ??= loadManifestMetadataSnapshot({
267+
config: cfg,
268+
env: process.env,
269+
}).plugins;
270+
return manifestPlugins;
271+
};
255272
const providers =
256273
parsed?.providers && typeof parsed.providers === "object"
257274
? (parsed.providers as Record<string, Record<string, unknown>>)
@@ -269,10 +286,15 @@ async function loadReadOnlyPersistedModelCatalog(params?: {
269286
? providerConfig.contextTokens
270287
: undefined;
271288
for (const entry of providerConfig.models as Record<string, unknown>[]) {
272-
const normalized = normalizePersistedModelCatalogEntry(providerRaw, entry, {
273-
contextWindow: providerContextWindow,
274-
contextTokens: providerContextTokens,
275-
});
289+
const normalized = normalizePersistedModelCatalogEntry(
290+
providerRaw,
291+
entry,
292+
{
293+
contextWindow: providerContextWindow,
294+
contextTokens: providerContextTokens,
295+
},
296+
{ manifestPlugins: getManifestPlugins() },
297+
);
276298
if (normalized && !shouldSuppressBuiltInModel(normalized)) {
277299
models.push(normalized);
278300
}

src/agents/model-ref-shared.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,36 @@ describe("normalizeStaticProviderModelId", () => {
1919
});
2020

2121
describe("normalizeConfiguredProviderCatalogModelId", () => {
22+
const manifestPlugins = [
23+
{
24+
modelIdNormalization: {
25+
providers: {
26+
custom: {
27+
aliases: {
28+
latest: "modern-model",
29+
},
30+
prefixWhenBare: "vendor",
31+
},
32+
},
33+
},
34+
},
35+
];
36+
37+
it("applies supplied manifest normalization policies to configured catalog ids", () => {
38+
expect(normalizeConfiguredProviderCatalogModelId("custom", "latest", { manifestPlugins })).toBe(
39+
"vendor/modern-model",
40+
);
41+
});
42+
43+
it("can skip manifest normalization while retaining built-in normalization", () => {
44+
expect(
45+
normalizeConfiguredProviderCatalogModelId("custom", "latest", {
46+
allowManifestNormalization: false,
47+
manifestPlugins,
48+
}),
49+
).toBe("latest");
50+
});
51+
2252
it("normalizes nested retired Google Gemini ids in proxy-prefixed rows", () => {
2353
expect(
2454
normalizeConfiguredProviderCatalogModelId("kilocode", "kilocode/google/gemini-3-pro-preview"),

src/agents/model-ref-shared.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ type StaticModelRef = {
99
model: string;
1010
};
1111

12+
export type ProviderModelIdNormalizationOptions = {
13+
allowManifestNormalization?: boolean;
14+
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
15+
};
16+
1217
export function modelKey(provider: string, model: string): string {
1318
const providerId = provider.trim();
1419
const modelId = model.trim();
@@ -28,10 +33,7 @@ export function modelKey(provider: string, model: string): string {
2833
export function normalizeStaticProviderModelId(
2934
provider: string,
3035
model: string,
31-
options: {
32-
allowManifestNormalization?: boolean;
33-
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
34-
} = {},
36+
options: ProviderModelIdNormalizationOptions = {},
3537
): string {
3638
const normalizedProvider = normalizeProviderId(provider);
3739
if (options.allowManifestNormalization === false) {
@@ -56,8 +58,12 @@ function normalizeBuiltInProviderModelId(provider: string, model: string): strin
5658
return model;
5759
}
5860

59-
export function normalizeConfiguredProviderCatalogModelId(provider: string, model: string): string {
60-
const providerModel = normalizeStaticProviderModelId(provider, model);
61+
export function normalizeConfiguredProviderCatalogModelId(
62+
provider: string,
63+
model: string,
64+
options: ProviderModelIdNormalizationOptions = {},
65+
): string {
66+
const providerModel = normalizeStaticProviderModelId(provider, model, options);
6167
const googlePrefix = "google/";
6268
if (!providerModel.startsWith(googlePrefix)) {
6369
const slash = providerModel.indexOf("/");

0 commit comments

Comments
 (0)