Skip to content

Commit 3b9f7c0

Browse files
committed
fix provider static model fallback
1 parent 301213a commit 3b9f7c0

7 files changed

Lines changed: 413 additions & 18 deletions

File tree

src/agents/embedded-agent-runner/model.static-catalog.test.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ const manifestMocks = vi.hoisted(() => ({
55
listOpenClawPluginManifestMetadata: vi.fn(),
66
loadPluginManifest: vi.fn(),
77
}));
8+
const providerMocks = vi.hoisted(() => ({
9+
normalizePluginDiscoveryResult: vi.fn(),
10+
resolveBundledProviderCompatPluginIds: vi.fn(),
11+
resolveOwningPluginIdsForProviderRef: vi.fn(),
12+
resolveRuntimePluginDiscoveryProviders: vi.fn(),
13+
runProviderStaticCatalog: vi.fn(),
14+
}));
815

916
vi.mock("../../plugins/manifest-metadata-scan.js", () => ({
1017
listOpenClawPluginManifestMetadata: manifestMocks.listOpenClawPluginManifestMetadata,
@@ -15,7 +22,24 @@ vi.mock("../../plugins/manifest.js", async (importOriginal) => ({
1522
loadPluginManifest: manifestMocks.loadPluginManifest,
1623
}));
1724

18-
import { resolveBundledStaticCatalogModel } from "./model.static-catalog.js";
25+
vi.mock("../../plugins/providers.js", async (importOriginal) => ({
26+
...(await importOriginal<typeof import("../../plugins/providers.js")>()),
27+
resolveBundledProviderCompatPluginIds: providerMocks.resolveBundledProviderCompatPluginIds,
28+
resolveOwningPluginIdsForProviderRef: providerMocks.resolveOwningPluginIdsForProviderRef,
29+
}));
30+
31+
vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({
32+
...(await importOriginal<typeof import("../../plugins/provider-discovery.js")>()),
33+
normalizePluginDiscoveryResult: providerMocks.normalizePluginDiscoveryResult,
34+
resolveRuntimePluginDiscoveryProviders: providerMocks.resolveRuntimePluginDiscoveryProviders,
35+
runProviderStaticCatalog: providerMocks.runProviderStaticCatalog,
36+
}));
37+
38+
import { getModelProviderRequestTransport } from "../provider-request-config.js";
39+
import {
40+
resolveBundledProviderStaticCatalogModel,
41+
resolveBundledStaticCatalogModel,
42+
} from "./model.static-catalog.js";
1943

2044
function setManifestPlugins(plugins: unknown[]) {
2145
// Static catalog resolution reads scan metadata first, then loads the manifest
@@ -81,7 +105,17 @@ function createMistralManifestPlugin(overrides?: {
81105
beforeEach(() => {
82106
manifestMocks.listOpenClawPluginManifestMetadata.mockReset();
83107
manifestMocks.loadPluginManifest.mockReset();
108+
providerMocks.normalizePluginDiscoveryResult.mockReset();
109+
providerMocks.resolveBundledProviderCompatPluginIds.mockReset();
110+
providerMocks.resolveOwningPluginIdsForProviderRef.mockReset();
111+
providerMocks.resolveRuntimePluginDiscoveryProviders.mockReset();
112+
providerMocks.runProviderStaticCatalog.mockReset();
84113
setManifestPlugins([]);
114+
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
115+
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(undefined);
116+
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([]);
117+
providerMocks.runProviderStaticCatalog.mockResolvedValue(undefined);
118+
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({});
85119
});
86120

87121
describe("resolveBundledStaticCatalogModel", () => {
@@ -166,3 +200,132 @@ describe("resolveBundledStaticCatalogModel", () => {
166200
).toBeUndefined();
167201
});
168202
});
203+
204+
describe("resolveBundledProviderStaticCatalogModel", () => {
205+
it("resolves exact rows from bundled provider static catalogs", async () => {
206+
const cfg = { plugins: { entries: { google: { enabled: true } } } };
207+
const provider = {
208+
id: "google",
209+
pluginId: "google",
210+
label: "Google",
211+
auth: [],
212+
staticCatalog: { run: vi.fn() },
213+
};
214+
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
215+
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
216+
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
217+
providerMocks.runProviderStaticCatalog.mockResolvedValue({ marker: "static-result" });
218+
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
219+
google: {
220+
api: "google-generative-ai",
221+
authHeader: true,
222+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
223+
request: { headers: { "X-Static-Catalog": "yes" } },
224+
models: [
225+
{
226+
id: "gemini-3.1-pro-preview",
227+
name: "Gemini 3.1 Pro Preview",
228+
reasoning: true,
229+
input: ["text", "image"],
230+
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
231+
contextWindow: 1_048_576,
232+
maxTokens: 65_536,
233+
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
234+
},
235+
],
236+
},
237+
});
238+
239+
const model = await resolveBundledProviderStaticCatalogModel({
240+
provider: "google",
241+
modelId: "gemini-3.1-pro-preview",
242+
cfg,
243+
});
244+
245+
expect(model).toMatchObject({
246+
api: "google-generative-ai",
247+
authHeader: true,
248+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
249+
contextTokens: undefined,
250+
contextWindow: 1_048_576,
251+
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
252+
headers: { "X-Static-Catalog": "yes" },
253+
id: "gemini-3.1-pro-preview",
254+
input: ["text", "image"],
255+
maxTokens: 65_536,
256+
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
257+
name: "Gemini 3.1 Pro Preview",
258+
provider: "google",
259+
reasoning: true,
260+
});
261+
expect(getModelProviderRequestTransport(model!)).toEqual({
262+
headers: { "X-Static-Catalog": "yes" },
263+
});
264+
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith({
265+
config: cfg,
266+
workspaceDir: undefined,
267+
env: process.env,
268+
onlyPluginIds: ["google"],
269+
includeUntrustedWorkspacePlugins: false,
270+
requireCompleteDiscoveryEntryCoverage: true,
271+
discoveryEntriesOnly: true,
272+
includeManifestModelCatalogProviders: false,
273+
});
274+
expect(providerMocks.runProviderStaticCatalog).toHaveBeenCalledWith({
275+
provider,
276+
config: cfg,
277+
workspaceDir: undefined,
278+
env: process.env,
279+
});
280+
});
281+
282+
it("does not load provider catalogs when the provider owner is not bundled and enabled", async () => {
283+
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
284+
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
285+
286+
await expect(
287+
resolveBundledProviderStaticCatalogModel({
288+
provider: "google",
289+
modelId: "gemini-3.1-pro-preview",
290+
cfg: {},
291+
}),
292+
).resolves.toBeUndefined();
293+
294+
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
295+
expect(providerMocks.runProviderStaticCatalog).not.toHaveBeenCalled();
296+
});
297+
298+
it("requires an exact provider and model match", async () => {
299+
const provider = { id: "google", pluginId: "google", label: "Google", auth: [] };
300+
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
301+
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
302+
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
303+
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
304+
google: {
305+
api: "google-generative-ai",
306+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
307+
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
308+
},
309+
"google-vertex": {
310+
api: "google-vertex",
311+
baseUrl: "https://aiplatform.googleapis.com/v1",
312+
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
313+
},
314+
});
315+
316+
await expect(
317+
resolveBundledProviderStaticCatalogModel({
318+
provider: "google",
319+
modelId: "gemini-2.5-pro",
320+
cfg: {},
321+
}),
322+
).resolves.toBeUndefined();
323+
await expect(
324+
resolveBundledProviderStaticCatalogModel({
325+
provider: "openrouter",
326+
modelId: "gemini-3.1-pro-preview",
327+
cfg: {},
328+
}),
329+
).resolves.toBeUndefined();
330+
});
331+
});

