fix provider static model fallback (#92293)

This commit is contained in:
Josh Avant
2026-06-12 11:14:00 -05:00
committed by GitHub
parent 9386d6214f
commit da4671ebcc
7 changed files with 413 additions and 18 deletions

View File

@@ -5,6 +5,13 @@ const manifestMocks = vi.hoisted(() => ({
listOpenClawPluginManifestMetadata: vi.fn(),
loadPluginManifest: vi.fn(),
}));
const providerMocks = vi.hoisted(() => ({
normalizePluginDiscoveryResult: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
resolveOwningPluginIdsForProviderRef: vi.fn(),
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderStaticCatalog: vi.fn(),
}));
vi.mock("../../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: manifestMocks.listOpenClawPluginManifestMetadata,
@@ -15,7 +22,24 @@ vi.mock("../../plugins/manifest.js", async (importOriginal) => ({
loadPluginManifest: manifestMocks.loadPluginManifest,
}));
import { resolveBundledStaticCatalogModel } from "./model.static-catalog.js";
vi.mock("../../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/providers.js")>()),
resolveBundledProviderCompatPluginIds: providerMocks.resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef: providerMocks.resolveOwningPluginIdsForProviderRef,
}));
vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/provider-discovery.js")>()),
normalizePluginDiscoveryResult: providerMocks.normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders: providerMocks.resolveRuntimePluginDiscoveryProviders,
runProviderStaticCatalog: providerMocks.runProviderStaticCatalog,
}));
import { getModelProviderRequestTransport } from "../provider-request-config.js";
import {
resolveBundledProviderStaticCatalogModel,
resolveBundledStaticCatalogModel,
} from "./model.static-catalog.js";
function setManifestPlugins(plugins: unknown[]) {
// Static catalog resolution reads scan metadata first, then loads the manifest
@@ -81,7 +105,17 @@ function createMistralManifestPlugin(overrides?: {
beforeEach(() => {
manifestMocks.listOpenClawPluginManifestMetadata.mockReset();
manifestMocks.loadPluginManifest.mockReset();
providerMocks.normalizePluginDiscoveryResult.mockReset();
providerMocks.resolveBundledProviderCompatPluginIds.mockReset();
providerMocks.resolveOwningPluginIdsForProviderRef.mockReset();
providerMocks.resolveRuntimePluginDiscoveryProviders.mockReset();
providerMocks.runProviderStaticCatalog.mockReset();
setManifestPlugins([]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(undefined);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([]);
providerMocks.runProviderStaticCatalog.mockResolvedValue(undefined);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({});
});
describe("resolveBundledStaticCatalogModel", () => {
@@ -166,3 +200,132 @@ describe("resolveBundledStaticCatalogModel", () => {
).toBeUndefined();
});
});
describe("resolveBundledProviderStaticCatalogModel", () => {
it("resolves exact rows from bundled provider static catalogs", async () => {
const cfg = { plugins: { entries: { google: { enabled: true } } } };
const provider = {
id: "google",
pluginId: "google",
label: "Google",
auth: [],
staticCatalog: { run: vi.fn() },
};
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
providerMocks.runProviderStaticCatalog.mockResolvedValue({ marker: "static-result" });
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
api: "google-generative-ai",
authHeader: true,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
request: { headers: { "X-Static-Catalog": "yes" } },
models: [
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
},
],
},
});
const model = await resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg,
});
expect(model).toMatchObject({
api: "google-generative-ai",
authHeader: true,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
contextTokens: undefined,
contextWindow: 1_048_576,
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
headers: { "X-Static-Catalog": "yes" },
id: "gemini-3.1-pro-preview",
input: ["text", "image"],
maxTokens: 65_536,
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
name: "Gemini 3.1 Pro Preview",
provider: "google",
reasoning: true,
});
expect(getModelProviderRequestTransport(model!)).toEqual({
headers: { "X-Static-Catalog": "yes" },
});
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith({
config: cfg,
workspaceDir: undefined,
env: process.env,
onlyPluginIds: ["google"],
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
includeManifestModelCatalogProviders: false,
});
expect(providerMocks.runProviderStaticCatalog).toHaveBeenCalledWith({
provider,
config: cfg,
workspaceDir: undefined,
env: process.env,
});
});
it("does not load provider catalogs when the provider owner is not bundled and enabled", async () => {
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: {},
}),
).resolves.toBeUndefined();
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
expect(providerMocks.runProviderStaticCatalog).not.toHaveBeenCalled();
});
it("requires an exact provider and model match", async () => {
const provider = { id: "google", pluginId: "google", label: "Google", auth: [] };
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
},
"google-vertex": {
api: "google-vertex",
baseUrl: "https://aiplatform.googleapis.com/v1",
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
},
});
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-2.5-pro",
cfg: {},
}),
).resolves.toBeUndefined();
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "openrouter",
modelId: "gemini-3.1-pro-preview",
cfg: {},
}),
).resolves.toBeUndefined();
});
});

