fix: bound unscoped provider discovery fallback

This commit is contained in:
Shakker
2026-04-24 05:21:51 +01:00
committed by Shakker
parent d3fe591853
commit da6c29b3d9
4 changed files with 114 additions and 40 deletions

View File

@@ -1,7 +1,6 @@
{
"id": "arcee",
"enabledByDefault": true,
"providerDiscoveryEntry": "./provider-discovery.ts",
"providers": ["arcee"],
"providerAuthEnvVars": {
"arcee": ["ARCEEAI_API_KEY"]

View File

@@ -1,17 +0,0 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { buildArceeProvider } from "./provider-catalog.js";
export const arceeProviderDiscovery: ProviderPlugin = {
id: "arcee",
label: "Arcee AI",
docsPath: "/providers/models",
auth: [],
staticCatalog: {
order: "simple",
run: async () => ({
provider: buildArceeProvider(),
}),
},
};
export default arceeProviderDiscovery;

View File

@@ -44,6 +44,19 @@ function createManifestPlugin(id: string): PluginManifestRecord {
};
}
function createManifestPluginWithoutDiscovery(params: {
id: string;
providerAuthEnvVars?: Record<string, string[]>;
}): PluginManifestRecord {
const { providerDiscoverySource: _providerDiscoverySource, ...plugin } = createManifestPlugin(
params.id,
);
return {
...plugin,
...(params.providerAuthEnvVars ? { providerAuthEnvVars: params.providerAuthEnvVars } : {}),
};
}
function createProvider(params: { id: string; mode: "static" | "catalog" }): ProviderPlugin {
const hook = {
run: async () => ({
@@ -80,28 +93,55 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => {
expect(mocks.resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
bundledProviderAllowlistCompat: true,
onlyPluginIds: ["deepseek"],
}),
);
});
it("falls back to full provider plugins for mixed live and static-only entries", () => {
it("keeps unscoped discovery bounded for mixed live and static-only entries", () => {
const codexEntryProvider = createProvider({ id: "codex", mode: "catalog" });
const fullProviders = [
createProvider({ id: "codex", mode: "catalog" }),
createProvider({ id: "deepseek", mode: "catalog" }),
createProvider({ id: "kilocode", mode: "catalog" }),
];
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["codex", "deepseek"]);
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue([
"codex",
"deepseek",
"kilocode",
"unused",
]);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [createManifestPlugin("codex"), createManifestPlugin("deepseek")],
plugins: [
createManifestPlugin("codex"),
createManifestPlugin("deepseek"),
createManifestPluginWithoutDiscovery({
id: "kilocode",
providerAuthEnvVars: { kilocode: ["KILOCODE_API_KEY"] },
}),
createManifestPluginWithoutDiscovery({
id: "unused",
providerAuthEnvVars: { unused: ["UNUSED_API_KEY"] },
}),
],
diagnostics: [],
});
mocks.loadSource.mockImplementation((modulePath: string) =>
modulePath.includes("/codex/")
? createProvider({ id: "codex", mode: "catalog" })
? codexEntryProvider
: createProvider({ id: "deepseek", mode: "static" }),
);
mocks.resolvePluginProviders.mockReturnValue(fullProviders);
expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual(fullProviders);
expect(
resolvePluginDiscoveryProvidersRuntime({
env: { KILOCODE_API_KEY: "sk-test" } as NodeJS.ProcessEnv,
}),
).toEqual([{ ...codexEntryProvider, pluginId: "codex" }, ...fullProviders]);
expect(mocks.resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["deepseek", "kilocode"],
}),
);
});
it("returns static-only discovery entries for callers that explicitly request them", () => {

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import { resolveDiscoveredProviderPluginIds } from "./providers.js";
import { resolvePluginProviders } from "./providers.runtime.js";
import { createPluginSourceLoader } from "./source-loader.js";
@@ -17,6 +17,8 @@ type ProviderDiscoveryModule =
type ProviderDiscoveryEntryResult = {
providers: ProviderPlugin[];
complete: boolean;
pluginRecords: PluginManifestRecord[];
entryPluginIds: Set<string>;
};
function normalizeDiscoveryModule(value: ProviderDiscoveryModule): ProviderPlugin[] {
@@ -48,6 +50,22 @@ function hasLiveProviderDiscoveryHook(provider: ProviderPlugin): boolean {
);
}
function hasProviderAuthEnvCredential(
plugin: PluginManifestRecord,
env: NodeJS.ProcessEnv,
): boolean {
return Object.values(plugin.providerAuthEnvVars ?? {}).some((envVars) =>
envVars.some((name) => {
const value = env[name]?.trim();
return value !== undefined && value !== "";
}),
);
}
function dedupeSorted(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
function resolveProviderDiscoveryEntryPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
@@ -59,19 +77,21 @@ function resolveProviderDiscoveryEntryPlugins(params: {
}): ProviderDiscoveryEntryResult {
const pluginIds = resolveDiscoveredProviderPluginIds(params);
const pluginIdSet = new Set(pluginIds);
const records = loadPluginManifestRegistry(params).plugins.filter(
(plugin) => plugin.providerDiscoverySource && pluginIdSet.has(plugin.id),
const pluginRecords = loadPluginManifestRegistry(params).plugins.filter((plugin) =>
pluginIdSet.has(plugin.id),
);
if (records.length === 0) {
return { providers: [], complete: false };
const entryRecords = pluginRecords.filter((plugin) => plugin.providerDiscoverySource);
const entryPluginIds = new Set(entryRecords.map((plugin) => plugin.id));
if (entryRecords.length === 0) {
return { providers: [], complete: false, pluginRecords, entryPluginIds };
}
const complete = records.length === pluginIdSet.size;
const complete = entryRecords.length === pluginIdSet.size;
if (params.requireCompleteDiscoveryEntryCoverage && !complete) {
return { providers: [], complete: false };
return { providers: [], complete: false, pluginRecords, entryPluginIds };
}
const loadSource = createPluginSourceLoader();
const providers: ProviderPlugin[] = [];
for (const manifest of records) {
for (const manifest of entryRecords) {
try {
const moduleExport = loadSource(manifest.providerDiscoverySource!) as ProviderDiscoveryModule;
providers.push(
@@ -82,10 +102,26 @@ function resolveProviderDiscoveryEntryPlugins(params: {
} catch {
// Discovery fast path is optional. Fall back to the full plugin loader
// below so existing plugin diagnostics/load behavior remains canonical.
return { providers: [], complete: false };
return { providers: [], complete: false, pluginRecords, entryPluginIds };
}
}
return { providers, complete };
return { providers, complete, pluginRecords, entryPluginIds };
}
function resolveSelectiveFullPluginIds(params: {
entryResult: ProviderDiscoveryEntryResult;
entryProviders: ProviderPlugin[];
env: NodeJS.ProcessEnv;
}): string[] {
const staticOnlyEntryPluginIds = params.entryProviders
.filter((provider) => !hasLiveProviderDiscoveryHook(provider))
.map((provider) => provider.pluginId)
.filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== "");
const missingEntryCredentialPluginIds = params.entryResult.pluginRecords
.filter((plugin) => !params.entryResult.entryPluginIds.has(plugin.id))
.filter((plugin) => hasProviderAuthEnvCredential(plugin, params.env))
.map((plugin) => plugin.id);
return dedupeSorted([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]);
}
export function resolvePluginDiscoveryProvidersRuntime(params: {
@@ -97,19 +133,35 @@ export function resolvePluginDiscoveryProvidersRuntime(params: {
requireCompleteDiscoveryEntryCoverage?: boolean;
discoveryEntriesOnly?: boolean;
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const entryResult = resolveProviderDiscoveryEntryPlugins(params);
if (params.discoveryEntriesOnly === true) {
return entryResult.providers;
}
if (
entryResult.complete &&
entryResult.providers.length > 0 &&
entryResult.providers.every(hasLiveProviderDiscoveryHook)
) {
return entryResult.providers;
const liveEntryProviders = entryResult.providers.filter(hasLiveProviderDiscoveryHook);
if (entryResult.complete && liveEntryProviders.length === entryResult.providers.length) {
return liveEntryProviders;
}
if (params.onlyPluginIds === undefined && entryResult.providers.length > 0) {
const fullPluginIds = resolveSelectiveFullPluginIds({
entryResult,
entryProviders: entryResult.providers,
env,
});
const fullProviders =
fullPluginIds.length > 0
? resolvePluginProviders({
...params,
env,
onlyPluginIds: fullPluginIds,
bundledProviderAllowlistCompat: true,
})
: [];
return [...liveEntryProviders, ...fullProviders];
}
return resolvePluginProviders({
...params,
env,
bundledProviderAllowlistCompat: true,
});
}