test(models-config): move merge coverage to fast helper seams

This commit is contained in:
Peter Steinberger
2026-04-05 20:33:52 +01:00
parent 6424b08772
commit 4da7b453b1
4 changed files with 283 additions and 601 deletions

View File

@@ -20,22 +20,52 @@ export function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise
});
}
export function installModelsConfigTestHooks(opts?: { restoreFetch?: boolean }) {
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;
resetPluginLoaderTestStateForTest();
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;
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}
resetModelsJsonReadyCacheForTest();
resetProviderRuntimeHookCacheForTest();
if (shouldResetProviderRuntimeHookCache) {
resetProviderRuntimeHookCacheForTest();
}
});
afterEach(() => {
process.env.HOME = previousHome;
resetPluginLoaderTestStateForTest();
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;
}
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}
resetModelsJsonReadyCacheForTest();
resetProviderRuntimeHookCacheForTest();
if (shouldResetProviderRuntimeHookCache) {
resetProviderRuntimeHookCacheForTest();
}
if (opts?.restoreFetch && originalFetch) {
globalThis.fetch = originalFetch;
}

View File

@@ -1,604 +1,29 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { validateConfigObject } from "../config/validation.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import {
CUSTOM_PROXY_MODELS_CONFIG,
installModelsConfigTestHooks,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
const MODELS_JSON_NAME = "models.json";
async function withEnvVar(name: string, value: string, run: () => Promise<void>) {
const previous = process.env[name];
process.env[name] = value;
try {
await run();
} finally {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
}
}
async function writeAgentModelsJson(content: unknown): Promise<void> {
const agentDir = resolveOpenClawAgentDir();
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, MODELS_JSON_NAME),
JSON.stringify(content, null, 2),
"utf8",
);
}
function createMergeConfigProvider() {
return {
baseUrl: "https://config.example/v1",
apiKey: "CONFIG_KEY", // pragma: allowlist secret
api: "openai-responses" as const,
models: [
{
id: "config-model",
name: "Config model",
input: ["text"] as Array<"text" | "image">,
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
};
}
type MergeSeedProvider = {
baseUrl: string;
apiKey: string;
api: string;
models: Array<{ id: string; name: string; input: string[]; api?: string }>;
};
type MergeConfigApiKeyRef = {
source: "env";
provider: "default";
id: string;
};
function createAgentSeedProvider(overrides: Partial<MergeSeedProvider> = {}): MergeSeedProvider {
return {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
...overrides,
};
}
async function runCustomProviderMergeTest(params: {
seedProvider: MergeSeedProvider;
existingProviderKey?: string;
configProviderKey?: string;
}) {
const existingProviderKey = params.existingProviderKey ?? "custom";
const configProviderKey = params.configProviderKey ?? "custom";
await writeAgentModelsJson({ providers: { [existingProviderKey]: params.seedProvider } });
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {
[configProviderKey]: createMergeConfigProvider(),
},
},
});
return readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>();
}
async function expectCustomProviderMergeResult(params: {
seedProvider?: MergeSeedProvider;
existingProviderKey?: string;
configProviderKey?: string;
expectedApiKey: string;
expectedBaseUrl: string;
}) {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
seedProvider: params.seedProvider ?? createAgentSeedProvider(),
existingProviderKey: params.existingProviderKey,
configProviderKey: params.configProviderKey,
});
expect(parsed.providers.custom?.apiKey).toBe(params.expectedApiKey);
expect(parsed.providers.custom?.baseUrl).toBe(params.expectedBaseUrl);
});
}
async function expectCustomProviderApiKeyRewrite(params: {
existingApiKey: string;
configuredApiKey: string | MergeConfigApiKeyRef;
expectedApiKey: string;
}) {
await withTempHome(async () => {
await writeAgentModelsJson({
providers: {
custom: createAgentSeedProvider({ apiKey: params.existingApiKey }),
},
});
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {
custom: {
...createMergeConfigProvider(),
apiKey: params.configuredApiKey,
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>();
expect(parsed.providers.custom?.apiKey).toBe(params.expectedApiKey);
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
}
function createMoonshotConfig(overrides: {
contextWindow: number;
maxTokens: number;
}): OpenClawConfig {
return {
models: {
providers: {
moonshot: {
baseUrl: "https://api.moonshot.ai/v1",
api: "openai-completions",
models: [
{
id: "kimi-k2.5",
name: "Kimi K2.5",
reasoning: false,
input: ["text"],
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
contextWindow: overrides.contextWindow,
maxTokens: overrides.maxTokens,
},
],
},
},
},
};
}
function createOpenAiConfigWithResolvedApiKey(mergeMode = false): OpenClawConfig {
return {
models: {
...(mergeMode ? { mode: "merge" as const } : {}),
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-plaintext-should-not-appear", // pragma: allowlist secret; simulates resolved ${OPENAI_API_KEY}
api: "openai-completions",
models: [
{
id: "gpt-4.1",
name: "GPT-4.1",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
},
],
},
},
},
};
}
async function expectOpenAiEnvMarkerApiKey(options?: { seedMergedProvider?: boolean }) {
await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => {
await withTempHome(async () => {
if (options?.seedMergedProvider) {
await writeAgentModelsJson({
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret
api: "openai-completions",
models: [{ id: "gpt-4.1", name: "GPT-4.1", input: ["text"] }],
},
},
});
}
await ensureOpenClawModelsJson(
createOpenAiConfigWithResolvedApiKey(options?.seedMergedProvider),
);
const result = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
});
});
}
async function expectMoonshotTokenLimits(params: {
contextWindow: number;
maxTokens: number;
expectedContextWindow: number;
expectedMaxTokens: number;
}) {
await withTempHome(async () => {
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
await ensureOpenClawModelsJson(
createMoonshotConfig({
contextWindow: params.contextWindow,
maxTokens: params.maxTokens,
}),
);
const parsed = await readGeneratedModelsJson<{
providers: Record<
string,
{
models?: Array<{
id: string;
contextWindow?: number;
maxTokens?: number;
}>;
}
>;
}>();
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
expect(kimi?.contextWindow).toBe(params.expectedContextWindow);
expect(kimi?.maxTokens).toBe(params.expectedMaxTokens);
});
});
}
import { resolveMissingProviderApiKey } from "./models-config.providers.secrets.js";
describe("models-config", () => {
it("keeps anthropic api defaults when model entries omit api", async () => {
await withTempHome(async () => {
const validated = validateConfigObject({
models: {
providers: {
anthropic: {
baseUrl: "https://relay.example.com/api",
apiKey: "cr_xxxx", // pragma: allowlist secret
models: [{ id: "claude-opus-4-6", name: "Claude Opus 4.6" }],
},
},
},
});
expect(validated.ok).toBe(true);
if (!validated.ok) {
throw new Error("expected config to validate");
}
await ensureOpenClawModelsJson(validated.config);
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { api?: string; models?: Array<{ id: string; api?: string }> }>;
}>();
expect(parsed.providers.anthropic?.api).toBe("anthropic-messages");
expect(parsed.providers.anthropic?.models?.[0]?.api).toBe("anthropic-messages");
});
});
it("fills missing provider.apiKey from env var name when models exist", async () => {
await withTempHome(async () => {
await withEnvVar("MINIMAX_API_KEY", "sk-minimax-test", async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
};
await ensureOpenClawModelsJson(cfg);
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
}>();
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret
});
});
});
it("fills anthropic-vertex apiKey with the ADC sentinel when models exist", async () => {
await withTempHome(async () => {
const adcDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(adcDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
const previousCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
try {
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
await ensureOpenClawModelsJson({
models: {
providers: {
"anthropic-vertex": {
baseUrl: "https://us-central1-aiplatform.googleapis.com",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 64000,
},
],
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
expect(parsed.providers["anthropic-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
} finally {
if (previousCredentials === undefined) {
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
} else {
process.env.GOOGLE_APPLICATION_CREDENTIALS = previousCredentials;
}
await fs.rm(adcDir, { recursive: true, force: true });
}
});
});
it("merges providers by default", async () => {
await withTempHome(async () => {
await writeAgentModelsJson({
providers: {
existing: {
baseUrl: "http://localhost:1234/v1",
apiKey: "EXISTING_KEY", // pragma: allowlist secret
api: "openai-completions",
models: [
{
id: "existing-model",
name: "Existing",
api: "openai-completions",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
},
},
});
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { baseUrl?: string }>;
}>();
expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1");
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
});
});
it("preserves non-empty agent apiKey but lets explicit config baseUrl win in merge mode", async () => {
await expectCustomProviderMergeResult({
expectedApiKey: "AGENT_KEY",
expectedBaseUrl: "https://config.example/v1",
});
});
it("lets explicit config baseUrl win in merge mode when the config provider key is normalized", async () => {
await expectCustomProviderMergeResult({
existingProviderKey: "custom",
configProviderKey: " custom ",
expectedApiKey: "AGENT_KEY",
expectedBaseUrl: "https://config.example/v1",
});
});
it("replaces stale merged baseUrl when the provider api changes", async () => {
await expectCustomProviderMergeResult({
seedProvider: createAgentSeedProvider({ api: "openai-completions" }),
expectedApiKey: "AGENT_KEY",
expectedBaseUrl: "https://config.example/v1",
});
});
it("replaces stale merged baseUrl when only model-level apis change", async () => {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "",
models: [
{
id: "agent-model",
name: "Agent model",
input: ["text"],
api: "openai-completions",
},
],
},
});
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
});
it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => {
await expectCustomProviderApiKeyRewrite({
existingApiKey: "STALE_AGENT_KEY", // pragma: allowlist secret
configuredApiKey: {
source: "env",
provider: "default",
id: "CUSTOM_PROVIDER_API_KEY", // pragma: allowlist secret
},
expectedApiKey: "CUSTOM_PROVIDER_API_KEY", // pragma: allowlist secret
});
});
it("replaces stale merged apiKey when provider is SecretRef-managed via auth-profiles", async () => {
await withTempHome(async () => {
const agentDir = resolveOpenClawAgentDir();
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
it("fills missing provider.apiKey from env var name when models exist", () => {
const provider = resolveMissingProviderApiKey({
providerKey: "minimax",
provider: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
models: [
{
version: 1,
profiles: {
"minimax:default": {
type: "api_key",
provider: "minimax",
keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, // pragma: allowlist secret
},
},
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
null,
2,
)}\n`,
"utf8",
);
await writeAgentModelsJson({
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret
api: "anthropic-messages",
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", input: ["text"] }],
},
},
});
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret
],
},
env: { MINIMAX_API_KEY: "sk-minimax-test" } as NodeJS.ProcessEnv,
profileApiKey: undefined,
});
});
it("replaces stale non-env marker when provider transitions back to plaintext config", async () => {
await expectCustomProviderApiKeyRewrite({
existingApiKey: NON_ENV_SECRETREF_MARKER,
configuredApiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret
expectedApiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret
});
});
it("uses config apiKey/baseUrl when existing agent values are empty", async () => {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "",
apiKey: "",
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
});
expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
});
it("preserves explicit moonshot model capabilities when config already defines the model", async () => {
await withTempHome(async () => {
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
const cfg = createMoonshotConfig({ contextWindow: 1024, maxTokens: 256 });
await ensureOpenClawModelsJson(cfg);
const parsed = await readGeneratedModelsJson<{
providers: Record<
string,
{
models?: Array<{
id: string;
input?: string[];
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
cost?: { input?: number; output?: number };
}>;
}
>;
}>();
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
expect(kimi?.input).toEqual(["text"]);
expect(kimi?.reasoning).toBe(false);
expect(kimi?.contextWindow).toBe(1024);
expect(kimi?.maxTokens).toBe(256);
// Preserve explicit user pricing overrides when refreshing capabilities.
expect(kimi?.cost?.input).toBe(123);
expect(kimi?.cost?.output).toBe(456);
});
});
});
it("does not persist resolved env var value as plaintext in models.json", async () => {
await expectOpenAiEnvMarkerApiKey();
});
it("replaces stale merged apiKey when config key normalizes to a known env marker", async () => {
await expectOpenAiEnvMarkerApiKey({ seedMergedProvider: true });
});
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
await expectMoonshotTokenLimits({
contextWindow: 350000,
maxTokens: 16384,
expectedContextWindow: 350000,
expectedMaxTokens: 16384,
});
});
it("preserves explicit moonshot token limits even when they are invalid", async () => {
await expectMoonshotTokenLimits({
contextWindow: 0,
maxTokens: -1,
expectedContextWindow: 0,
expectedMaxTokens: -1,
});
expect(provider.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import {
mergeProviderModels,
mergeProviders,
@@ -9,6 +10,39 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js";
describe("models-config merge helpers", () => {
const preservedApiKey = "AGENT_KEY"; // pragma: allowlist secret
const configApiKey = "CONFIG_KEY"; // pragma: allowlist secret
function createConfigProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
baseUrl: "https://config.example/v1",
apiKey: configApiKey,
api: "openai-responses",
models: [
{
id: "config-model",
name: "Config model",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
...overrides,
} as ProviderConfig;
}
function createExistingProvider(
overrides: Partial<ExistingProviderConfig> = {},
): ExistingProviderConfig {
return {
baseUrl: "https://agent.example/v1",
apiKey: preservedApiKey,
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
...overrides,
} as ExistingProviderConfig;
}
it("refreshes implicit model metadata while preserving explicit reasoning overrides", () => {
const merged = mergeProviderModels(
@@ -33,6 +67,7 @@ describe("models-config merge helpers", () => {
name: "GPT-5.4",
input: ["image"],
reasoning: false,
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
contextWindow: 2_000_000,
maxTokens: 200_000,
},
@@ -45,6 +80,7 @@ describe("models-config merge helpers", () => {
id: "gpt-5.4",
input: ["text"],
reasoning: false,
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
contextWindow: 2_000_000,
maxTokens: 200_000,
}),
@@ -66,6 +102,66 @@ describe("models-config merge helpers", () => {
});
});
it("keeps existing providers alongside newly configured providers in merge mode", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
"custom-proxy": {
baseUrl: "http://localhost:4000/v1",
api: "openai-completions",
models: [],
} as ProviderConfig,
},
existingProviders: {
existing: {
baseUrl: "http://localhost:1234/v1",
apiKey: "EXISTING_KEY", // pragma: allowlist secret
api: "openai-completions",
models: [{ id: "existing-model", name: "Existing", input: ["text"] }],
} as ExistingProviderConfig,
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom-proxy"]),
});
expect(merged.existing?.baseUrl).toBe("http://localhost:1234/v1");
expect(merged["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
});
it("preserves non-empty existing apiKey while explicit baseUrl wins", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: createConfigProvider(),
},
existingProviders: {
custom: createExistingProvider(),
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom"]),
});
expect(merged.custom?.apiKey).toBe(preservedApiKey);
expect(merged.custom?.baseUrl).toBe("https://config.example/v1");
});
it("preserves existing apiKey after explicit provider key normalization", () => {
const normalized = mergeProviders({
explicit: {
" custom ": createConfigProvider(),
},
});
const merged = mergeWithExistingProviderSecrets({
nextProviders: normalized,
existingProviders: {
custom: createExistingProvider(),
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom"]),
});
expect(merged.custom?.apiKey).toBe(preservedApiKey);
expect(merged.custom?.baseUrl).toBe("https://config.example/v1");
});
it("preserves implicit provider headers when explicit config adds extra headers", () => {
const merged = mergeProviderModels(
{
@@ -129,6 +225,27 @@ describe("models-config merge helpers", () => {
);
});
it("replaces stale baseUrl when only model-level apis change", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: createConfigProvider({ api: "" }),
},
existingProviders: {
custom: createExistingProvider({
api: "",
models: [
{ id: "agent-model", name: "Agent model", input: ["text"], api: "openai-completions" },
],
}),
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom"]),
});
expect(merged.custom?.apiKey).toBe(preservedApiKey);
expect(merged.custom?.baseUrl).toBe("https://config.example/v1");
});
it("does not preserve stale plaintext apiKey when next entry is a marker", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
@@ -149,4 +266,41 @@ describe("models-config merge helpers", () => {
expect(merged.custom?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
});
it("does not preserve a stale non-env marker when config returns to plaintext", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: createConfigProvider({ apiKey: "ALLCAPS_SAMPLE" }), // pragma: allowlist secret
},
existingProviders: {
custom: createExistingProvider({
apiKey: NON_ENV_SECRETREF_MARKER,
}),
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom"]),
});
expect(merged.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); // pragma: allowlist secret
expect(merged.custom?.baseUrl).toBe("https://config.example/v1");
});
it("uses config apiKey/baseUrl when existing values are empty", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: createConfigProvider(),
},
existingProviders: {
custom: createExistingProvider({
apiKey: "",
baseUrl: "",
}),
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(["custom"]),
});
expect(merged.custom?.apiKey).toBe(configApiKey);
expect(merged.custom?.baseUrl).toBe("https://config.example/v1");
});
});

View File

@@ -1,12 +1,20 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import { normalizeProviders } from "./models-config.providers.normalize.js";
import { resolveApiKeyFromProfiles } from "./models-config.providers.secrets.js";
import { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js";
vi.mock("./models-config.providers.policy.runtime.js", () => ({
applyProviderNativeStreamingUsagePolicy: () => undefined,
normalizeProviderConfigPolicy: () => undefined,
resolveProviderConfigApiKeyPolicy: () => undefined,
}));
describe("normalizeProviders", () => {
it("trims provider keys so image models remain discoverable for custom providers", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
@@ -112,6 +120,71 @@ describe("normalizeProviders", () => {
}
});
it("normalizes SecretRef-managed provider apiKey values to env markers", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const secretRefManagedProviders = new Set<string>();
try {
const providers: NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]> = {
custom: {
baseUrl: "https://config.example/v1",
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" },
models: [{ id: "config-model", name: "Config model", input: ["text"] }],
},
};
const normalized = normalizeProviders({
providers,
agentDir,
secretRefManagedProviders,
});
expect(normalized?.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret
expect(secretRefManagedProviders.has("custom")).toBe(true);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("reads provider apiKey markers from auth-profiles env refs", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
try {
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
"minimax:default": {
type: "api_key",
provider: "minimax",
keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" },
},
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const resolved = resolveApiKeyFromProfiles({
provider: "minimax",
store,
env: process.env,
});
expect(resolved?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret
expect(resolved?.source).toBe("env-ref");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
try {