View File

@@ -3,15 +3,26 @@
*/
import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { planManifestModelCatalogRows } from "../../model-catalog/manifest-planner.js";
import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metadata-scan.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import {
normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders,
runProviderStaticCatalog,
} from "../../plugins/provider-discovery.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef,
} from "../../plugins/providers.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeStaticProviderModelId } from "../model-ref-shared.js";
import { buildInlineProviderModels } from "./model.inline-provider.js";
/**
* Resolves bundled plugin static model-catalog rows into runtime model records.
@@ -20,21 +31,35 @@ function rowMatchesModel(params: {
row: NormalizedModelCatalogRow;
provider: string;
modelId: string;
}): boolean {
return staticModelIdMatches({
candidateId: params.row.id,
provider: params.provider,
modelId: params.modelId,
rowProvider: params.row.provider,
});
}
function staticModelIdMatches(params: {
candidateId: string;
provider: string;
modelId: string;
rowProvider?: string;
}): boolean {
const normalizedProvider = normalizeProviderId(params.provider);
if (normalizeProviderId(params.row.provider) !== normalizedProvider) {
if (params.rowProvider && normalizeProviderId(params.rowProvider) !== normalizedProvider) {
return false;
}
return (
normalizeStaticProviderModelId(normalizedProvider, params.row.id).trim().toLowerCase() ===
normalizeStaticProviderModelId(normalizedProvider, params.candidateId).trim().toLowerCase() ===
normalizeStaticProviderModelId(normalizedProvider, params.modelId).trim().toLowerCase()
);
}
function normalizeStaticCatalogInput(
input: NormalizedModelCatalogRow["input"],
input: readonly unknown[] | undefined,
): ProviderRuntimeModel["input"] {
const normalizedInput = input.filter(
const normalizedInput = (input ?? []).filter(
(item): item is "text" | "image" => item === "text" || item === "image",
);
return normalizedInput.length > 0 ? normalizedInput : ["text"];
@@ -71,6 +96,42 @@ function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): ProviderRunt
};
}
function modelFromProviderStaticCatalog(params: {
provider: string;
providerConfig: ModelProviderConfig;
model: ModelProviderConfig["models"][number];
}): ProviderRuntimeModel {
const [model] = buildInlineProviderModels({
[params.provider]: { ...params.providerConfig, models: [params.model] },
});
return {
...model,
id: model?.id ?? params.model.id,
name: model?.name || params.model.name || params.model.id,
provider: params.provider,
api: model?.api ?? params.model.api ?? params.providerConfig.api ?? "openai-responses",
baseUrl: model?.baseUrl ?? params.model.baseUrl ?? params.providerConfig.baseUrl ?? "",
reasoning: model?.reasoning ?? params.model.reasoning ?? false,
input: normalizeStaticCatalogInput(model?.input ?? params.model.input),
cost: model?.cost ?? normalizeStaticCatalogCost(params.model.cost),
contextWindow:
model?.contextWindow ??
params.model.contextWindow ??
params.providerConfig.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
contextTokens:
model?.contextTokens ?? params.model.contextTokens ?? params.providerConfig.contextTokens,
maxTokens:
model?.maxTokens ??
params.model.maxTokens ??
params.providerConfig.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
...(params.providerConfig.authHeader !== undefined
? { authHeader: params.providerConfig.authHeader }
: {}),
};
}
type StaticCatalogPlugin = Parameters<
typeof planManifestModelCatalogRows
>[0]["registry"]["plugins"][number];
@@ -210,3 +271,86 @@ export function resolveBundledStaticCatalogModel(params: {
}
return undefined;
}
/**
* Resolves one bundled provider static-catalog model row for provider/model lookup.
*
* Some bundled providers expose their canonical offline rows through
* `providerCatalogEntry` instead of manifest `modelCatalog`. This keeps the
* skip-discovery fallback aligned with model list/inspect without running live
* discovery or untrusted workspace plugins.
*/
export async function resolveBundledProviderStaticCatalogModel(params: {
provider: string;
modelId: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderRuntimeModel | undefined> {
const env = params.env ?? process.env;
const provider = normalizeProviderId(params.provider);
if (!provider || !params.modelId.trim()) {
return undefined;
}
const pluginIds = resolveOwningPluginIdsForProviderRef({
provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
});
if (!pluginIds || pluginIds.length === 0) {
return undefined;
}
const bundledPluginIds = new Set(
resolveBundledProviderCompatPluginIds({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
}),
);
const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIds.has(pluginId));
if (scopedPluginIds.length === 0) {
return undefined;
}
const providers = await resolveRuntimePluginDiscoveryProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
includeManifestModelCatalogProviders: false,
});
for (const catalogProvider of providers) {
const result = await runProviderStaticCatalog({
provider: catalogProvider,
config: params.cfg ?? {},
workspaceDir: params.workspaceDir,
env,
});
const normalized = normalizePluginDiscoveryResult({
provider: catalogProvider,
result,
});
for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) {
const providerId = normalizeProviderId(providerIdRaw);
if (providerId !== provider || !Array.isArray(providerConfig.models)) {
continue;
}
const model = providerConfig.models.find((candidate) =>
staticModelIdMatches({
candidateId: candidate.id,
provider,
modelId: params.modelId,
}),
);
if (model) {
return modelFromProviderStaticCatalog({ provider, providerConfig, model });
}
}
}
return undefined;
}

