refactor: move provider auth-choice helpers into plugins

This commit is contained in:
Peter Steinberger
2026-03-16 22:40:26 -07:00
parent 049bb37c62
commit 0bc9c065f2
17 changed files with 563 additions and 544 deletions

View File

@@ -1,6 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import { applyAuthChoiceLoadedPluginProvider } from "../../commands/auth-choice.apply.plugin-provider.js";
import {
createAuthTestLifecycle,
createExitThrowingRuntime,
@@ -8,18 +6,20 @@ import {
readAuthProfilesForAgent,
requireOpenClawAgentDir,
setupAuthTestEnv,
} from "../../commands/test-wizard-helpers.js";
} from "../../../test/helpers/auth-wizard.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import { applyAuthChoiceLoadedPluginProvider } from "../../plugins/provider-auth-choice.js";
import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js";
import { buildProviderPluginMethodChoice } from "../provider-wizard.js";
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js";
type ResolvePluginProviders =
typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders;
typeof import("../../plugins/provider-auth-choice.runtime.js").resolvePluginProviders;
type ResolveProviderPluginChoice =
typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolveProviderPluginChoice;
typeof import("../../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice;
type RunProviderModelSelectedHook =
typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").runProviderModelSelectedHook;
typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook;
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn());
@@ -38,7 +38,7 @@ vi.mock("../../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
}));
vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({
vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
@@ -54,7 +54,7 @@ vi.mock("../../plugins/providers.js", async () => {
});
const { resolvePreferredProviderForAuthChoice } =
await import("../../commands/auth-choice.preferred-provider.js");
await import("../../plugins/provider-auth-choice-preference.js");
type StoredAuthProfile = {
type?: string;

View File

@@ -14,19 +14,19 @@ import type {
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
type LoginOpenAICodexOAuth =
(typeof import("../../commands/openai-codex-oauth.js"))["loginOpenAICodexOAuth"];
(typeof import("../../plugins/provider-openai-codex-oauth.js"))["loginOpenAICodexOAuth"];
type LoginQwenPortalOAuth =
(typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"];
type GithubCopilotLoginCommand =
(typeof import("../../providers/github-copilot-auth.js"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
(typeof import("../../commands/oauth-flow.js"))["createVpsAwareOAuthHandlers"];
(typeof import("../../plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn<LoginQwenPortalOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
vi.mock("../../commands/openai-codex-oauth.js", () => ({
vi.mock("../../plugins/provider-openai-codex-oauth.js", () => ({
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
}));

View File

@@ -0,0 +1,82 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ProviderAuthMethod, ProviderPlugin } from "./types.js";
export function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider?: string,
): ProviderPlugin | null {
const raw = rawProvider?.trim();
if (!raw) {
return null;
}
const normalized = normalizeProviderId(raw);
return (
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
providers.find(
(provider) =>
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
) ??
null
);
}
export function pickAuthMethod(
provider: ProviderPlugin,
rawMethod?: string,
): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) {
return null;
}
const normalized = raw.toLowerCase();
return (
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
null
);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
const existing = next[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
next[key] = mergeConfigPatch(existing, value);
} else {
next[key] = value;
}
}
return next as T;
}
export function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[model] = models[model] ?? {};
const existingModel = cfg.agents?.defaults?.model;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
: undefined),
primary: model,
},
},
},
};
}

View File

@@ -0,0 +1,53 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveManifestProviderAuthChoice } from "./provider-auth-choices.js";
const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<string, string>> = {
chutes: "chutes",
"litellm-api-key": "litellm",
"custom-api-key": "custom",
};
function normalizeLegacyAuthChoice(choice: string): string {
if (choice === "oauth") {
return "setup-token";
}
if (choice === "claude-cli") {
return "setup-token";
}
if (choice === "codex-cli") {
return "openai-codex";
}
return choice;
}
export async function resolvePreferredProviderForAuthChoice(params: {
choice: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const choice = normalizeLegacyAuthChoice(params.choice) ?? params.choice;
const manifestResolved = resolveManifestProviderAuthChoice(choice, params);
if (manifestResolved) {
return manifestResolved.providerId;
}
const { resolveProviderPluginChoice, resolvePluginProviders } =
await import("./provider-auth-choice.runtime.js");
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const pluginResolved = resolveProviderPluginChoice({
providers,
choice,
});
if (pluginResolved) {
return pluginResolved.provider.id;
}
return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
}

View File

@@ -0,0 +1,2 @@
export { resolveProviderPluginChoice, runProviderModelSelectedHook } from "./provider-wizard.js";
export { resolvePluginProviders } from "./providers.js";

View File

@@ -0,0 +1,309 @@
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import {
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { enablePluginInConfig } from "./enable.js";
import {
applyDefaultModel,
mergeConfigPatch,
pickAuthMethod,
resolveProviderMatch,
} from "./provider-auth-choice-helpers.js";
import { applyAuthProfileConfig } from "./provider-auth-helpers.js";
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import { isRemoteEnvironment, openUrl } from "./setup-browser.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
export type ApplyProviderAuthChoiceParams = {
authChoice: string;
config: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
opts?: Partial<ProviderAuthOptionBag>;
};
export type ApplyProviderAuthChoiceResult = {
config: OpenClawConfig;
agentModelOverride?: string;
};
export type PluginProviderAuthChoiceOptions = {
authChoice: string;
pluginId: string;
providerId: string;
methodId?: string;
label: string;
};
function restoreConfiguredPrimaryModel(
nextConfig: OpenClawConfig,
originalConfig: OpenClawConfig,
): OpenClawConfig {
const originalModel = originalConfig.agents?.defaults?.model;
const nextAgents = nextConfig.agents;
const nextDefaults = nextAgents?.defaults;
if (!nextDefaults) {
return nextConfig;
}
if (originalModel !== undefined) {
return {
...nextConfig,
agents: {
...nextAgents,
defaults: {
...nextDefaults,
model: originalModel,
},
},
};
}
const { model: _model, ...restDefaults } = nextDefaults;
return {
...nextConfig,
agents: {
...nextAgents,
defaults: restDefaults,
},
};
}
async function loadPluginProviderRuntime() {
return import("./provider-auth-choice.runtime.js");
}
export async function runProviderPluginAuthMethod(params: {
config: OpenClawConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
method: ProviderAuthMethod;
agentDir?: string;
agentId?: string;
workspaceDir?: string;
emitNotes?: boolean;
secretInputMode?: ProviderAuthOptionBag["secretInputMode"];
allowSecretRefPrompt?: boolean;
opts?: Partial<ProviderAuthOptionBag>;
}): Promise<{ config: OpenClawConfig; defaultModel?: string }> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
const defaultAgentId = resolveDefaultAgentId(params.config);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId
? resolveOpenClawAgentDir()
: resolveAgentDir(params.config, agentId));
const workspaceDir =
params.workspaceDir ??
resolveAgentWorkspaceDir(params.config, agentId) ??
resolveDefaultAgentWorkspaceDir();
const result = await params.method.run({
config: params.config,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
opts: params.opts,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
let nextConfig = params.config;
if (result.configPatch) {
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
}
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
});
}
if (params.emitNotes !== false && result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}
return {
config: nextConfig,
defaultModel: result.defaultModel,
};
}
export async function applyAuthChoiceLoadedPluginProvider(
params: ApplyProviderAuthChoiceParams,
): Promise<ApplyProviderAuthChoiceResult | null> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
const workspaceDir =
resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir();
const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } =
await loadPluginProviderRuntime();
const providers = resolvePluginProviders({
config: params.config,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const resolved = resolveProviderPluginChoice({
providers,
choice: params.authChoice,
});
if (!resolved) {
return null;
}
const applied = await runProviderPluginAuthMethod({
config: params.config,
runtime: params.runtime,
prompter: params.prompter,
method: resolved.method,
agentDir: params.agentDir,
agentId: params.agentId,
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: false,
opts: params.opts,
});
let nextConfig = applied.config;
let agentModelOverride: string | undefined;
if (applied.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, applied.defaultModel);
await runProviderModelSelectedHook({
config: nextConfig,
model: applied.defaultModel,
prompter: params.prompter,
agentDir: params.agentDir,
workspaceDir,
});
await params.prompter.note(
`Default model set to ${applied.defaultModel}`,
"Model configured",
);
return { config: nextConfig };
}
nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config);
agentModelOverride = applied.defaultModel;
}
return { config: nextConfig, agentModelOverride };
}
export async function applyAuthChoicePluginProvider(
params: ApplyProviderAuthChoiceParams,
options: PluginProviderAuthChoiceOptions,
): Promise<ApplyProviderAuthChoiceResult | null> {
if (params.authChoice !== options.authChoice) {
return null;
}
const enableResult = enablePluginInConfig(params.config, options.pluginId);
let nextConfig = enableResult.config;
if (!enableResult.enabled) {
await params.prompter.note(
`${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`,
options.label,
);
return { config: nextConfig };
}
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveOpenClawAgentDir() : resolveAgentDir(nextConfig, agentId));
const workspaceDir =
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
const { resolvePluginProviders, runProviderModelSelectedHook } =
await loadPluginProviderRuntime();
const providers = resolvePluginProviders({
config: nextConfig,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {
await params.prompter.note(
`${options.label} auth plugin is not available. Enable it and re-run onboarding.`,
options.label,
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0];
if (!method) {
await params.prompter.note(`${options.label} auth method missing.`, options.label);
return { config: nextConfig };
}
const applied = await runProviderPluginAuthMethod({
config: nextConfig,
runtime: params.runtime,
prompter: params.prompter,
method,
agentDir,
agentId,
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: false,
opts: params.opts,
});
nextConfig = applied.config;
if (applied.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, applied.defaultModel);
await runProviderModelSelectedHook({
config: nextConfig,
model: applied.defaultModel,
prompter: params.prompter,
agentDir,
workspaceDir,
});
await params.prompter.note(
`Default model set to ${applied.defaultModel}`,
"Model configured",
);
return { config: nextConfig };
}
if (params.agentId) {
await params.prompter.note(
`Default model set to ${applied.defaultModel} for agent "${params.agentId}".`,
"Model configured",
);
}
nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config);
return { config: nextConfig, agentModelOverride: applied.defaultModel };
}
return { config: nextConfig };
}