src/agents/embedded-agent-runner/model.static-catalog.ts

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,26 @@
33
*/
44
import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types";
55
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
6+
import type { ModelProviderConfig } from "../../config/types.models.js";
67
import type { OpenClawConfig } from "../../config/types.openclaw.js";
78
import { planManifestModelCatalogRows } from "../../model-catalog/manifest-planner.js";
89
import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metadata-scan.js";
910
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
1011
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
1112
import { loadPluginManifest } from "../../plugins/manifest.js";
13+
import {
14+
normalizePluginDiscoveryResult,
15+
resolveRuntimePluginDiscoveryProviders,
16+
runProviderStaticCatalog,
17+
} from "../../plugins/provider-discovery.js";
1218
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
19+
import {
20+
resolveBundledProviderCompatPluginIds,
21+
resolveOwningPluginIdsForProviderRef,
22+
} from "../../plugins/providers.js";
1323
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
1424
import { normalizeStaticProviderModelId } from "../model-ref-shared.js";
25+
import { buildInlineProviderModels } from "./model.inline-provider.js";
1526

1627
/**
1728
* Resolves bundled plugin static model-catalog rows into runtime model records.
@@ -20,21 +31,35 @@ function rowMatchesModel(params: {
2031
row: NormalizedModelCatalogRow;
2132
provider: string;
2233
modelId: string;
34+
}): boolean {
35+
return staticModelIdMatches({
36+
candidateId: params.row.id,
37+
provider: params.provider,
38+
modelId: params.modelId,
39+
rowProvider: params.row.provider,
40+
});
41+
}
42+
43+
function staticModelIdMatches(params: {
44+
candidateId: string;
45+
provider: string;
46+
modelId: string;
47+
rowProvider?: string;
2348
}): boolean {
2449
const normalizedProvider = normalizeProviderId(params.provider);
25-
if (normalizeProviderId(params.row.provider) !== normalizedProvider) {
50+
if (params.rowProvider && normalizeProviderId(params.rowProvider) !== normalizedProvider) {
2651
return false;
2752
}
2853
return (
29-
normalizeStaticProviderModelId(normalizedProvider, params.row.id).trim().toLowerCase() ===
54+
normalizeStaticProviderModelId(normalizedProvider, params.candidateId).trim().toLowerCase() ===
3055
normalizeStaticProviderModelId(normalizedProvider, params.modelId).trim().toLowerCase()
3156
);
3257
}
3358

3459
function normalizeStaticCatalogInput(
35-
input: NormalizedModelCatalogRow["input"],
60+
input: readonly unknown[] | undefined,
3661
): ProviderRuntimeModel["input"] {
37-
const normalizedInput = input.filter(
62+
const normalizedInput = (input ?? []).filter(
3863
(item): item is "text" | "image" => item === "text" || item === "image",
3964
);
4065
return normalizedInput.length > 0 ? normalizedInput : ["text"];
@@ -71,6 +96,42 @@ function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): ProviderRunt
7196
};
7297
}
7398

99+
function modelFromProviderStaticCatalog(params: {
100+
provider: string;
101+
providerConfig: ModelProviderConfig;
102+
model: ModelProviderConfig["models"][number];
103+
}): ProviderRuntimeModel {
104+
const [model] = buildInlineProviderModels({
105+
[params.provider]: { ...params.providerConfig, models: [params.model] },
106+
});
107+
return {
108+
...model,
109+
id: model?.id ?? params.model.id,
110+
name: model?.name || params.model.name || params.model.id,
111+
provider: params.provider,
112+
api: model?.api ?? params.model.api ?? params.providerConfig.api ?? "openai-responses",
113+
baseUrl: model?.baseUrl ?? params.model.baseUrl ?? params.providerConfig.baseUrl ?? "",
114+
reasoning: model?.reasoning ?? params.model.reasoning ?? false,
115+
input: normalizeStaticCatalogInput(model?.input ?? params.model.input),
116+
cost: model?.cost ?? normalizeStaticCatalogCost(params.model.cost),
117+
contextWindow:
118+
model?.contextWindow ??
119+
params.model.contextWindow ??
120+
params.providerConfig.contextWindow ??
121+
DEFAULT_CONTEXT_TOKENS,
122+
contextTokens:
123+
model?.contextTokens ?? params.model.contextTokens ?? params.providerConfig.contextTokens,
124+
maxTokens:
125+
model?.maxTokens ??
126+
params.model.maxTokens ??
127+
params.providerConfig.maxTokens ??
128+
DEFAULT_CONTEXT_TOKENS,
129+
...(params.providerConfig.authHeader !== undefined
130+
? { authHeader: params.providerConfig.authHeader }
131+
: {}),
132+
};
133+
}
134+
74135
type StaticCatalogPlugin = Parameters<
75136
typeof planManifestModelCatalogRows
76137
>[0]["registry"]["plugins"][number];
@@ -210,3 +271,86 @@ export function resolveBundledStaticCatalogModel(params: {
210271
}
211272
return undefined;
212273
}
274+
275+
/**
276+
* Resolves one bundled provider static-catalog model row for provider/model lookup.
277+
*
278+
* Some bundled providers expose their canonical offline rows through
279+
* `providerCatalogEntry` instead of manifest `modelCatalog`. This keeps the
280+
* skip-discovery fallback aligned with model list/inspect without running live
281+
* discovery or untrusted workspace plugins.
282+
*/
283+
export async function resolveBundledProviderStaticCatalogModel(params: {
284+
provider: string;
285+
modelId: string;
286+
cfg?: OpenClawConfig;
287+
workspaceDir?: string;
288+
env?: NodeJS.ProcessEnv;
289+
}): Promise<ProviderRuntimeModel | undefined> {
290+
const env = params.env ?? process.env;
291+
const provider = normalizeProviderId(params.provider);
292+
if (!provider || !params.modelId.trim()) {
293+
return undefined;
294+
}
295+
const pluginIds = resolveOwningPluginIdsForProviderRef({
296+
provider,
297+
config: params.cfg,
298+
workspaceDir: params.workspaceDir,
299+
env,
300+
});
301+
if (!pluginIds || pluginIds.length === 0) {
302+
return undefined;
303+
}
304+
const bundledPluginIds = new Set(
305+
resolveBundledProviderCompatPluginIds({
306+
config: params.cfg,
307+
workspaceDir: params.workspaceDir,
308+
env,
309+
}),
310+
);
311+
const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIds.has(pluginId));
312+
if (scopedPluginIds.length === 0) {
313+
return undefined;
314+
}
315+
316+
const providers = await resolveRuntimePluginDiscoveryProviders({
317+
config: params.cfg,
318+
workspaceDir: params.workspaceDir,
319+
env,
320+
onlyPluginIds: scopedPluginIds,
321+
includeUntrustedWorkspacePlugins: false,
322+
requireCompleteDiscoveryEntryCoverage: true,
323+
discoveryEntriesOnly: true,
324+
includeManifestModelCatalogProviders: false,
325+
});
326+
327+
for (const catalogProvider of providers) {
328+
const result = await runProviderStaticCatalog({
329+
provider: catalogProvider,
330+
config: params.cfg ?? {},
331+
workspaceDir: params.workspaceDir,
332+
env,
333+
});
334+
const normalized = normalizePluginDiscoveryResult({
335+
provider: catalogProvider,
336+
result,
337+
});
338+
for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) {
339+
const providerId = normalizeProviderId(providerIdRaw);
340+
if (providerId !== provider || !Array.isArray(providerConfig.models)) {
341+
continue;
342+
}
343+
const model = providerConfig.models.find((candidate) =>
344+
staticModelIdMatches({
345+
candidateId: candidate.id,
346+
provider,
347+
modelId: params.modelId,
348+
}),
349+
);
350+
if (model) {
351+
return modelFromProviderStaticCatalog({ provider, providerConfig, model });
352+
}
353+
}
354+
}
355+
return undefined;
356+
}

0 commit comments

Comments
 (0)