View File

@@ -19,6 +19,7 @@ import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
const resolveBundledStaticCatalogModelMock = vi.hoisted(() => vi.fn());
const resolveBundledProviderStaticCatalogModelMock = vi.hoisted(() => vi.fn());
const resolveRuntimeSyntheticAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => []));
const resolveRuntimeExternalAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => []));
@@ -134,6 +135,7 @@ vi.mock("./model.static-catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./model.static-catalog.js")>();
return {
...actual,
resolveBundledProviderStaticCatalogModel: resolveBundledProviderStaticCatalogModelMock,
resolveBundledStaticCatalogModel: resolveBundledStaticCatalogModelMock,
};
});
@@ -187,6 +189,7 @@ beforeEach(() => {
mockLoadOpenRouterModelCapabilities.mockReset();
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
resolveBundledStaticCatalogModelMock.mockReset();
resolveBundledProviderStaticCatalogModelMock.mockReset();
});
function createRuntimeHooks() {
@@ -568,6 +571,58 @@ describe("resolveModel", () => {
cfg: undefined,
workspaceDir: undefined,
});
expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled();
expect(discoverAuthStorage).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
});
it("resolves opt-in provider static catalog rows while skipping agent discovery", async () => {
resolveBundledProviderStaticCatalogModelMock.mockResolvedValueOnce({
provider: "google",
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
});
const result = await resolveModelAsync(
"google",
"gemini-3.1-pro-preview",
"/tmp/agent",
undefined,
{
allowBundledStaticCatalogFallback: true,
runtimeHooks: createRuntimeHooks(),
skipAgentDiscovery: true,
},
);
expectRecordFields(expectResolvedModel(result), {
provider: "google",
id: "gemini-3.1-pro-preview",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
contextWindow: 1_048_576,
maxTokens: 65_536,
});
expect(resolveBundledStaticCatalogModelMock).toHaveBeenCalledWith({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: undefined,
workspaceDir: undefined,
});
expect(resolveBundledProviderStaticCatalogModelMock).toHaveBeenCalledWith({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: undefined,
workspaceDir: undefined,
});
expect(discoverAuthStorage).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
});

View File

