Skip to content

Commit d014b36

Browse files
committed
feat: resolve model suppressions from manifests
1 parent b2685e7 commit d014b36

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

src/agents/model-suppression.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { resolveManifestBuiltInModelSuppression } from "../plugins/manifest-model-suppression.js";
23
import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js";
34
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
45
import { normalizeProviderId } from "./provider-id.js";
@@ -14,6 +15,15 @@ function resolveBuiltInModelSuppression(params: {
1415
if (!provider || !modelId) {
1516
return undefined;
1617
}
18+
const manifestResult = resolveManifestBuiltInModelSuppression({
19+
provider,
20+
id: modelId,
21+
...(params.config ? { config: params.config } : {}),
22+
env: process.env,
23+
});
24+
if (manifestResult?.suppress) {
25+
return manifestResult;
26+
}
1727
return resolveProviderBuiltInModelSuppression({
1828
...(params.config ? { config: params.config } : {}),
1929
env: process.env,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
loadPluginManifestRegistryForPluginRegistry: vi.fn(),
5+
}));
6+
7+
vi.mock("./plugin-registry.js", () => ({
8+
loadPluginManifestRegistryForPluginRegistry: mocks.loadPluginManifestRegistryForPluginRegistry,
9+
}));
10+
11+
import {
12+
clearManifestModelSuppressionCacheForTest,
13+
resolveManifestBuiltInModelSuppression,
14+
} from "./manifest-model-suppression.js";
15+
16+
describe("manifest model suppression", () => {
17+
beforeEach(() => {
18+
clearManifestModelSuppressionCacheForTest();
19+
mocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
20+
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
21+
diagnostics: [],
22+
plugins: [
23+
{
24+
id: "openai",
25+
providers: ["openai"],
26+
modelCatalog: {
27+
aliases: {
28+
"azure-openai-responses": {
29+
provider: "openai",
30+
},
31+
},
32+
suppressions: [
33+
{
34+
provider: "azure-openai-responses",
35+
model: "gpt-5.3-codex-spark",
36+
reason: "Use openai/gpt-5.5.",
37+
},
38+
{
39+
provider: "openrouter",
40+
model: "foreign-row",
41+
},
42+
],
43+
},
44+
},
45+
],
46+
});
47+
});
48+
49+
it("resolves manifest suppressions for declared provider aliases", () => {
50+
expect(
51+
resolveManifestBuiltInModelSuppression({
52+
provider: "azure-openai-responses",
53+
id: "GPT-5.3-Codex-Spark",
54+
env: process.env,
55+
}),
56+
).toEqual({
57+
suppress: true,
58+
errorMessage:
59+
"Unknown model: azure-openai-responses/gpt-5.3-codex-spark. Use openai/gpt-5.5.",
60+
});
61+
});
62+
63+
it("ignores suppressions for providers the plugin does not own", () => {
64+
expect(
65+
resolveManifestBuiltInModelSuppression({
66+
provider: "openrouter",
67+
id: "foreign-row",
68+
env: process.env,
69+
}),
70+
).toBeUndefined();
71+
});
72+
73+
it("caches planned manifest suppressions per config and environment", () => {
74+
const config = { plugins: { entries: { openai: { enabled: true } } } };
75+
76+
resolveManifestBuiltInModelSuppression({
77+
provider: "azure-openai-responses",
78+
id: "gpt-5.3-codex-spark",
79+
config,
80+
env: process.env,
81+
});
82+
resolveManifestBuiltInModelSuppression({
83+
provider: "azure-openai-responses",
84+
id: "gpt-5.3-codex-spark",
85+
config,
86+
env: process.env,
87+
});
88+
89+
expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1);
90+
});
91+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import {
3+
buildModelCatalogMergeKey,
4+
planManifestModelCatalogSuppressions,
5+
type ManifestModelCatalogSuppressionEntry,
6+
} from "../model-catalog/index.js";
7+
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
8+
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
9+
10+
type ManifestSuppressionCache = Map<string, readonly ManifestModelCatalogSuppressionEntry[]>;
11+
12+
let cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
13+
let cacheByConfig = new WeakMap<
14+
OpenClawConfig,
15+
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
16+
>();
17+
18+
function resolveSuppressionCache(params: {
19+
config?: OpenClawConfig;
20+
env: NodeJS.ProcessEnv;
21+
}): ManifestSuppressionCache {
22+
if (!params.config) {
23+
let cache = cacheWithoutConfig.get(params.env);
24+
if (!cache) {
25+
cache = new Map();
26+
cacheWithoutConfig.set(params.env, cache);
27+
}
28+
return cache;
29+
}
30+
let envCaches = cacheByConfig.get(params.config);
31+
if (!envCaches) {
32+
envCaches = new WeakMap();
33+
cacheByConfig.set(params.config, envCaches);
34+
}
35+
let cache = envCaches.get(params.env);
36+
if (!cache) {
37+
cache = new Map();
38+
envCaches.set(params.env, cache);
39+
}
40+
return cache;
41+
}
42+
43+
function cacheKey(params: { workspaceDir?: string }): string {
44+
return params.workspaceDir ?? "";
45+
}
46+
47+
function listManifestModelCatalogSuppressions(params: {
48+
config?: OpenClawConfig;
49+
workspaceDir?: string;
50+
env: NodeJS.ProcessEnv;
51+
}): readonly ManifestModelCatalogSuppressionEntry[] {
52+
const cache = resolveSuppressionCache({
53+
config: params.config,
54+
env: params.env,
55+
});
56+
const key = cacheKey(params);
57+
const cached = cache.get(key);
58+
if (cached) {
59+
return cached;
60+
}
61+
const registry = loadPluginManifestRegistryForPluginRegistry({
62+
config: params.config,
63+
workspaceDir: params.workspaceDir,
64+
env: params.env,
65+
});
66+
const planned = planManifestModelCatalogSuppressions({ registry });
67+
cache.set(key, planned.suppressions);
68+
return planned.suppressions;
69+
}
70+
71+
function buildManifestSuppressionError(params: {
72+
provider: string;
73+
modelId: string;
74+
reason?: string;
75+
}): string {
76+
const ref = `${params.provider}/${params.modelId}`;
77+
return params.reason ? `Unknown model: ${ref}. ${params.reason}` : `Unknown model: ${ref}.`;
78+
}
79+
80+
export function clearManifestModelSuppressionCacheForTest(): void {
81+
cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
82+
cacheByConfig = new WeakMap<
83+
OpenClawConfig,
84+
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
85+
>();
86+
}
87+
88+
export function resolveManifestBuiltInModelSuppression(params: {
89+
provider?: string | null;
90+
id?: string | null;
91+
config?: OpenClawConfig;
92+
workspaceDir?: string;
93+
env?: NodeJS.ProcessEnv;
94+
}) {
95+
const provider = normalizeLowercaseStringOrEmpty(params.provider);
96+
const modelId = normalizeLowercaseStringOrEmpty(params.id);
97+
if (!provider || !modelId) {
98+
return undefined;
99+
}
100+
const mergeKey = buildModelCatalogMergeKey(provider, modelId);
101+
const suppression = listManifestModelCatalogSuppressions({
102+
config: params.config,
103+
workspaceDir: params.workspaceDir,
104+
env: params.env ?? process.env,
105+
}).find((entry) => entry.mergeKey === mergeKey);
106+
if (!suppression) {
107+
return undefined;
108+
}
109+
return {
110+
suppress: true,
111+
errorMessage: buildManifestSuppressionError({
112+
provider,
113+
modelId,
114+
reason: suppression.reason,
115+
}),
116+
};
117+
}

0 commit comments

Comments
 (0)