fix: restrict static model catalogs to bundled providers

This commit is contained in:
Shakker
2026-04-22 03:45:13 +01:00
committed by Shakker
parent f3da6e96b7
commit 10959aa980
3 changed files with 60 additions and 165 deletions

View File

@@ -241,10 +241,11 @@ API key auth, and dynamic model resolution.
`buildProvider` is the live catalog path used when OpenClaw can resolve real
provider auth. It may perform provider-specific discovery. Use
`buildStaticProvider` only for bundled/offline rows that are safe to show in
display-only surfaces such as `models list --all` before auth is configured;
it must not require credentials or make network requests. Static catalog
hooks run with an empty config, empty env, and no agent/workspace paths.
`buildStaticProvider` only for offline rows that are safe to show before auth
is configured; it must not require credentials or make network requests.
OpenClaw's `models list --all` display currently executes static catalogs
only for bundled provider plugins, with an empty config, empty env, and no
agent/workspace paths.
If your auth flow also needs to patch `models.providers.*`, aliases, and
the agent default model during onboarding, use the preset helpers from

View File

@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProviderPlugin } from "../../plugins/types.js";
import {
loadProviderCatalogModelsForList,
resolveProviderCatalogPluginIdsForFilter,
@@ -24,7 +23,6 @@ const baseParams = {
describe("loadProviderCatalogModelsForList", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -53,48 +51,6 @@ describe("loadProviderCatalogModelsForList", () => {
);
});
it("skips static catalogs that exceed the display budget", async () => {
vi.useFakeTimers();
const hungProvider = {
id: "hung",
label: "Hung",
auth: [],
staticCatalog: {
run: async () => new Promise<never>(() => {}),
},
} satisfies ProviderPlugin;
const healthyProvider = {
id: "healthy",
label: "Healthy",
auth: [],
staticCatalog: {
run: async () => ({
provider: {
baseUrl: "https://healthy.example/v1",
models: [{ id: "healthy-model", name: "Healthy Model" }],
},
}),
},
} satisfies ProviderPlugin;
const discovery = await import("../../plugins/provider-discovery.js");
vi.spyOn(discovery, "resolvePluginDiscoveryProviders").mockResolvedValue([
hungProvider,
healthyProvider,
]);
const rowsPromise = loadProviderCatalogModelsForList({
...baseParams,
});
await vi.advanceTimersByTimeAsync(2_000);
await expect(rowsPromise).resolves.toEqual([
expect.objectContaining({
provider: "healthy",
id: "healthy-model",
}),
]);
});
it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => {
await expect(
resolveProviderCatalogPluginIdsForFilter({
@@ -105,47 +61,46 @@ describe("loadProviderCatalogModelsForList", () => {
).resolves.toEqual(["openai"]);
});
it("recognizes trusted workspace provider aliases before the unknown-provider short-circuit", async () => {
const manifestRegistry = await import("../../plugins/manifest-registry.js");
it("does not execute workspace provider static catalogs", async () => {
const providers = await import("../../plugins/providers.js");
const discovery = await import("../../plugins/provider-discovery.js");
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [
{
id: "workspace-demo",
origin: "workspace",
providers: ["workspace-demo"],
cliBackends: [],
},
],
diagnostics: [],
} as never);
vi.spyOn(providers, "resolveDiscoveredProviderPluginIds").mockReturnValue(["workspace-demo"]);
const workspaceStaticCatalog = vi.fn(async () => ({
provider: { baseUrl: "https://workspace.example/v1", models: [] },
}));
vi.spyOn(providers, "resolveBundledProviderCompatPluginIds").mockReturnValue(["bundled-demo"]);
vi.spyOn(discovery, "resolvePluginDiscoveryProviders").mockResolvedValue([
{
id: "bundled-demo",
pluginId: "bundled-demo",
label: "Bundled Demo",
auth: [],
staticCatalog: {
run: async () => null,
},
},
{
id: "workspace-demo",
pluginId: "workspace-demo",
label: "Workspace Demo",
aliases: ["workspace-demo-alias"],
auth: [],
staticCatalog: {
run: async () => ({
provider: {
baseUrl: "https://workspace.example/v1",
models: [],
},
}),
run: workspaceStaticCatalog,
},
},
]);
await expect(
resolveProviderCatalogPluginIdsForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "workspace-demo-alias",
const rows = await loadProviderCatalogModelsForList({
...baseParams,
});
expect(discovery.resolvePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["bundled-demo"],
includeUntrustedWorkspacePlugins: false,
}),
).resolves.toEqual(["workspace-demo"]);
);
expect(workspaceStaticCatalog).not.toHaveBeenCalled();
expect(rows).toEqual([]);
});
it("keeps unknown provider filters eligible for early empty results", async () => {

View File

@@ -4,7 +4,6 @@ import type { ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
@@ -12,63 +11,14 @@ import {
runProviderStaticCatalog,
} from "../../plugins/provider-discovery.js";
import {
resolveDiscoveredProviderPluginIds,
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProvider,
} from "../../plugins/providers.js";
import type { ProviderPlugin } from "../../plugins/types.js";
const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const;
const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]);
const STATIC_CATALOG_TIMEOUT_MS = 2_000;
const log = createSubsystemLogger("models/list-provider-catalog");
function providerMatchesFilterAlias(provider: ProviderPlugin, providerFilter: string): boolean {
return [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some(
(providerId) => normalizeProviderId(providerId) === providerFilter,
);
}
async function resolveWorkspacePluginIdsForProviderAlias(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
providerFilter: string;
}): Promise<string[] | undefined> {
const discoverablePluginIds = new Set(
resolveDiscoveredProviderPluginIds({
config: params.cfg,
env: params.env,
includeUntrustedWorkspacePlugins: false,
}),
);
const workspacePluginIds = loadPluginManifestRegistry({
config: params.cfg,
env: params.env,
})
.plugins.filter(
(plugin) => plugin.origin === "workspace" && discoverablePluginIds.has(plugin.id),
)
.map((plugin) => plugin.id);
if (workspacePluginIds.length === 0) {
return undefined;
}
const providers = await resolvePluginDiscoveryProviders({
config: params.cfg,
env: params.env,
onlyPluginIds: workspacePluginIds,
includeUntrustedWorkspacePlugins: false,
});
const pluginIds = [
...new Set(
providers
.filter((provider) => providerMatchesFilterAlias(provider, params.providerFilter))
.map((provider) => provider.pluginId)
.filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""),
),
].toSorted((left, right) => left.localeCompare(right));
return pluginIds.length > 0 ? pluginIds : undefined;
}
export async function resolveProviderCatalogPluginIdsForFilter(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -92,11 +42,7 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: {
if (bundledAliasPluginIds) {
return bundledAliasPluginIds;
}
return await resolveWorkspacePluginIdsForProviderAlias({
cfg: params.cfg,
env: params.env,
providerFilter,
});
return undefined;
}
function modelFromProviderCatalog(params: {
@@ -121,29 +67,6 @@ function modelFromProviderCatalog(params: {
} as Model<Api>;
}
async function withStaticCatalogTimeout<T>(
providerId: string,
run: () => T | Promise<T>,
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
`provider static catalog timed out for ${providerId} after ${STATIC_CATALOG_TIMEOUT_MS}ms`,
),
);
}, STATIC_CATALOG_TIMEOUT_MS);
});
try {
return await Promise.race([Promise.resolve().then(run), timeout]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
export async function loadProviderCatalogModelsForList(params: {
cfg: OpenClawConfig;
agentDir: string;
@@ -162,12 +85,30 @@ export async function loadProviderCatalogModelsForList(params: {
if (providerFilter && !onlyPluginIds) {
return [];
}
const providers = await resolvePluginDiscoveryProviders({
const bundledPluginIds = resolveBundledProviderCompatPluginIds({
config: params.cfg,
env,
...(onlyPluginIds ? { onlyPluginIds } : {}),
includeUntrustedWorkspacePlugins: false,
});
const bundledPluginIdSet = new Set(bundledPluginIds);
const scopedPluginIds = onlyPluginIds
? onlyPluginIds.filter((pluginId) => bundledPluginIdSet.has(pluginId))
: bundledPluginIds;
if (scopedPluginIds.length === 0) {
return [];
}
const providers = (
await resolvePluginDiscoveryProviders({
config: params.cfg,
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
})
).filter(
(provider) =>
typeof provider.pluginId === "string" && bundledPluginIdSet.has(provider.pluginId),
);
const byOrder = groupPluginDiscoveryProvidersByOrder(providers);
const rows: Model<Api>[] = [];
const seen = new Set<string>();
@@ -179,14 +120,12 @@ export async function loadProviderCatalogModelsForList(params: {
}
let result: Awaited<ReturnType<typeof runProviderStaticCatalog>> | null;
try {
result = await withStaticCatalogTimeout(provider.id, () =>
runProviderStaticCatalog({
provider,
config: params.cfg,
agentDir: params.agentDir,
env,
}),
);
result = await runProviderStaticCatalog({
provider,
config: params.cfg,
agentDir: params.agentDir,
env,
});
} catch (error) {
log.warn(`provider static catalog failed for ${provider.id}: ${formatErrorMessage(error)}`);
result = null;