@@ -54,6 +54,7 @@ import {
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
import {
canonicalizeManifestModelCatalogProviderAlias,
resolveBundledProviderStaticCatalogModel,
resolveBundledStaticCatalogModel,
} from "./model.static-catalog.js";
@@ -1566,25 +1567,32 @@ export async function resolveModelAsync(
authProfileId: options?.authProfileId,
preferredProfile: options?.preferredProfile,
});
let staticCatalogLookupComplete = false;
let staticCatalogModel: ReturnType<typeof resolveBundledStaticCatalogModel> | undefined;
const resolveStaticCatalogModel = () => {
let staticCatalogLookup: Promise<ProviderRuntimeModel | undefined> | undefined;
const resolveStaticCatalogModel = async () => {
if (!options?.allowBundledStaticCatalogFallback) {
return undefined;
}
if (!staticCatalogLookupComplete) {
staticCatalogLookupComplete = true;
staticCatalogModel = resolveBundledStaticCatalogModel({
staticCatalogLookup ??= (async () => {
const manifestModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
});
}
return staticCatalogModel;
if (manifestModel) {
return manifestModel;
}
return await resolveBundledProviderStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
});
})();
return await staticCatalogLookup;
};
const resolveStaticCatalogFallbackModel = () => {
const catalogModel = resolveStaticCatalogModel();
const resolveStaticCatalogFallbackModel = async () => {
const catalogModel = await resolveStaticCatalogModel();
if (!catalogModel) {
return undefined;
}
@@ -1657,7 +1665,7 @@ export async function resolveModelAsync(
model = await resolveDynamicAttempt();
}
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = resolveStaticCatalogFallbackModel();
model = await resolveStaticCatalogFallbackModel();
}
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = resolveConfiguredFallbackModel({
@@ -1670,7 +1678,7 @@ export async function resolveModelAsync(
});
}
if (model && options?.allowBundledStaticCatalogFallback) {
const staticMediaInput = resolveStaticCatalogModel()?.mediaInput;
const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput;
const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput;
const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput);
if (mediaInput) {

View File

@@ -691,6 +691,25 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => {
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
it("can omit manifest model catalogs from static discovery entries", () => {
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["openai"]);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
index: { plugins: [] },
manifestRegistry: {
plugins: [createManifestPluginWithModelCatalog("openai")],
diagnostics: [],
},
});
const providers = resolvePluginDiscoveryProvidersRuntime({
discoveryEntriesOnly: true,
includeManifestModelCatalogProviders: false,
});
expect(providers).toStrictEqual([]);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
it("defaults missing manifest model costs for static discovery entries", async () => {
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["anthropic"]);
mocks.loadPluginMetadataSnapshot.mockReturnValue({

View File

@@ -274,6 +274,7 @@ function resolveProviderDiscoveryEntryPlugins(params: {
includeUntrustedWorkspacePlugins?: boolean;
requireCompleteDiscoveryEntryCoverage?: boolean;
discoveryEntriesOnly?: boolean;
includeManifestModelCatalogProviders?: boolean;
pluginMetadataSnapshot?: PluginMetadataRegistryView;
}): ProviderDiscoveryEntryResult {
const metadataSnapshot =
@@ -295,7 +296,10 @@ function resolveProviderDiscoveryEntryPlugins(params: {
const runtimeManifestCatalogPluginIds = resolveRuntimeManifestCatalogPluginIds(pluginRecords);
const entryRecords = pluginRecords.filter((plugin) => plugin.providerDiscoverySource);
const entryPluginIds = new Set(entryRecords.map((plugin) => plugin.id));
const manifestProviders = resolveManifestModelCatalogProviders(pluginRecords);
const manifestProviders =
params.includeManifestModelCatalogProviders === false
? []
: resolveManifestModelCatalogProviders(pluginRecords);
const manifestEntryPluginIds = new Set<string>();
for (const pluginId of manifestProviders.map((provider) => provider.pluginId)) {
if (pluginId) {
@@ -427,6 +431,7 @@ export function resolvePluginDiscoveryProvidersRuntime(params: {
includeUntrustedWorkspacePlugins?: boolean;
requireCompleteDiscoveryEntryCoverage?: boolean;
discoveryEntriesOnly?: boolean;
includeManifestModelCatalogProviders?: boolean;
pluginMetadataSnapshot?: PluginMetadataRegistryView;
}): ProviderPlugin[] {
const env = params.env ?? process.env;

View File

@@ -46,6 +46,7 @@ export type ResolveRuntimePluginDiscoveryProvidersParams = {
includeUntrustedWorkspacePlugins?: boolean;
requireCompleteDiscoveryEntryCoverage?: boolean;
discoveryEntriesOnly?: boolean;
includeManifestModelCatalogProviders?: boolean;
pluginMetadataSnapshot?: PluginMetadataRegistryView;
};