Files
openclaw/src/agents/models-config.e2e-harness.ts
2026-04-08 19:00:24 +01:00

419 lines
13 KiB
TypeScript

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 { clearConfigCache, clearRuntimeConfigSnapshot } 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-state.js";
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
export function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
// Models-config tests do not exercise session persistence; skip draining
// unrelated session lock state during temp-home teardown.
return withTempHomeBase(fn, {
prefix: "openclaw-models-",
skipSessionCleanup: true,
});
}
export function installModelsConfigTestHooks(opts?: {
restoreFetch?: boolean;
resetPluginLoaderState?: boolean;
resetProviderRuntimeHookCache?: boolean;
}) {
let previousHome: string | undefined;
let previousOpenClawAgentDir: string | undefined;
let previousPiCodingAgentDir: string | undefined;
const originalFetch = globalThis.fetch;
const shouldResetPluginLoaderState = opts?.resetPluginLoaderState !== false;
const shouldResetProviderRuntimeHookCache = opts?.resetProviderRuntimeHookCache !== false;
beforeEach(() => {
previousHome = process.env.HOME;
previousOpenClawAgentDir = process.env.OPENCLAW_AGENT_DIR;
previousPiCodingAgentDir = process.env.PI_CODING_AGENT_DIR;
delete process.env.OPENCLAW_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
clearRuntimeConfigSnapshot();
clearConfigCache();
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}
resetModelsJsonReadyCacheForTest();
if (shouldResetProviderRuntimeHookCache) {
resetProviderRuntimeHookCacheForTest();
}
});
afterEach(() => {
process.env.HOME = previousHome;
if (previousOpenClawAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
} else {
process.env.OPENCLAW_AGENT_DIR = previousOpenClawAgentDir;
}
if (previousPiCodingAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiCodingAgentDir;
}
clearRuntimeConfigSnapshot();
clearConfigCache();
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}
resetModelsJsonReadyCacheForTest();
if (shouldResetProviderRuntimeHookCache) {
resetProviderRuntimeHookCacheForTest();
}
if (opts?.restoreFetch && originalFetch) {
globalThis.fetch = originalFetch;
}
});
}
export async function withTempEnv<T>(vars: string[], fn: () => Promise<T>): Promise<T> {
const previous: Record<string, string | undefined> = {};
for (const envVar of vars) {
previous[envVar] = process.env[envVar];
}
try {
return await fn();
} finally {
for (const envVar of vars) {
const value = previous[envVar];
if (value === undefined) {
delete process.env[envVar];
} else {
process.env[envVar] = value;
}
}
}
}
export function unsetEnv(vars: string[]) {
for (const envVar of vars) {
delete process.env[envVar];
}
}
export const COPILOT_TOKEN_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
const COPILOT_DISCOVERY_ENV_VARS = [
...COPILOT_TOKEN_ENV_VARS,
"OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS",
];
export async function withUnsetCopilotTokenEnv<T>(fn: () => Promise<T>): Promise<T> {
return withTempEnv(COPILOT_DISCOVERY_ENV_VARS, async () => {
unsetEnv(COPILOT_TOKEN_ENV_VARS);
process.env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS = "github-copilot";
return fn();
});
}
export function mockCopilotTokenExchangeSuccess(): MockFn {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
token: "copilot-token;proxy-ep=proxy.copilot.example",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}
export async function withCopilotGithubToken<T>(
token: string,
fn: (fetchMock: MockFn) => Promise<T>,
): Promise<T> {
return withTempEnv(COPILOT_DISCOVERY_ENV_VARS, async () => {
process.env.COPILOT_GITHUB_TOKEN = token;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
process.env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS = "github-copilot";
const fetchMock = mockCopilotTokenExchangeSuccess();
return fn(fetchMock);
});
}
export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS",
"VITEST",
"NODE_ENV",
"AI_GATEWAY_API_KEY",
"CLOUDFLARE_AI_GATEWAY_API_KEY",
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"HF_TOKEN",
"HUGGINGFACE_HUB_TOKEN",
"MINIMAX_API_KEY",
"MINIMAX_API_HOST",
"MINIMAX_OAUTH_TOKEN",
"MOONSHOT_API_KEY",
"NVIDIA_API_KEY",
"OLLAMA_API_KEY",
"OPENCLAW_AGENT_DIR",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"PI_CODING_AGENT_DIR",
"QIANFAN_API_KEY",
"QWEN_API_KEY",
"MODELSTUDIO_API_KEY",
"SYNTHETIC_API_KEY",
"STEPFUN_API_KEY",
"TOGETHER_API_KEY",
"VOLCANO_ENGINE_API_KEY",
"BYTEPLUS_API_KEY",
"CHUTES_API_KEY",
"CHUTES_OAUTH_TOKEN",
"KILOCODE_API_KEY",
"KIMI_API_KEY",
"KIMICODE_API_KEY",
"GEMINI_API_KEY",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_PROJECT_ID",
"ANTHROPIC_VERTEX_USE_GCP_METADATA",
"VENICE_API_KEY",
"VLLM_API_KEY",
"XIAOMI_API_KEY",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION",
// Avoid ambient AWS creds unintentionally enabling Bedrock discovery.
"AWS_ACCESS_KEY_ID",
"AWS_CONFIG_FILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_DEFAULT_REGION",
"AWS_PROFILE",
"AWS_REGION",
"AWS_SESSION_TOKEN",
"AWS_SECRET_ACCESS_KEY",
"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"],
CHUTES_API_KEY: ["chutes"],
CHUTES_OAUTH_TOKEN: ["chutes"],
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", "kimi"],
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 = {};
for (const envVar of MODELS_CONFIG_IMPLICIT_ENV_VARS) {
const value = source[envVar];
if (value !== undefined) {
snapshot[envVar] = value;
}
}
// Provider discovery tests can temporarily scrub VITEST/NODE_ENV to exercise
// live HTTP paths. Keep the bundled plugin root pinned to the source checkout
// so those tests do not fall back to potentially stale dist-runtime wrappers.
snapshot.VITEST ??= process.env.VITEST;
snapshot.NODE_ENV ??= process.env.NODE_ENV;
snapshot.OPENCLAW_BUNDLED_PLUGINS_DIR ??=
resolveBundledPluginsDir({ VITEST: "true" } as NodeJS.ProcessEnv) ?? undefined;
return snapshot;
}
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());
}
}
const legacyGrokApiKey =
params.config?.tools?.web?.search &&
typeof params.config.tools.web.search === "object" &&
"grok" in params.config.tools.web.search
? (params.config.tools.web.search.grok as { apiKey?: unknown } | undefined)?.apiKey
: undefined;
if (legacyGrokApiKey !== undefined && params.config?.plugins?.entries?.xai?.enabled !== false) {
providerIds.add("xai");
}
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);
}
for (const [pluginId, entry] of Object.entries(params.config?.plugins?.entries ?? {})) {
if (!pluginId.trim() || entry?.enabled === false) {
continue;
}
const pluginConfig =
entry.config && typeof entry.config === "object"
? (entry.config as { webSearch?: { apiKey?: unknown } })
: undefined;
if (pluginConfig?.webSearch?.apiKey !== undefined) {
providerIds.add(pluginId);
}
}
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,
});
}
export const CUSTOM_PROXY_MODELS_CONFIG: OpenClawConfig = {
models: {
providers: {
"custom-proxy": {
baseUrl: "http://localhost:4000/v1",
apiKey: "TEST_KEY",
api: "openai-completions",
models: [
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B (Proxy)",
api: "openai-completions",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
},
],
},
},
},
};