mirror of https://github.com/openclaw/openclaw.git
1332 lines
42 KiB
TypeScript
1332 lines
42 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
|
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
|
|
import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js";
|
|
import {
|
|
MINIMAX_CN_API_BASE_URL,
|
|
ZAI_CODING_CN_BASE_URL,
|
|
ZAI_CODING_GLOBAL_BASE_URL,
|
|
} from "./onboard-auth.js";
|
|
import type { AuthChoice } from "./onboard-types.js";
|
|
import {
|
|
authProfilePathForAgent,
|
|
createAuthTestLifecycle,
|
|
createExitThrowingRuntime,
|
|
createWizardPrompter,
|
|
readAuthProfilesForAgent,
|
|
requireOpenClawAgentDir,
|
|
setupAuthTestEnv,
|
|
} from "./test-wizard-helpers.js";
|
|
|
|
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
|
|
|
|
vi.mock("../providers/github-copilot-auth.js", () => ({
|
|
githubCopilotLoginCommand: vi.fn(async () => {}),
|
|
}));
|
|
|
|
const loginOpenAICodexOAuth = vi.hoisted(() =>
|
|
vi.fn<() => Promise<OAuthCredentials | null>>(async () => null),
|
|
);
|
|
vi.mock("./openai-codex-oauth.js", () => ({
|
|
loginOpenAICodexOAuth,
|
|
}));
|
|
|
|
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
|
|
vi.mock("../plugins/providers.js", () => ({
|
|
resolvePluginProviders,
|
|
}));
|
|
|
|
const detectZaiEndpoint = vi.hoisted(() => vi.fn<DetectZaiEndpoint>(async () => null));
|
|
vi.mock("./zai-endpoint-detect.js", () => ({
|
|
detectZaiEndpoint,
|
|
}));
|
|
|
|
type StoredAuthProfile = {
|
|
key?: string;
|
|
keyRef?: { source: string; provider: string; id: string };
|
|
access?: string;
|
|
refresh?: string;
|
|
provider?: string;
|
|
type?: string;
|
|
email?: string;
|
|
metadata?: Record<string, string>;
|
|
};
|
|
|
|
describe("applyAuthChoice", () => {
|
|
const lifecycle = createAuthTestLifecycle([
|
|
"OPENCLAW_STATE_DIR",
|
|
"OPENCLAW_AGENT_DIR",
|
|
"PI_CODING_AGENT_DIR",
|
|
"ANTHROPIC_API_KEY",
|
|
"OPENROUTER_API_KEY",
|
|
"HF_TOKEN",
|
|
"HUGGINGFACE_HUB_TOKEN",
|
|
"LITELLM_API_KEY",
|
|
"AI_GATEWAY_API_KEY",
|
|
"CLOUDFLARE_AI_GATEWAY_API_KEY",
|
|
"MOONSHOT_API_KEY",
|
|
"MISTRAL_API_KEY",
|
|
"KIMI_API_KEY",
|
|
"GEMINI_API_KEY",
|
|
"XIAOMI_API_KEY",
|
|
"VENICE_API_KEY",
|
|
"OPENCODE_API_KEY",
|
|
"TOGETHER_API_KEY",
|
|
"QIANFAN_API_KEY",
|
|
"SYNTHETIC_API_KEY",
|
|
"SSH_TTY",
|
|
"CHUTES_CLIENT_ID",
|
|
]);
|
|
let activeStateDir: string | null = null;
|
|
async function setupTempState() {
|
|
if (activeStateDir) {
|
|
await fs.rm(activeStateDir, { recursive: true, force: true });
|
|
}
|
|
const env = await setupAuthTestEnv("openclaw-auth-");
|
|
activeStateDir = env.stateDir;
|
|
lifecycle.setStateDir(env.stateDir);
|
|
}
|
|
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
|
return createWizardPrompter(overrides, { defaultSelect: "" });
|
|
}
|
|
function createSelectFirstOption(): WizardPrompter["select"] {
|
|
return vi.fn(async (params) => params.options[0]?.value as never);
|
|
}
|
|
function createNoopMultiselect(): WizardPrompter["multiselect"] {
|
|
return vi.fn(async () => []);
|
|
}
|
|
function createApiKeyPromptHarness(
|
|
overrides: Partial<Pick<WizardPrompter, "select" | "multiselect" | "text" | "confirm">> = {},
|
|
): {
|
|
select: WizardPrompter["select"];
|
|
multiselect: WizardPrompter["multiselect"];
|
|
prompter: WizardPrompter;
|
|
runtime: ReturnType<typeof createExitThrowingRuntime>;
|
|
} {
|
|
const select = overrides.select ?? createSelectFirstOption();
|
|
const multiselect = overrides.multiselect ?? createNoopMultiselect();
|
|
return {
|
|
select,
|
|
multiselect,
|
|
prompter: createPrompter({ ...overrides, select, multiselect }),
|
|
runtime: createExitThrowingRuntime(),
|
|
};
|
|
}
|
|
async function readAuthProfiles() {
|
|
return await readAuthProfilesForAgent<{
|
|
profiles?: Record<string, StoredAuthProfile>;
|
|
}>(requireOpenClawAgentDir());
|
|
}
|
|
async function readAuthProfile(profileId: string) {
|
|
return (await readAuthProfiles()).profiles?.[profileId];
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
resolvePluginProviders.mockReset();
|
|
detectZaiEndpoint.mockReset();
|
|
detectZaiEndpoint.mockResolvedValue(null);
|
|
loginOpenAICodexOAuth.mockReset();
|
|
loginOpenAICodexOAuth.mockResolvedValue(null);
|
|
await lifecycle.cleanup();
|
|
activeStateDir = null;
|
|
});
|
|
|
|
it("does not throw when openai-codex oauth fails", async () => {
|
|
await setupTempState();
|
|
|
|
loginOpenAICodexOAuth.mockRejectedValueOnce(new Error("oauth failed"));
|
|
|
|
const prompter = createPrompter({});
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
await expect(
|
|
applyAuthChoice({
|
|
authChoice: "openai-codex",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
}),
|
|
).resolves.toEqual({ config: {} });
|
|
});
|
|
|
|
it("stores openai-codex OAuth with email profile id", async () => {
|
|
await setupTempState();
|
|
|
|
loginOpenAICodexOAuth.mockResolvedValueOnce({
|
|
email: "user@example.com",
|
|
refresh: "refresh-token",
|
|
access: "access-token",
|
|
expires: Date.now() + 60_000,
|
|
});
|
|
|
|
const prompter = createPrompter({});
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "openai-codex",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
});
|
|
|
|
expect(result.config.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({
|
|
provider: "openai-codex",
|
|
mode: "oauth",
|
|
});
|
|
expect(result.config.auth?.profiles?.["openai-codex:default"]).toBeUndefined();
|
|
expect(await readAuthProfile("openai-codex:user@example.com")).toMatchObject({
|
|
type: "oauth",
|
|
provider: "openai-codex",
|
|
refresh: "refresh-token",
|
|
access: "access-token",
|
|
email: "user@example.com",
|
|
});
|
|
});
|
|
|
|
it("prompts and writes provider API key for common providers", async () => {
|
|
const scenarios: Array<{
|
|
authChoice:
|
|
| "minimax-api"
|
|
| "minimax-api-key-cn"
|
|
| "synthetic-api-key"
|
|
| "huggingface-api-key";
|
|
promptContains: string;
|
|
profileId: string;
|
|
provider: string;
|
|
token: string;
|
|
expectedBaseUrl?: string;
|
|
expectedModelPrefix?: string;
|
|
}> = [
|
|
{
|
|
authChoice: "minimax-api" as const,
|
|
promptContains: "Enter MiniMax API key",
|
|
profileId: "minimax:default",
|
|
provider: "minimax",
|
|
token: "sk-minimax-test",
|
|
},
|
|
{
|
|
authChoice: "minimax-api-key-cn" as const,
|
|
promptContains: "Enter MiniMax China API key",
|
|
profileId: "minimax-cn:default",
|
|
provider: "minimax-cn",
|
|
token: "sk-minimax-test",
|
|
expectedBaseUrl: MINIMAX_CN_API_BASE_URL,
|
|
},
|
|
{
|
|
authChoice: "synthetic-api-key" as const,
|
|
promptContains: "Enter Synthetic API key",
|
|
profileId: "synthetic:default",
|
|
provider: "synthetic",
|
|
token: "sk-synthetic-test",
|
|
},
|
|
{
|
|
authChoice: "huggingface-api-key" as const,
|
|
promptContains: "Hugging Face",
|
|
profileId: "huggingface:default",
|
|
provider: "huggingface",
|
|
token: "hf-test-token",
|
|
expectedModelPrefix: "huggingface/",
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
|
|
const text = vi.fn().mockResolvedValue(scenario.token);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
expect(text).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: expect.stringContaining(scenario.promptContains) }),
|
|
);
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.provider,
|
|
mode: "api_key",
|
|
});
|
|
if (scenario.expectedBaseUrl) {
|
|
expect(result.config.models?.providers?.[scenario.provider]?.baseUrl).toBe(
|
|
scenario.expectedBaseUrl,
|
|
);
|
|
}
|
|
if (scenario.expectedModelPrefix) {
|
|
expect(
|
|
resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)?.startsWith(
|
|
scenario.expectedModelPrefix,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token);
|
|
}
|
|
});
|
|
|
|
it("handles Z.AI endpoint selection and detection paths", async () => {
|
|
const scenarios: Array<{
|
|
authChoice: "zai-api-key" | "zai-coding-global";
|
|
token: string;
|
|
endpointSelection?: "coding-cn" | "global";
|
|
detectResult?: {
|
|
endpoint: "coding-global" | "coding-cn";
|
|
modelId: string;
|
|
baseUrl: string;
|
|
note: string;
|
|
};
|
|
expectedBaseUrl: string;
|
|
expectedModel?: string;
|
|
shouldPromptForEndpoint: boolean;
|
|
shouldAssertDetectCall?: boolean;
|
|
}> = [
|
|
{
|
|
authChoice: "zai-api-key",
|
|
token: "zai-test-key",
|
|
endpointSelection: "coding-cn",
|
|
expectedBaseUrl: ZAI_CODING_CN_BASE_URL,
|
|
expectedModel: "zai/glm-5",
|
|
shouldPromptForEndpoint: true,
|
|
},
|
|
{
|
|
authChoice: "zai-coding-global",
|
|
token: "zai-test-key",
|
|
expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL,
|
|
shouldPromptForEndpoint: false,
|
|
},
|
|
{
|
|
authChoice: "zai-api-key",
|
|
token: "zai-detected-key",
|
|
detectResult: {
|
|
endpoint: "coding-global",
|
|
modelId: "glm-4.5",
|
|
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
|
|
note: "Detected coding-global endpoint",
|
|
},
|
|
expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL,
|
|
expectedModel: "zai/glm-4.5",
|
|
shouldPromptForEndpoint: false,
|
|
shouldAssertDetectCall: true,
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
detectZaiEndpoint.mockReset();
|
|
detectZaiEndpoint.mockResolvedValue(null);
|
|
if (scenario.detectResult) {
|
|
detectZaiEndpoint.mockResolvedValueOnce(scenario.detectResult);
|
|
}
|
|
|
|
const text = vi.fn().mockResolvedValue(scenario.token);
|
|
const select = vi.fn(async (params: { message: string }) => {
|
|
if (params.message === "Select Z.AI endpoint") {
|
|
return scenario.endpointSelection ?? "global";
|
|
}
|
|
return "default";
|
|
});
|
|
const { prompter, runtime } = createApiKeyPromptHarness({
|
|
select: select as WizardPrompter["select"],
|
|
text,
|
|
});
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
if (scenario.shouldAssertDetectCall) {
|
|
expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: scenario.token });
|
|
}
|
|
if (scenario.shouldPromptForEndpoint) {
|
|
expect(select).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "global" }),
|
|
);
|
|
} else {
|
|
expect(select).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: "Select Z.AI endpoint" }),
|
|
);
|
|
}
|
|
expect(result.config.models?.providers?.zai?.baseUrl).toBe(scenario.expectedBaseUrl);
|
|
if (scenario.expectedModel) {
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
scenario.expectedModel,
|
|
);
|
|
}
|
|
if (scenario.authChoice === "zai-api-key") {
|
|
expect((await readAuthProfile("zai:default"))?.key).toBe(scenario.token);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("maps apiKey tokenProvider aliases to provider flow", async () => {
|
|
const scenarios: Array<{
|
|
tokenProvider: string;
|
|
token: string;
|
|
profileId: string;
|
|
provider: string;
|
|
expectedModel?: string;
|
|
expectedModelPrefix?: string;
|
|
}> = [
|
|
{
|
|
tokenProvider: "huggingface",
|
|
token: "hf-token-provider-test",
|
|
profileId: "huggingface:default",
|
|
provider: "huggingface",
|
|
expectedModelPrefix: "huggingface/",
|
|
},
|
|
{
|
|
tokenProvider: " ToGeThEr ",
|
|
token: "sk-together-token-provider-test",
|
|
profileId: "together:default",
|
|
provider: "together",
|
|
expectedModelPrefix: "together/",
|
|
},
|
|
{
|
|
tokenProvider: "KIMI-CODING",
|
|
token: "sk-kimi-token-provider-test",
|
|
profileId: "kimi-coding:default",
|
|
provider: "kimi-coding",
|
|
expectedModelPrefix: "kimi-coding/",
|
|
},
|
|
{
|
|
tokenProvider: " GOOGLE ",
|
|
token: "sk-gemini-token-provider-test",
|
|
profileId: "google:default",
|
|
provider: "google",
|
|
expectedModel: GOOGLE_GEMINI_DEFAULT_MODEL,
|
|
},
|
|
{
|
|
tokenProvider: " LITELLM ",
|
|
token: "sk-litellm-token-provider-test",
|
|
profileId: "litellm:default",
|
|
provider: "litellm",
|
|
expectedModelPrefix: "litellm/",
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
delete process.env.HF_TOKEN;
|
|
delete process.env.HUGGINGFACE_HUB_TOKEN;
|
|
|
|
const text = vi.fn().mockResolvedValue("should-not-be-used");
|
|
const confirm = vi.fn(async () => false);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "apiKey",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
opts: {
|
|
tokenProvider: scenario.tokenProvider,
|
|
token: scenario.token,
|
|
},
|
|
});
|
|
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.provider,
|
|
mode: "api_key",
|
|
});
|
|
if (scenario.expectedModel) {
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
scenario.expectedModel,
|
|
);
|
|
}
|
|
if (scenario.expectedModelPrefix) {
|
|
expect(
|
|
resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)?.startsWith(
|
|
scenario.expectedModelPrefix,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
expect(text).not.toHaveBeenCalled();
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
authChoice: "moonshot-api-key",
|
|
tokenProvider: "moonshot",
|
|
profileId: "moonshot:default",
|
|
provider: "moonshot",
|
|
modelPrefix: "moonshot/",
|
|
},
|
|
{
|
|
authChoice: "mistral-api-key",
|
|
tokenProvider: "mistral",
|
|
profileId: "mistral:default",
|
|
provider: "mistral",
|
|
modelPrefix: "mistral/",
|
|
},
|
|
{
|
|
authChoice: "kimi-code-api-key",
|
|
tokenProvider: "kimi-code",
|
|
profileId: "kimi-coding:default",
|
|
provider: "kimi-coding",
|
|
modelPrefix: "kimi-coding/",
|
|
},
|
|
{
|
|
authChoice: "xiaomi-api-key",
|
|
tokenProvider: "xiaomi",
|
|
profileId: "xiaomi:default",
|
|
provider: "xiaomi",
|
|
modelPrefix: "xiaomi/",
|
|
},
|
|
{
|
|
authChoice: "venice-api-key",
|
|
tokenProvider: "venice",
|
|
profileId: "venice:default",
|
|
provider: "venice",
|
|
modelPrefix: "venice/",
|
|
},
|
|
{
|
|
authChoice: "opencode-zen",
|
|
tokenProvider: "opencode",
|
|
profileId: "opencode:default",
|
|
provider: "opencode",
|
|
modelPrefix: "opencode/",
|
|
},
|
|
{
|
|
authChoice: "together-api-key",
|
|
tokenProvider: "together",
|
|
profileId: "together:default",
|
|
provider: "together",
|
|
modelPrefix: "together/",
|
|
},
|
|
{
|
|
authChoice: "qianfan-api-key",
|
|
tokenProvider: "qianfan",
|
|
profileId: "qianfan:default",
|
|
provider: "qianfan",
|
|
modelPrefix: "qianfan/",
|
|
},
|
|
{
|
|
authChoice: "synthetic-api-key",
|
|
tokenProvider: "synthetic",
|
|
profileId: "synthetic:default",
|
|
provider: "synthetic",
|
|
modelPrefix: "synthetic/",
|
|
},
|
|
] as const)(
|
|
"uses opts token for $authChoice without prompting",
|
|
async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => {
|
|
await setupTempState();
|
|
|
|
const text = vi.fn();
|
|
const confirm = vi.fn(async () => false);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
const token = `sk-${tokenProvider}-test`;
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
opts: {
|
|
tokenProvider,
|
|
token,
|
|
},
|
|
});
|
|
|
|
expect(text).not.toHaveBeenCalled();
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
expect(result.config.auth?.profiles?.[profileId]).toMatchObject({
|
|
provider,
|
|
mode: "api_key",
|
|
});
|
|
expect(
|
|
resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)?.startsWith(
|
|
modelPrefix,
|
|
),
|
|
).toBe(true);
|
|
expect((await readAuthProfile(profileId))?.key).toBe(token);
|
|
},
|
|
);
|
|
|
|
it("uses opts token for Gemini and keeps global default model when setDefaultModel=false", async () => {
|
|
await setupTempState();
|
|
|
|
const text = vi.fn();
|
|
const confirm = vi.fn(async () => false);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "gemini-api-key",
|
|
config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } },
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
opts: {
|
|
tokenProvider: "google",
|
|
token: "sk-gemini-test",
|
|
},
|
|
});
|
|
|
|
expect(text).not.toHaveBeenCalled();
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({
|
|
provider: "google",
|
|
mode: "api_key",
|
|
});
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
"openai/gpt-4o-mini",
|
|
);
|
|
expect(result.agentModelOverride).toBe(GOOGLE_GEMINI_DEFAULT_MODEL);
|
|
expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-test");
|
|
});
|
|
|
|
it("prompts for Venice API key and shows the Venice note when no token is provided", async () => {
|
|
await setupTempState();
|
|
process.env.VENICE_API_KEY = "";
|
|
|
|
const note = vi.fn(async () => {});
|
|
const text = vi.fn(async () => "sk-venice-manual");
|
|
const prompter = createPrompter({ note, text });
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "venice-api-key",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
expect(note).toHaveBeenCalledWith(
|
|
expect.stringContaining("privacy-focused inference"),
|
|
"Venice AI",
|
|
);
|
|
expect(text).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Enter Venice AI API key",
|
|
}),
|
|
);
|
|
expect(result.config.auth?.profiles?.["venice:default"]).toMatchObject({
|
|
provider: "venice",
|
|
mode: "api_key",
|
|
});
|
|
expect((await readAuthProfile("venice:default"))?.key).toBe("sk-venice-manual");
|
|
});
|
|
|
|
it("uses existing env API keys for selected providers", async () => {
|
|
const scenarios: Array<{
|
|
authChoice: "synthetic-api-key" | "openrouter-api-key" | "ai-gateway-api-key";
|
|
envKey: "SYNTHETIC_API_KEY" | "OPENROUTER_API_KEY" | "AI_GATEWAY_API_KEY";
|
|
envValue: string;
|
|
profileId: string;
|
|
provider: string;
|
|
opts?: { secretInputMode?: "ref" };
|
|
expectEnvPrompt: boolean;
|
|
expectedTextCalls: number;
|
|
expectedKey?: string;
|
|
expectedKeyRef?: { source: "env"; provider: string; id: string };
|
|
expectedModel?: string;
|
|
expectedModelPrefix?: string;
|
|
}> = [
|
|
{
|
|
authChoice: "synthetic-api-key",
|
|
envKey: "SYNTHETIC_API_KEY",
|
|
envValue: "sk-synthetic-env",
|
|
profileId: "synthetic:default",
|
|
provider: "synthetic",
|
|
expectEnvPrompt: true,
|
|
expectedTextCalls: 0,
|
|
expectedKey: "sk-synthetic-env",
|
|
expectedModelPrefix: "synthetic/",
|
|
},
|
|
{
|
|
authChoice: "openrouter-api-key",
|
|
envKey: "OPENROUTER_API_KEY",
|
|
envValue: "sk-openrouter-test",
|
|
profileId: "openrouter:default",
|
|
provider: "openrouter",
|
|
expectEnvPrompt: true,
|
|
expectedTextCalls: 0,
|
|
expectedKey: "sk-openrouter-test",
|
|
expectedModel: "openrouter/auto",
|
|
},
|
|
{
|
|
authChoice: "ai-gateway-api-key",
|
|
envKey: "AI_GATEWAY_API_KEY",
|
|
envValue: "gateway-test-key",
|
|
profileId: "vercel-ai-gateway:default",
|
|
provider: "vercel-ai-gateway",
|
|
expectEnvPrompt: true,
|
|
expectedTextCalls: 0,
|
|
expectedKey: "gateway-test-key",
|
|
expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6",
|
|
},
|
|
{
|
|
authChoice: "ai-gateway-api-key",
|
|
envKey: "AI_GATEWAY_API_KEY",
|
|
envValue: "gateway-ref-key",
|
|
profileId: "vercel-ai-gateway:default",
|
|
provider: "vercel-ai-gateway",
|
|
opts: { secretInputMode: "ref" }, // pragma: allowlist secret
|
|
expectEnvPrompt: false,
|
|
expectedTextCalls: 1,
|
|
expectedKeyRef: { source: "env", provider: "default", id: "AI_GATEWAY_API_KEY" },
|
|
expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6",
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
delete process.env.SYNTHETIC_API_KEY;
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
delete process.env.AI_GATEWAY_API_KEY;
|
|
process.env[scenario.envKey] = scenario.envValue;
|
|
|
|
const text = vi.fn();
|
|
const confirm = vi.fn(async () => true);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
opts: scenario.opts,
|
|
});
|
|
|
|
if (scenario.expectEnvPrompt) {
|
|
expect(confirm).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining(scenario.envKey),
|
|
}),
|
|
);
|
|
} else {
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
}
|
|
expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls);
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.provider,
|
|
mode: "api_key",
|
|
});
|
|
if (scenario.expectedModel) {
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
scenario.expectedModel,
|
|
);
|
|
}
|
|
if (scenario.expectedModelPrefix) {
|
|
expect(
|
|
resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)?.startsWith(
|
|
scenario.expectedModelPrefix,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
const profile = await readAuthProfile(scenario.profileId);
|
|
if (scenario.expectedKeyRef) {
|
|
expect(profile?.keyRef).toEqual(scenario.expectedKeyRef);
|
|
expect(profile?.key).toBeUndefined();
|
|
} else {
|
|
expect(profile?.key).toBe(scenario.expectedKey);
|
|
expect(profile?.keyRef).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("retries ref setup when provider preflight fails and can switch to env ref", async () => {
|
|
await setupTempState();
|
|
process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret
|
|
|
|
const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"];
|
|
const select = vi.fn(async (params: Parameters<WizardPrompter["select"]>[0]) => {
|
|
const next = selectValues[0];
|
|
if (next && params.options.some((option) => option.value === next)) {
|
|
selectValues.shift();
|
|
return next as never;
|
|
}
|
|
return (params.options[0]?.value ?? "env") as never;
|
|
});
|
|
const text = vi
|
|
.fn<WizardPrompter["text"]>()
|
|
.mockResolvedValueOnce("/providers/openai/apiKey")
|
|
.mockResolvedValueOnce("OPENAI_API_KEY");
|
|
const note = vi.fn(async () => undefined);
|
|
|
|
const prompter = createPrompter({
|
|
select,
|
|
text,
|
|
note,
|
|
confirm: vi.fn(async () => true),
|
|
});
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "openai-api-key",
|
|
config: {
|
|
secrets: {
|
|
providers: {
|
|
filemain: {
|
|
source: "file",
|
|
path: "/tmp/openclaw-missing-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
opts: { secretInputMode: "ref" }, // pragma: allowlist secret
|
|
});
|
|
|
|
expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({
|
|
provider: "openai",
|
|
mode: "api_key",
|
|
});
|
|
expect(note).toHaveBeenCalledWith(
|
|
expect.stringContaining("Could not validate provider reference"),
|
|
"Reference check failed",
|
|
);
|
|
expect(note).toHaveBeenCalledWith(
|
|
expect.stringContaining("Validated environment variable OPENAI_API_KEY."),
|
|
"Reference validated",
|
|
);
|
|
expect(await readAuthProfile("openai:default")).toMatchObject({
|
|
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
});
|
|
});
|
|
|
|
it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => {
|
|
const scenarios: Array<{
|
|
authChoice: "xai-api-key" | "opencode-zen";
|
|
token: string;
|
|
promptMessage: string;
|
|
existingPrimary: string;
|
|
expectedOverride: string;
|
|
profileId?: string;
|
|
profileProvider?: string;
|
|
expectProviderConfigUndefined?: "opencode-zen";
|
|
agentId?: string;
|
|
}> = [
|
|
{
|
|
authChoice: "xai-api-key",
|
|
token: "sk-xai-test",
|
|
promptMessage: "Enter xAI API key",
|
|
existingPrimary: "openai/gpt-4o-mini",
|
|
expectedOverride: "xai/grok-4",
|
|
profileId: "xai:default",
|
|
profileProvider: "xai",
|
|
agentId: "agent-1",
|
|
},
|
|
{
|
|
authChoice: "opencode-zen",
|
|
token: "sk-opencode-zen-test",
|
|
promptMessage: "Enter OpenCode Zen API key",
|
|
existingPrimary: "anthropic/claude-opus-4-5",
|
|
expectedOverride: "opencode/claude-opus-4-6",
|
|
expectProviderConfigUndefined: "opencode-zen",
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
|
|
const text = vi.fn().mockResolvedValue(scenario.token);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: { agents: { defaults: { model: { primary: scenario.existingPrimary } } } },
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
agentId: scenario.agentId,
|
|
});
|
|
|
|
expect(text).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: scenario.promptMessage }),
|
|
);
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
scenario.existingPrimary,
|
|
);
|
|
expect(result.agentModelOverride).toBe(scenario.expectedOverride);
|
|
if (scenario.profileId && scenario.profileProvider) {
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.profileProvider,
|
|
mode: "api_key",
|
|
});
|
|
expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token);
|
|
}
|
|
if (scenario.expectProviderConfigUndefined) {
|
|
expect(
|
|
result.config.models?.providers?.[scenario.expectProviderConfigUndefined],
|
|
).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("sets default model when selecting github-copilot", async () => {
|
|
await setupTempState();
|
|
|
|
const prompter = createPrompter({});
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
|
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
|
|
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
|
|
Object.defineProperty(stdin, "isTTY", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get: () => true,
|
|
});
|
|
|
|
try {
|
|
const result = await applyAuthChoice({
|
|
authChoice: "github-copilot",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
"github-copilot/gpt-4o",
|
|
);
|
|
} finally {
|
|
if (previousIsTTYDescriptor) {
|
|
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
|
} else if (!hadOwnIsTTY) {
|
|
delete (stdin as { isTTY?: boolean }).isTTY;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("does not persist literal 'undefined' when API key prompts return undefined", async () => {
|
|
const scenarios = [
|
|
{
|
|
authChoice: "apiKey" as const,
|
|
envKey: "ANTHROPIC_API_KEY",
|
|
profileId: "anthropic:default",
|
|
provider: "anthropic",
|
|
},
|
|
{
|
|
authChoice: "openrouter-api-key" as const,
|
|
envKey: "OPENROUTER_API_KEY",
|
|
profileId: "openrouter:default",
|
|
provider: "openrouter",
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
delete process.env[scenario.envKey];
|
|
|
|
const text = vi.fn(async () => undefined as unknown as string);
|
|
const prompter = createPrompter({ text });
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
});
|
|
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.provider,
|
|
mode: "api_key",
|
|
});
|
|
|
|
const profile = await readAuthProfile(scenario.profileId);
|
|
expect(profile?.key).toBe("");
|
|
expect(profile?.key).not.toBe("undefined");
|
|
}
|
|
});
|
|
|
|
it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => {
|
|
await setupTempState();
|
|
process.env.LITELLM_API_KEY = "sk-litellm-test"; // pragma: allowlist secret
|
|
|
|
const authProfilePath = authProfilePathForAgent(requireOpenClawAgentDir());
|
|
await fs.writeFile(
|
|
authProfilePath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"litellm:legacy": {
|
|
type: "oauth",
|
|
provider: "litellm",
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
const text = vi.fn();
|
|
const confirm = vi.fn(async () => true);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "litellm-api-key",
|
|
config: {
|
|
auth: {
|
|
profiles: {
|
|
"litellm:legacy": { provider: "litellm", mode: "oauth" },
|
|
},
|
|
order: { litellm: ["litellm:legacy"] },
|
|
},
|
|
},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
expect(confirm).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("LITELLM_API_KEY"),
|
|
}),
|
|
);
|
|
expect(text).not.toHaveBeenCalled();
|
|
expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({
|
|
provider: "litellm",
|
|
mode: "api_key",
|
|
});
|
|
|
|
expect(await readAuthProfile("litellm:default")).toMatchObject({
|
|
type: "api_key",
|
|
key: "sk-litellm-test",
|
|
});
|
|
});
|
|
|
|
it("configures cloudflare ai gateway via env key and explicit opts", async () => {
|
|
const scenarios: Array<{
|
|
envGatewayKey?: string;
|
|
textValues: string[];
|
|
confirmValue: boolean;
|
|
opts?: {
|
|
secretInputMode?: "ref"; // pragma: allowlist secret
|
|
cloudflareAiGatewayAccountId?: string;
|
|
cloudflareAiGatewayGatewayId?: string;
|
|
cloudflareAiGatewayApiKey?: string;
|
|
};
|
|
expectEnvPrompt: boolean;
|
|
expectedTextCalls: number;
|
|
expectedKey?: string;
|
|
expectedKeyRef?: { source: string; provider: string; id: string };
|
|
expectedMetadata: { accountId: string; gatewayId: string };
|
|
}> = [
|
|
{
|
|
envGatewayKey: "cf-gateway-test-key",
|
|
textValues: ["cf-account-id", "cf-gateway-id"],
|
|
confirmValue: true,
|
|
expectEnvPrompt: true,
|
|
expectedTextCalls: 2,
|
|
expectedKey: "cf-gateway-test-key",
|
|
expectedMetadata: {
|
|
accountId: "cf-account-id",
|
|
gatewayId: "cf-gateway-id",
|
|
},
|
|
},
|
|
{
|
|
envGatewayKey: "cf-gateway-ref-key",
|
|
textValues: ["cf-account-id-ref", "cf-gateway-id-ref"],
|
|
confirmValue: true,
|
|
opts: {
|
|
secretInputMode: "ref", // pragma: allowlist secret
|
|
},
|
|
expectEnvPrompt: false,
|
|
expectedTextCalls: 3,
|
|
expectedKeyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" },
|
|
expectedMetadata: {
|
|
accountId: "cf-account-id-ref",
|
|
gatewayId: "cf-gateway-id-ref",
|
|
},
|
|
},
|
|
{
|
|
textValues: [],
|
|
confirmValue: false,
|
|
opts: {
|
|
cloudflareAiGatewayAccountId: "acc-direct",
|
|
cloudflareAiGatewayGatewayId: "gw-direct",
|
|
cloudflareAiGatewayApiKey: "cf-direct-key", // pragma: allowlist secret
|
|
},
|
|
expectEnvPrompt: false,
|
|
expectedTextCalls: 0,
|
|
expectedKey: "cf-direct-key",
|
|
expectedMetadata: {
|
|
accountId: "acc-direct",
|
|
gatewayId: "gw-direct",
|
|
},
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
|
|
if (scenario.envGatewayKey) {
|
|
process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = scenario.envGatewayKey;
|
|
}
|
|
|
|
const text = vi.fn();
|
|
for (const textValue of scenario.textValues) {
|
|
text.mockResolvedValueOnce(textValue);
|
|
}
|
|
const confirm = vi.fn(async () => scenario.confirmValue);
|
|
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "cloudflare-ai-gateway-api-key",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
opts: scenario.opts,
|
|
});
|
|
|
|
if (scenario.expectEnvPrompt) {
|
|
expect(confirm).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("CLOUDFLARE_AI_GATEWAY_API_KEY"),
|
|
}),
|
|
);
|
|
} else {
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
}
|
|
expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls);
|
|
expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({
|
|
provider: "cloudflare-ai-gateway",
|
|
mode: "api_key",
|
|
});
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
"cloudflare-ai-gateway/claude-sonnet-4-5",
|
|
);
|
|
|
|
const profile = await readAuthProfile("cloudflare-ai-gateway:default");
|
|
if (scenario.expectedKeyRef) {
|
|
expect(profile?.keyRef).toEqual(scenario.expectedKeyRef);
|
|
} else {
|
|
expect(profile?.key).toBe(scenario.expectedKey);
|
|
}
|
|
expect(profile?.metadata).toEqual(scenario.expectedMetadata);
|
|
}
|
|
delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
|
|
});
|
|
|
|
it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => {
|
|
await setupTempState();
|
|
process.env.SSH_TTY = "1";
|
|
process.env.CHUTES_CLIENT_ID = "cid_test";
|
|
|
|
const fetchSpy = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.chutes.ai/idp/token") {
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "at_test",
|
|
refresh_token: "rt_test",
|
|
expires_in: 3600,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
if (url === "https://api.chutes.ai/idp/userinfo") {
|
|
return new Response(JSON.stringify({ username: "remote-user" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
const runtime = createExitThrowingRuntime();
|
|
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
|
if (params.message === "Paste the redirect URL") {
|
|
const runtimeLog = runtime.log as ReturnType<typeof vi.fn>;
|
|
const lastLog = runtimeLog.mock.calls.at(-1)?.[0];
|
|
const urlLine = typeof lastLog === "string" ? lastLog : String(lastLog ?? "");
|
|
const urlMatch = urlLine.match(/https?:\/\/\S+/)?.[0] ?? "";
|
|
const state = urlMatch ? new URL(urlMatch).searchParams.get("state") : null;
|
|
if (!state) {
|
|
throw new Error("missing state in oauth URL");
|
|
}
|
|
return `?code=code_manual&state=${state}`;
|
|
}
|
|
return "code_manual";
|
|
});
|
|
const { prompter } = createApiKeyPromptHarness({ text });
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: "chutes",
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: false,
|
|
});
|
|
|
|
expect(text).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Paste the redirect URL",
|
|
}),
|
|
);
|
|
expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({
|
|
provider: "chutes",
|
|
mode: "oauth",
|
|
});
|
|
|
|
expect(await readAuthProfile("chutes:remote-user")).toMatchObject({
|
|
provider: "chutes",
|
|
access: "at_test",
|
|
refresh: "rt_test",
|
|
email: "remote-user",
|
|
});
|
|
});
|
|
|
|
it("writes portal OAuth credentials for plugin providers", async () => {
|
|
const scenarios: Array<{
|
|
authChoice: "qwen-portal" | "minimax-portal";
|
|
label: string;
|
|
authId: string;
|
|
authLabel: string;
|
|
providerId: string;
|
|
profileId: string;
|
|
baseUrl: string;
|
|
api: "openai-completions" | "anthropic-messages";
|
|
defaultModel: string;
|
|
apiKey: string;
|
|
selectValue?: string;
|
|
}> = [
|
|
{
|
|
authChoice: "qwen-portal",
|
|
label: "Qwen",
|
|
authId: "device",
|
|
authLabel: "Qwen OAuth",
|
|
providerId: "qwen-portal",
|
|
profileId: "qwen-portal:default",
|
|
baseUrl: "https://portal.qwen.ai/v1",
|
|
api: "openai-completions",
|
|
defaultModel: "qwen-portal/coder-model",
|
|
apiKey: "qwen-oauth", // pragma: allowlist secret
|
|
},
|
|
{
|
|
authChoice: "minimax-portal",
|
|
label: "MiniMax",
|
|
authId: "oauth",
|
|
authLabel: "MiniMax OAuth (Global)",
|
|
providerId: "minimax-portal",
|
|
profileId: "minimax-portal:default",
|
|
baseUrl: "https://api.minimax.io/anthropic",
|
|
api: "anthropic-messages",
|
|
defaultModel: "minimax-portal/MiniMax-M2.5",
|
|
apiKey: "minimax-oauth", // pragma: allowlist secret
|
|
selectValue: "oauth",
|
|
},
|
|
];
|
|
for (const scenario of scenarios) {
|
|
await setupTempState();
|
|
|
|
resolvePluginProviders.mockReturnValue([
|
|
{
|
|
id: scenario.providerId,
|
|
label: scenario.label,
|
|
auth: [
|
|
{
|
|
id: scenario.authId,
|
|
label: scenario.authLabel,
|
|
kind: "device_code",
|
|
run: vi.fn(async () => ({
|
|
profiles: [
|
|
{
|
|
profileId: scenario.profileId,
|
|
credential: {
|
|
type: "oauth",
|
|
provider: scenario.providerId,
|
|
access: "access",
|
|
refresh: "refresh",
|
|
expires: Date.now() + 60 * 60 * 1000,
|
|
},
|
|
},
|
|
],
|
|
configPatch: {
|
|
models: {
|
|
providers: {
|
|
[scenario.providerId]: {
|
|
baseUrl: scenario.baseUrl,
|
|
apiKey: scenario.apiKey,
|
|
api: scenario.api,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
defaultModel: scenario.defaultModel,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
] as never);
|
|
|
|
const prompter = createPrompter(
|
|
scenario.selectValue
|
|
? { select: vi.fn(async () => scenario.selectValue as never) as WizardPrompter["select"] }
|
|
: {},
|
|
);
|
|
const runtime = createExitThrowingRuntime();
|
|
|
|
const result = await applyAuthChoice({
|
|
authChoice: scenario.authChoice,
|
|
config: {},
|
|
prompter,
|
|
runtime,
|
|
setDefaultModel: true,
|
|
});
|
|
|
|
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
|
provider: scenario.providerId,
|
|
mode: "oauth",
|
|
});
|
|
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
|
|
scenario.defaultModel,
|
|
);
|
|
expect(result.config.models?.providers?.[scenario.providerId]).toMatchObject({
|
|
baseUrl: scenario.baseUrl,
|
|
apiKey: scenario.apiKey,
|
|
});
|
|
expect(await readAuthProfile(scenario.profileId)).toMatchObject({
|
|
provider: scenario.providerId,
|
|
access: "access",
|
|
refresh: "refresh",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("resolvePreferredProviderForAuthChoice", () => {
|
|
it("maps known and unknown auth choices", () => {
|
|
const scenarios = [
|
|
{ authChoice: "github-copilot" as const, expectedProvider: "github-copilot" },
|
|
{ authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" },
|
|
{ authChoice: "mistral-api-key" as const, expectedProvider: "mistral" },
|
|
{ authChoice: "unknown" as AuthChoice, expectedProvider: undefined },
|
|
] as const;
|
|
for (const scenario of scenarios) {
|
|
expect(resolvePreferredProviderForAuthChoice(scenario.authChoice)).toBe(
|
|
scenario.expectedProvider,
|
|
);
|
|
}
|
|
});
|
|
});
|