fix: unblock live harness provider discovery

This commit is contained in:
Peter Steinberger
2026-03-23 23:01:58 -07:00
parent ab8c834aab
commit a2d3b9f317
6 changed files with 158 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
buildAnthropicVertexProvider,
buildKimiCodingProvider,
@@ -68,9 +69,41 @@ const MODELSTUDIO_NATIVE_BASE_URLS = new Set([
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
]);
const log = createSubsystemLogger("agents/model-providers");
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
function resolveLiveProviderCatalogTimeoutMs(env: NodeJS.ProcessEnv): number | null {
const live =
env.OPENCLAW_LIVE_TEST === "1" || env.OPENCLAW_LIVE_GATEWAY === "1" || env.LIVE === "1";
if (!live) {
return null;
}
const raw = env.OPENCLAW_LIVE_PROVIDER_DISCOVERY_TIMEOUT_MS?.trim();
if (!raw) {
return 15_000;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15_000;
}
function resolveLiveProviderDiscoveryFilter(env: NodeJS.ProcessEnv): string[] | undefined {
const live =
env.OPENCLAW_LIVE_TEST === "1" || env.OPENCLAW_LIVE_GATEWAY === "1" || env.LIVE === "1";
if (!live) {
return undefined;
}
const raw = env.OPENCLAW_LIVE_PROVIDERS?.trim();
if (!raw || raw === "all") {
return undefined;
}
const ids = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}
function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim();
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
@@ -662,10 +695,12 @@ async function resolvePluginImplicitProviders(
ctx: ImplicitProviderContext,
order: import("../plugins/types.js").ProviderDiscoveryOrder,
): Promise<Record<string, ProviderConfig> | undefined> {
const onlyPluginIds = resolveLiveProviderDiscoveryFilter(ctx.env);
const providers = resolvePluginDiscoveryProviders({
config: ctx.config,
workspaceDir: ctx.workspaceDir,
env: ctx.env,
onlyPluginIds,
});
const byOrder = groupPluginDiscoveryProvidersByOrder(providers);
const discovered: Record<string, ProviderConfig> = {};
@@ -683,7 +718,7 @@ async function resolvePluginImplicitProviders(
}
: (ctx.config ?? {});
for (const provider of byOrder[order]) {
const result = await runProviderCatalog({
const catalogRun = runProviderCatalog({
provider,
config: catalogConfig,
agentDir: ctx.agentDir,
@@ -694,6 +729,35 @@ async function resolvePluginImplicitProviders(
resolveProviderAuth: (providerId, options) =>
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
});
const timeoutMs = resolveLiveProviderCatalogTimeoutMs(ctx.env);
let result: Awaited<ReturnType<typeof runProviderCatalog>>;
if (!timeoutMs) {
result = await catalogRun;
} else {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
result = await Promise.race([
catalogRun,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`provider catalog timed out after ${timeoutMs}ms: ${provider.id}`));
}, timeoutMs);
timer.unref?.();
}),
]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("provider catalog timed out after")) {
log.warn(`${message}; skipping provider discovery`);
continue;
}
throw error;
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
mergeImplicitProviderSet(
discovered,
normalizePluginDiscoveryResult({

View File

@@ -20,6 +20,10 @@ const LIVE = isLiveTestEnabled();
const DIRECT_ENABLED = Boolean(process.env.OPENCLAW_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled();
const LIVE_HEARTBEAT_MS = Math.max(1_000, toInt(process.env.OPENCLAW_LIVE_HEARTBEAT_MS, 30_000));
const LIVE_SETUP_TIMEOUT_MS = Math.max(
1_000,
toInt(process.env.OPENCLAW_LIVE_SETUP_TIMEOUT_MS, 45_000),
);
const describeLive = LIVE ? describe : describe.skip;
@@ -69,6 +73,32 @@ async function withLiveHeartbeat<T>(operation: Promise<T>, context: string): Pro
}
}
async function withLiveStageTimeout<T>(
operation: Promise<T>,
context: string,
timeoutMs = LIVE_SETUP_TIMEOUT_MS,
): Promise<T> {
let hardTimer: ReturnType<typeof setTimeout> | undefined;
try {
return await withLiveHeartbeat(
Promise.race([
operation,
new Promise<never>((_, reject) => {
hardTimer = setTimeout(() => {
reject(new Error(`${context} timed out after ${timeoutMs}ms`));
}, timeoutMs);
hardTimer.unref?.();
}),
]),
context,
);
} finally {
if (hardTimer) {
clearTimeout(hardTimer);
}
}
}
function formatFailurePreview(
failures: Array<{ model: string; error: string }>,
maxItems: number,
@@ -341,8 +371,16 @@ describeLive("live models (profile keys)", () => {
it(
"completes across selected models",
async () => {
const cfg = loadConfig();
await ensureOpenClawModelsJson(cfg);
logProgress("[live-models] loading config");
const cfg = await withLiveStageTimeout(
Promise.resolve().then(() => loadConfig()),
"[live-models] load config",
);
logProgress("[live-models] preparing models.json");
await withLiveStageTimeout(
ensureOpenClawModelsJson(cfg),
"[live-models] prepare models.json",
);
if (!DIRECT_ENABLED) {
logProgress(
"[live-models] skipping (set OPENCLAW_LIVE_MODELS=modern|all|<list>; all=modern)",
@@ -357,8 +395,11 @@ describeLive("live models (profile keys)", () => {
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const models = modelRegistry.getAll();
logProgress("[live-models] loading model registry");
const models = await withLiveStageTimeout(
Promise.resolve().then(() => discoverModels(authStorage, agentDir).getAll()),
"[live-models] load model registry",
);
const rawModels = process.env.OPENCLAW_LIVE_MODELS?.trim();
const useModern = rawModels === "modern" || rawModels === "all";

View File

@@ -1,3 +1,5 @@
import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
import { buildSingleProviderApiKeyCatalog } from "../plugins/provider-catalog.js";
import type { ProviderPlugin, ProviderPluginWizardSetup } from "../plugins/types.js";
import { definePluginEntry } from "./plugin-entry.js";
import type {
@@ -5,8 +7,6 @@ import type {
OpenClawPluginConfigSchema,
OpenClawPluginDefinition,
} from "./plugin-entry.js";
import { createProviderApiKeyAuthMethod } from "./provider-auth.js";
import { buildSingleProviderApiKeyCatalog } from "./provider-catalog.js";
type ApiKeyAuthMethodOptions = Parameters<typeof createProviderApiKeyAuthMethod>[0];

View File

@@ -1020,6 +1020,30 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
clearPluginCommands();
});
it("can scope bundled provider loads to deepseek without hanging", () => {
if (prevBundledDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir;
}
const scoped = loadOpenClawPlugins({
cache: false,
activate: false,
config: {
plugins: {
enabled: true,
allow: ["deepseek"],
},
},
onlyPluginIds: ["deepseek"],
});
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["deepseek"]);
expect(scoped.plugins[0]?.status).toBe("loaded");
expect(scoped.providers.map((entry) => entry.provider.id)).toEqual(["deepseek"]);
});
it("does not replace the active memory prompt section during non-activating loads", () => {
useNoBundledPlugins();
registerMemoryPromptSection(() => ["active memory section"]);

View File

@@ -14,6 +14,7 @@ export function resolvePluginDiscoveryProviders(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): ProviderPlugin[] {
return resolvePluginProviders({
...params,

View File

@@ -10,6 +10,27 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
};
});
vi.mock("@mariozechner/clipboard", () => ({
availableFormats: () => [],
getText: async () => "",
setText: async () => {},
hasText: () => false,
getImageBinary: async () => [],
getImageBase64: async () => "",
setImageBinary: async () => {},
setImageBase64: async () => {},
hasImage: () => false,
getHtml: async () => "",
setHtml: async () => {},
hasHtml: () => false,
getRtf: async () => "",
setRtf: async () => {},
hasRtf: () => false,
clear: async () => {},
watch: () => {},
callThreadsafeFunction: () => {},
}));
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
// Config validation walks plugin manifests; keep an aggressive cache in tests to avoid