mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix: restrict static model catalogs to bundled providers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user