* initial commit * removes assesment from docs * resolves automated review comments * resolves lint , type , tests , refactors , and submits * solves : why do we have to lint the tests xD * adds greptile fixes * solves a type error * solves a ci error * refactors auths * solves a failing test after i pulled from main lol * solves a failing test after i pulled from main lol * resolves token naming issue to comply with better practices when using hf / huggingface * fixes curly lints ! * fixes failing tests for google api from main * solve merge conflicts * solve failing tests with a defensive check 'undefined' openrouterapi key * fix: preserve Hugging Face auth-choice intent and token behavior (#13472) (thanks @Josephrp) * test: resolve auth-choice cherry-pick conflict cleanup (#13472) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
719 lines
23 KiB
TypeScript
719 lines
23 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { describe, expect, it } from "vitest";
|
|
import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js";
|
|
|
|
type RuntimeMock = {
|
|
log: () => void;
|
|
error: (msg: string) => never;
|
|
exit: (code: number) => never;
|
|
};
|
|
|
|
type EnvSnapshot = {
|
|
home: string | undefined;
|
|
stateDir: string | undefined;
|
|
configPath: string | undefined;
|
|
skipChannels: string | undefined;
|
|
skipGmail: string | undefined;
|
|
skipCron: string | undefined;
|
|
skipCanvas: string | undefined;
|
|
token: string | undefined;
|
|
password: string | undefined;
|
|
customApiKey: string | undefined;
|
|
disableConfigCache: string | undefined;
|
|
};
|
|
|
|
type OnboardEnv = {
|
|
configPath: string;
|
|
runtime: RuntimeMock;
|
|
};
|
|
|
|
async function removeDirWithRetry(dir: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
return;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
const isTransient = code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM";
|
|
if (!isTransient || attempt === 4) {
|
|
throw error;
|
|
}
|
|
await delay(25 * (attempt + 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
function captureEnv(): EnvSnapshot {
|
|
return {
|
|
home: process.env.HOME,
|
|
stateDir: process.env.OPENCLAW_STATE_DIR,
|
|
configPath: process.env.OPENCLAW_CONFIG_PATH,
|
|
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
|
|
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
|
|
skipCron: process.env.OPENCLAW_SKIP_CRON,
|
|
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
|
|
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
|
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
|
|
customApiKey: process.env.CUSTOM_API_KEY,
|
|
disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE,
|
|
};
|
|
}
|
|
|
|
function restoreEnvVar(key: keyof NodeJS.ProcessEnv, value: string | undefined): void {
|
|
if (value == null) {
|
|
delete process.env[key];
|
|
return;
|
|
}
|
|
process.env[key] = value;
|
|
}
|
|
|
|
function restoreEnv(prev: EnvSnapshot): void {
|
|
restoreEnvVar("HOME", prev.home);
|
|
restoreEnvVar("OPENCLAW_STATE_DIR", prev.stateDir);
|
|
restoreEnvVar("OPENCLAW_CONFIG_PATH", prev.configPath);
|
|
restoreEnvVar("OPENCLAW_SKIP_CHANNELS", prev.skipChannels);
|
|
restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", prev.skipGmail);
|
|
restoreEnvVar("OPENCLAW_SKIP_CRON", prev.skipCron);
|
|
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas);
|
|
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token);
|
|
restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password);
|
|
restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey);
|
|
restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache);
|
|
}
|
|
|
|
async function withOnboardEnv(
|
|
prefix: string,
|
|
run: (ctx: OnboardEnv) => Promise<void>,
|
|
): Promise<void> {
|
|
const prev = captureEnv();
|
|
|
|
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
|
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
|
process.env.OPENCLAW_SKIP_CRON = "1";
|
|
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
|
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
|
delete process.env.CUSTOM_API_KEY;
|
|
|
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
const configPath = path.join(tempHome, "openclaw.json");
|
|
process.env.HOME = tempHome;
|
|
process.env.OPENCLAW_STATE_DIR = tempHome;
|
|
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
|
|
|
const runtime: RuntimeMock = {
|
|
log: () => {},
|
|
error: (msg: string) => {
|
|
throw new Error(msg);
|
|
},
|
|
exit: (code: number) => {
|
|
throw new Error(`exit:${code}`);
|
|
},
|
|
};
|
|
|
|
try {
|
|
await run({ configPath, runtime });
|
|
} finally {
|
|
await removeDirWithRetry(tempHome);
|
|
restoreEnv(prev);
|
|
}
|
|
}
|
|
|
|
async function runNonInteractive(
|
|
options: Record<string, unknown>,
|
|
runtime: RuntimeMock,
|
|
): Promise<void> {
|
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
|
await runNonInteractiveOnboarding(options, runtime);
|
|
}
|
|
|
|
async function readJsonFile<T>(filePath: string): Promise<T> {
|
|
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
}
|
|
|
|
async function expectApiKeyProfile(params: {
|
|
profileId: string;
|
|
provider: string;
|
|
key: string;
|
|
metadata?: Record<string, string>;
|
|
}): Promise<void> {
|
|
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js");
|
|
const store = ensureAuthProfileStore();
|
|
const profile = store.profiles[params.profileId];
|
|
expect(profile?.type).toBe("api_key");
|
|
if (profile?.type === "api_key") {
|
|
expect(profile.provider).toBe(params.provider);
|
|
expect(profile.key).toBe(params.key);
|
|
if (params.metadata) {
|
|
expect(profile.metadata).toEqual(params.metadata);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("onboard (non-interactive): provider auth", () => {
|
|
it("stores Z.AI API key and uses global baseUrl by default", async () => {
|
|
await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "zai-api-key",
|
|
zaiApiKey: "zai-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
models?: { providers?: Record<string, { baseUrl?: string }> };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai");
|
|
expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key");
|
|
expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/paas/v4");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5");
|
|
await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" });
|
|
});
|
|
}, 60_000);
|
|
|
|
it("supports Z.AI CN coding endpoint auth choice", async () => {
|
|
await withOnboardEnv("openclaw-onboard-zai-cn-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "zai-coding-cn",
|
|
zaiApiKey: "zai-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
models?: { providers?: Record<string, { baseUrl?: string }> };
|
|
}>(configPath);
|
|
|
|
expect(cfg.models?.providers?.zai?.baseUrl).toBe(
|
|
"https://open.bigmodel.cn/api/coding/paas/v4",
|
|
);
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores xAI API key and sets default model", async () => {
|
|
await withOnboardEnv("openclaw-onboard-xai-", async ({ configPath, runtime }) => {
|
|
const rawKey = "xai-test-\r\nkey";
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "xai-api-key",
|
|
xaiApiKey: rawKey,
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["xai:default"]?.provider).toBe("xai");
|
|
expect(cfg.auth?.profiles?.["xai:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4");
|
|
await expectApiKeyProfile({ profileId: "xai:default", provider: "xai", key: "xai-test-key" });
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores Vercel AI Gateway API key and sets default model", async () => {
|
|
await withOnboardEnv("openclaw-onboard-ai-gateway-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "ai-gateway-api-key",
|
|
aiGatewayApiKey: "gateway-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.provider).toBe("vercel-ai-gateway");
|
|
expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe(
|
|
"vercel-ai-gateway/anthropic/claude-opus-4.6",
|
|
);
|
|
await expectApiKeyProfile({
|
|
profileId: "vercel-ai-gateway:default",
|
|
provider: "vercel-ai-gateway",
|
|
key: "gateway-test-key",
|
|
});
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores token auth profile", async () => {
|
|
await withOnboardEnv("openclaw-onboard-token-", async ({ configPath, runtime }) => {
|
|
const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`;
|
|
const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`;
|
|
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "token",
|
|
tokenProvider: "anthropic",
|
|
token,
|
|
tokenProfileId: "anthropic:default",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic");
|
|
expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token");
|
|
|
|
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js");
|
|
const store = ensureAuthProfileStore();
|
|
const profile = store.profiles["anthropic:default"];
|
|
expect(profile?.type).toBe("token");
|
|
if (profile?.type === "token") {
|
|
expect(profile.provider).toBe("anthropic");
|
|
expect(profile.token).toBe(cleanToken);
|
|
}
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores OpenAI API key and sets OpenAI default model", async () => {
|
|
await withOnboardEnv("openclaw-onboard-openai-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "openai-api-key",
|
|
openaiApiKey: "sk-openai-test",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL);
|
|
});
|
|
}, 60_000);
|
|
|
|
it("rejects vLLM auth choice in non-interactive mode", async () => {
|
|
await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => {
|
|
await expect(
|
|
runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "vllm",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
),
|
|
).rejects.toThrow('Auth choice "vllm" requires interactive mode.');
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores LiteLLM API key and sets default model", async () => {
|
|
await withOnboardEnv("openclaw-onboard-litellm-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "litellm-api-key",
|
|
litellmApiKey: "litellm-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["litellm:default"]?.provider).toBe("litellm");
|
|
expect(cfg.auth?.profiles?.["litellm:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("litellm/claude-opus-4-6");
|
|
await expectApiKeyProfile({
|
|
profileId: "litellm:default",
|
|
provider: "litellm",
|
|
key: "litellm-test-key",
|
|
});
|
|
});
|
|
}, 60_000);
|
|
|
|
it("stores Cloudflare AI Gateway API key and metadata", async () => {
|
|
await withOnboardEnv("openclaw-onboard-cf-gateway-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "cloudflare-ai-gateway-api-key",
|
|
cloudflareAiGatewayAccountId: "cf-account-id",
|
|
cloudflareAiGatewayGatewayId: "cf-gateway-id",
|
|
cloudflareAiGatewayApiKey: "cf-gateway-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe(
|
|
"cloudflare-ai-gateway",
|
|
);
|
|
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5");
|
|
await expectApiKeyProfile({
|
|
profileId: "cloudflare-ai-gateway:default",
|
|
provider: "cloudflare-ai-gateway",
|
|
key: "cf-gateway-test-key",
|
|
metadata: { accountId: "cf-account-id", gatewayId: "cf-gateway-id" },
|
|
});
|
|
});
|
|
}, 60_000);
|
|
|
|
it("infers Cloudflare auth choice from API key flags", async () => {
|
|
await withOnboardEnv("openclaw-onboard-cf-gateway-infer-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
cloudflareAiGatewayAccountId: "cf-account-id",
|
|
cloudflareAiGatewayGatewayId: "cf-gateway-id",
|
|
cloudflareAiGatewayApiKey: "cf-gateway-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe(
|
|
"cloudflare-ai-gateway",
|
|
);
|
|
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5");
|
|
await expectApiKeyProfile({
|
|
profileId: "cloudflare-ai-gateway:default",
|
|
provider: "cloudflare-ai-gateway",
|
|
key: "cf-gateway-test-key",
|
|
metadata: { accountId: "cf-account-id", gatewayId: "cf-gateway-id" },
|
|
});
|
|
});
|
|
}, 60_000);
|
|
|
|
it("infers Together auth choice from --together-api-key and sets default model", async () => {
|
|
await withOnboardEnv("openclaw-onboard-together-infer-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
togetherApiKey: "together-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.auth?.profiles?.["together:default"]?.provider).toBe("together");
|
|
expect(cfg.auth?.profiles?.["together:default"]?.mode).toBe("api_key");
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("together/moonshotai/Kimi-K2.5");
|
|
await expectApiKeyProfile({
|
|
profileId: "together:default",
|
|
provider: "together",
|
|
key: "together-test-key",
|
|
});
|
|
});
|
|
}, 60_000);
|
|
|
|
it("configures a custom provider from non-interactive flags", async () => {
|
|
await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "custom-api-key",
|
|
customBaseUrl: "https://llm.example.com/v1",
|
|
customApiKey: "custom-test-key",
|
|
customModelId: "foo-large",
|
|
customCompatibility: "anthropic",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
models?: {
|
|
providers?: Record<
|
|
string,
|
|
{
|
|
baseUrl?: string;
|
|
api?: string;
|
|
apiKey?: string;
|
|
models?: Array<{ id?: string }>;
|
|
}
|
|
>;
|
|
};
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
const provider = cfg.models?.providers?.["custom-llm-example-com"];
|
|
expect(provider?.baseUrl).toBe("https://llm.example.com/v1");
|
|
expect(provider?.api).toBe("anthropic-messages");
|
|
expect(provider?.apiKey).toBe("custom-test-key");
|
|
expect(provider?.models?.some((model) => model.id === "foo-large")).toBe(true);
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("custom-llm-example-com/foo-large");
|
|
});
|
|
}, 60_000);
|
|
|
|
it("infers custom provider auth choice from custom flags", async () => {
|
|
await withOnboardEnv(
|
|
"openclaw-onboard-custom-provider-infer-",
|
|
async ({ configPath, runtime }) => {
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
customBaseUrl: "https://models.custom.local/v1",
|
|
customModelId: "local-large",
|
|
customApiKey: "custom-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
models?: {
|
|
providers?: Record<
|
|
string,
|
|
{
|
|
baseUrl?: string;
|
|
api?: string;
|
|
}
|
|
>;
|
|
};
|
|
agents?: { defaults?: { model?: { primary?: string } } };
|
|
}>(configPath);
|
|
|
|
expect(cfg.models?.providers?.["custom-models-custom-local"]?.baseUrl).toBe(
|
|
"https://models.custom.local/v1",
|
|
);
|
|
expect(cfg.models?.providers?.["custom-models-custom-local"]?.api).toBe(
|
|
"openai-completions",
|
|
);
|
|
expect(cfg.agents?.defaults?.model?.primary).toBe("custom-models-custom-local/local-large");
|
|
},
|
|
);
|
|
}, 60_000);
|
|
|
|
it("uses CUSTOM_API_KEY env fallback for non-interactive custom provider auth", async () => {
|
|
await withOnboardEnv(
|
|
"openclaw-onboard-custom-provider-env-fallback-",
|
|
async ({ configPath, runtime }) => {
|
|
process.env.CUSTOM_API_KEY = "custom-env-key";
|
|
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "custom-api-key",
|
|
customBaseUrl: "https://models.custom.local/v1",
|
|
customModelId: "local-large",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
models?: {
|
|
providers?: Record<
|
|
string,
|
|
{
|
|
apiKey?: string;
|
|
}
|
|
>;
|
|
};
|
|
}>(configPath);
|
|
|
|
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
|
"custom-env-key",
|
|
);
|
|
},
|
|
);
|
|
}, 60_000);
|
|
|
|
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
|
|
await withOnboardEnv(
|
|
"openclaw-onboard-custom-provider-profile-fallback-",
|
|
async ({ configPath, runtime }) => {
|
|
const { upsertAuthProfile } = await import("../agents/auth-profiles.js");
|
|
upsertAuthProfile({
|
|
profileId: "custom-models-custom-local:default",
|
|
credential: {
|
|
type: "api_key",
|
|
provider: "custom-models-custom-local",
|
|
key: "custom-profile-key",
|
|
},
|
|
});
|
|
|
|
await runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "custom-api-key",
|
|
customBaseUrl: "https://models.custom.local/v1",
|
|
customModelId: "local-large",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const cfg = await readJsonFile<{
|
|
models?: {
|
|
providers?: Record<
|
|
string,
|
|
{
|
|
apiKey?: string;
|
|
}
|
|
>;
|
|
};
|
|
}>(configPath);
|
|
|
|
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
|
"custom-profile-key",
|
|
);
|
|
},
|
|
);
|
|
}, 60_000);
|
|
|
|
it("fails custom provider auth when compatibility is invalid", async () => {
|
|
await withOnboardEnv(
|
|
"openclaw-onboard-custom-provider-invalid-compat-",
|
|
async ({ runtime }) => {
|
|
await expect(
|
|
runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "custom-api-key",
|
|
customBaseUrl: "https://models.custom.local/v1",
|
|
customModelId: "local-large",
|
|
customCompatibility: "xmlrpc",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
),
|
|
).rejects.toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
|
|
},
|
|
);
|
|
}, 60_000);
|
|
|
|
it("fails custom provider auth when explicit provider id is invalid", async () => {
|
|
await withOnboardEnv("openclaw-onboard-custom-provider-invalid-id-", async ({ runtime }) => {
|
|
await expect(
|
|
runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
authChoice: "custom-api-key",
|
|
customBaseUrl: "https://models.custom.local/v1",
|
|
customModelId: "local-large",
|
|
customProviderId: "!!!",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
),
|
|
).rejects.toThrow(
|
|
"Invalid custom provider config: Custom provider ID must include letters, numbers, or hyphens.",
|
|
);
|
|
});
|
|
}, 60_000);
|
|
|
|
it("fails inferred custom auth when required flags are incomplete", async () => {
|
|
await withOnboardEnv(
|
|
"openclaw-onboard-custom-provider-missing-required-",
|
|
async ({ runtime }) => {
|
|
await expect(
|
|
runNonInteractive(
|
|
{
|
|
nonInteractive: true,
|
|
customApiKey: "custom-test-key",
|
|
skipHealth: true,
|
|
skipChannels: true,
|
|
skipSkills: true,
|
|
json: true,
|
|
},
|
|
runtime,
|
|
),
|
|
).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
|
|
},
|
|
);
|
|
}, 60_000);
|
|
});
|