test: scope implicit provider discovery harness

This commit is contained in:
Peter Steinberger
2026-04-05 11:12:19 +01:00
parent be5a2611b9
commit fd917a471c
4 changed files with 170 additions and 16 deletions

View File

@@ -1,9 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { resetModelsJsonReadyCacheForTest } from "./models-config.js";
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
@@ -94,6 +97,7 @@ export async function withCopilotGithubToken<T>(
}
export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS",
"VITEST",
"NODE_ENV",
"AI_GATEWAY_API_KEY",
@@ -147,6 +151,53 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"AWS_SHARED_CREDENTIALS_FILE",
];
const TEST_PROVIDER_ENV_TO_PROVIDER_IDS: Record<string, string[]> = {
AI_GATEWAY_API_KEY: ["vercel-ai-gateway"],
ANTHROPIC_VERTEX_PROJECT_ID: ["anthropic-vertex"],
ANTHROPIC_VERTEX_USE_GCP_METADATA: ["anthropic-vertex"],
AWS_ACCESS_KEY_ID: ["amazon-bedrock"],
AWS_BEARER_TOKEN_BEDROCK: ["amazon-bedrock"],
AWS_CONFIG_FILE: ["amazon-bedrock"],
AWS_DEFAULT_REGION: ["amazon-bedrock"],
AWS_PROFILE: ["amazon-bedrock"],
AWS_REGION: ["amazon-bedrock"],
AWS_SECRET_ACCESS_KEY: ["amazon-bedrock"],
AWS_SESSION_TOKEN: ["amazon-bedrock"],
AWS_SHARED_CREDENTIALS_FILE: ["amazon-bedrock"],
BYTEPLUS_API_KEY: ["byteplus"],
CLOUD_ML_REGION: ["anthropic-vertex"],
CLOUDFLARE_AI_GATEWAY_API_KEY: ["cloudflare-ai-gateway"],
COPILOT_GITHUB_TOKEN: ["github-copilot"],
GEMINI_API_KEY: ["google"],
GITHUB_TOKEN: ["github-copilot"],
GH_TOKEN: ["github-copilot"],
GOOGLE_APPLICATION_CREDENTIALS: ["anthropic-vertex"],
GOOGLE_CLOUD_LOCATION: ["anthropic-vertex"],
GOOGLE_CLOUD_PROJECT: ["anthropic-vertex"],
GOOGLE_CLOUD_PROJECT_ID: ["anthropic-vertex"],
HF_TOKEN: ["huggingface"],
HUGGINGFACE_HUB_TOKEN: ["huggingface"],
KILOCODE_API_KEY: ["kilocode"],
KIMI_API_KEY: ["moonshot"],
KIMICODE_API_KEY: ["kimi-coding"],
MINIMAX_API_KEY: ["minimax"],
MINIMAX_OAUTH_TOKEN: ["minimax"],
MODELSTUDIO_API_KEY: ["chutes"],
MOONSHOT_API_KEY: ["moonshot"],
NVIDIA_API_KEY: ["nvidia"],
OLLAMA_API_KEY: ["ollama"],
OPENAI_API_KEY: ["openai"],
OPENROUTER_API_KEY: ["openrouter"],
QIANFAN_API_KEY: ["qianfan"],
STEPFUN_API_KEY: ["stepfun"],
SYNTHETIC_API_KEY: ["custom-proxy"],
TOGETHER_API_KEY: ["together"],
VENICE_API_KEY: ["venice"],
VLLM_API_KEY: ["vllm"],
VOLCANO_ENGINE_API_KEY: ["volcengine"],
XIAOMI_API_KEY: ["xiaomi"],
};
export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const source = env ?? process.env;
const snapshot: NodeJS.ProcessEnv = {};
@@ -167,12 +218,103 @@ export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.Pro
return snapshot;
}
export function resolveImplicitProvidersForTest(
async function inferAuthProfileProviderIds(agentDir?: string): Promise<string[]> {
if (!agentDir) {
return [];
}
try {
const raw = await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { provider?: string }>;
order?: Record<string, unknown>;
};
const providers = new Set<string>();
for (const providerId of Object.keys(parsed.order ?? {})) {
if (providerId.trim()) {
providers.add(providerId.trim());
}
}
for (const profile of Object.values(parsed.profiles ?? {})) {
const providerId = profile?.provider?.trim();
if (providerId) {
providers.add(providerId);
}
}
return [...providers];
} catch {
return [];
}
}
async function inferImplicitProviderTestPluginIds(params: {
agentDir?: string;
config?: OpenClawConfig;
explicitProviders?: Record<string, unknown> | null;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
}): Promise<string[]> {
const providerIds = new Set<string>();
for (const providerId of Object.keys(params.config?.models?.providers ?? {})) {
if (providerId.trim()) {
providerIds.add(providerId.trim());
}
}
for (const providerId of Object.keys(params.explicitProviders ?? {})) {
if (providerId.trim()) {
providerIds.add(providerId.trim());
}
}
for (const [envVar, mappedProviderIds] of Object.entries(TEST_PROVIDER_ENV_TO_PROVIDER_IDS)) {
if (!params.env[envVar]?.trim()) {
continue;
}
for (const providerId of mappedProviderIds) {
providerIds.add(providerId);
}
}
for (const providerId of await inferAuthProfileProviderIds(params.agentDir)) {
providerIds.add(providerId);
}
if (providerIds.size === 0) {
// No config/env/auth hints: keep ambient local auto-discovery focused on the
// one provider that is expected to probe localhost in tests.
return ["ollama"];
}
const pluginIds = new Set<string>();
for (const providerId of providerIds) {
const owningPluginIds =
resolveOwningPluginIdsForProvider({
provider: providerId,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? [];
for (const pluginId of owningPluginIds) {
pluginIds.add(pluginId);
}
}
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
}
export async function resolveImplicitProvidersForTest(
params: Parameters<typeof resolveImplicitProviders>[0],
) {
const env = snapshotImplicitProviderEnv(params.env);
const inferredPluginIds = await inferImplicitProviderTestPluginIds({
agentDir: params.agentDir,
config: params.config,
explicitProviders: params.explicitProviders,
env,
workspaceDir: params.workspaceDir,
});
if (inferredPluginIds.length > 0) {
env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS = inferredPluginIds.join(",");
}
return resolveImplicitProviders({
...params,
env: snapshotImplicitProviderEnv(params.env),
env,
});
}

View File

@@ -64,7 +64,15 @@ function resolveLiveProviderCatalogTimeoutMs(env: NodeJS.ProcessEnv): number | n
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15_000;
}
function resolveLiveProviderDiscoveryFilter(env: NodeJS.ProcessEnv): string[] | undefined {
function resolveProviderDiscoveryFilter(env: NodeJS.ProcessEnv): string[] | undefined {
const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim();
if (testRaw) {
const ids = testRaw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}
const live =
env.OPENCLAW_LIVE_TEST === "1" || env.OPENCLAW_LIVE_GATEWAY === "1" || env.LIVE === "1";
if (!live) {
@@ -150,15 +158,9 @@ function resolveExistingImplicitProviderFromContext(params: {
async function resolvePluginImplicitProviders(
ctx: ImplicitProviderContext,
providers: import("../plugins/types.js").ProviderPlugin[],
order: import("../plugins/types.js").ProviderDiscoveryOrder,
): Promise<Record<string, ProviderConfig> | undefined> {
const onlyPluginIds = resolveLiveProviderDiscoveryFilter(ctx.env);
const providers = await resolvePluginDiscoveryProviders({
config: ctx.config,
workspaceDir: ctx.workspaceDir,
env: ctx.env,
onlyPluginIds,
});
const byOrder = groupPluginDiscoveryProvidersByOrder(providers);
const discovered: Record<string, ProviderConfig> = {};
const catalogConfig = buildPluginCatalogConfig(ctx);
@@ -309,9 +311,18 @@ export async function resolveImplicitProviders(
resolveProviderApiKey: createProviderApiKeyResolver(env, authStore, params.config),
resolveProviderAuth: createProviderAuthResolver(env, authStore, params.config),
};
const discoveryProviders = await resolvePluginDiscoveryProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: resolveProviderDiscoveryFilter(env),
});
for (const order of PLUGIN_DISCOVERY_ORDERS) {
mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, order));
mergeImplicitProviderSet(
providers,
await resolvePluginImplicitProviders(context, discoveryProviders, order),
);
}
return providers;

View File

@@ -55,7 +55,7 @@ describe("Ollama auto-discovery", () => {
}) as unknown as typeof fetch;
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProvidersForTest({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.ollama).toBeDefined();
expect(providers?.ollama?.apiKey).toBe("ollama-local");
@@ -73,7 +73,7 @@ describe("Ollama auto-discovery", () => {
mockOllamaUnreachable();
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProvidersForTest({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.ollama).toBeUndefined();
const ollamaWarnings = warnSpy.mock.calls.filter(
@@ -91,6 +91,7 @@ describe("Ollama auto-discovery", () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await resolveImplicitProvidersForTest({
agentDir,
env: {},
explicitProviders: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",

View File

@@ -61,7 +61,7 @@ describe("openai-codex implicit provider", () => {
});
});
it("keeps generated openai-codex rows on the generic OpenAI responses transport", async () => {
it("normalizes generated openai-codex rows back to the Codex transport when oauth exists", async () => {
await withModelsTempHome(async () => {
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
@@ -99,8 +99,8 @@ describe("openai-codex implicit provider", () => {
providers: Record<string, { baseUrl?: string; api?: string }>;
}>();
expect(parsed.providers["openai-codex"]).toMatchObject({
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",
});
});
});