refactor(plugins): move provider onboarding auth into plugins

This commit is contained in:
Peter Steinberger
2026-03-15 22:42:58 -07:00
parent 0b58a1cc13
commit 55cbfb6e6a
12 changed files with 420 additions and 34 deletions

View File

@@ -0,0 +1,152 @@
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js";
import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js";
import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SecretInput } from "../config/types.secrets.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import type {
ProviderAuthMethod,
ProviderAuthMethodNonInteractiveContext,
ProviderPluginWizardSetup,
} from "./types.js";
type ProviderApiKeyAuthMethodOptions = {
providerId: string;
methodId: string;
label: string;
hint?: string;
wizard?: ProviderPluginWizardSetup;
optionKey: string;
flagName: `--${string}`;
envVar: string;
promptMessage: string;
profileId?: string;
defaultModel?: string;
expectedProviders?: string[];
metadata?: Record<string, string>;
noteMessage?: string;
noteTitle?: string;
applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
};
function resolveStringOption(opts: Record<string, unknown> | undefined, optionKey: string) {
return normalizeOptionalSecretInput(opts?.[optionKey]);
}
function resolveProfileId(params: { providerId: string; profileId?: string }) {
return params.profileId?.trim() || `${params.providerId}:default`;
}
function applyApiKeyConfig(params: {
ctx: ProviderAuthMethodNonInteractiveContext;
providerId: string;
profileId: string;
applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
}) {
const next = applyAuthProfileConfig(params.ctx.config, {
profileId: params.profileId,
provider: params.providerId,
mode: "api_key",
});
return params.applyConfig ? params.applyConfig(next) : next;
}
export function createProviderApiKeyAuthMethod(
params: ProviderApiKeyAuthMethodOptions,
): ProviderAuthMethod {
return {
id: params.methodId,
label: params.label,
hint: params.hint,
kind: "api_key",
wizard: params.wizard,
run: async (ctx) => {
const opts = ctx.opts as Record<string, unknown> | undefined;
const flagValue = resolveStringOption(opts, params.optionKey);
let capturedSecretInput: SecretInput | undefined;
let capturedMode: "plaintext" | "ref" | undefined;
await ensureApiKeyFromOptionEnvOrPrompt({
token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token),
tokenProvider: flagValue
? params.providerId
: normalizeOptionalSecretInput(ctx.opts?.tokenProvider),
secretInputMode:
ctx.allowSecretRefPrompt === false
? (ctx.secretInputMode ?? "plaintext")
: ctx.secretInputMode,
config: ctx.config,
expectedProviders: params.expectedProviders ?? [params.providerId],
provider: params.providerId,
envLabel: params.envVar,
promptMessage: params.promptMessage,
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: ctx.prompter,
noteMessage: params.noteMessage,
noteTitle: params.noteTitle,
setCredential: async (apiKey, mode) => {
capturedSecretInput = apiKey;
capturedMode = mode;
},
});
if (!capturedSecretInput) {
throw new Error(`Missing API key input for provider "${params.providerId}".`);
}
return {
profiles: [
{
profileId: resolveProfileId(params),
credential: buildApiKeyCredential(
params.providerId,
capturedSecretInput,
params.metadata,
capturedMode ? { secretInputMode: capturedMode } : undefined,
),
},
],
...(params.defaultModel ? { defaultModel: params.defaultModel } : {}),
};
},
runNonInteractive: async (ctx) => {
const opts = ctx.opts as Record<string, unknown> | undefined;
const resolved = await ctx.resolveApiKey({
provider: params.providerId,
flagValue: resolveStringOption(opts, params.optionKey),
flagName: params.flagName,
envVar: params.envVar,
});
if (!resolved) {
return null;
}
const profileId = resolveProfileId(params);
if (resolved.source !== "profile") {
const credential = ctx.toApiKeyCredential({
provider: params.providerId,
resolved,
...(params.metadata ? { metadata: params.metadata } : {}),
});
if (!credential) {
return null;
}
upsertAuthProfile({
profileId,
credential,
agentDir: ctx.agentDir,
});
}
return applyApiKeyConfig({
ctx,
providerId: params.providerId,
profileId,
applyConfig: params.applyConfig,
});
},
};
}

View File

@@ -64,6 +64,46 @@ describe("provider wizard boundaries", () => {
});
});
it("builds wizard options from method-level metadata", () => {
const provider = makeProvider({
id: "openai",
label: "OpenAI",
auth: [
{
id: "api-key",
label: "OpenAI API key",
kind: "api_key",
wizard: {
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
},
run: vi.fn(),
},
],
});
resolvePluginProviders.mockReturnValue([provider]);
expect(resolveProviderWizardOptions({})).toEqual([
{
value: "openai-api-key",
label: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
},
]);
expect(
resolveProviderPluginChoice({
providers: [provider],
choice: "openai-api-key",
}),
).toEqual({
provider,
method: provider.auth[0],
});
});
it("builds model-picker entries from plugin metadata and provider-method choices", () => {
const provider = makeProvider({
id: "sglang",

View File

@@ -61,6 +61,17 @@ function resolveMethodById(
return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId);
}
function listMethodWizardSetups(provider: ProviderPlugin): Array<{
method: ProviderAuthMethod;
wizard: ProviderPluginWizardSetup;
}> {
return provider.auth
.map((method) => (method.wizard ? { method, wizard: method.wizard } : null))
.filter((entry): entry is { method: ProviderAuthMethod; wizard: ProviderPluginWizardSetup } =>
Boolean(entry),
);
}
function buildSetupOptionForMethod(params: {
provider: ProviderPlugin;
wizard: ProviderPluginWizardSetup;
@@ -93,6 +104,20 @@ export function resolveProviderWizardOptions(params: {
const options: ProviderWizardOption[] = [];
for (const provider of providers) {
const methodSetups = listMethodWizardSetups(provider);
for (const { method, wizard } of methodSetups) {
options.push(
buildSetupOptionForMethod({
provider,
wizard,
method,
value: wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id),
}),
);
}
if (methodSetups.length > 0) {
continue;
}
const setup = provider.wizard?.setup;
if (!setup) {
continue;
@@ -187,6 +212,13 @@ export function resolveProviderPluginChoice(params: {
}
for (const provider of params.providers) {
for (const { method, wizard } of listMethodWizardSetups(provider)) {
const choiceId =
wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id);
if (normalizeChoiceId(choiceId) === choice) {
return { provider, method };
}
}
const setup = provider.wizard?.setup;
if (setup) {
const setupChoiceId = resolveWizardSetupChoiceId(provider, setup);

View File

@@ -119,6 +119,15 @@ export type ProviderAuthContext = {
workspaceDir?: string;
prompter: WizardPrompter;
runtime: RuntimeEnv;
/**
* Optional onboarding CLI options that triggered this auth flow.
*
* Present for setup/configure/auth-choice flows so provider methods can
* honor preseeded flags like `--openai-api-key` or generic
* `--token/--token-provider` pairs. Direct `models auth login` usually
* leaves this undefined.
*/
opts?: Partial<OnboardOptions>;
/**
* Onboarding secret persistence preference.
*
@@ -187,6 +196,14 @@ export type ProviderAuthMethod = {
label: string;
hint?: string;
kind: ProviderAuthKind;
/**
* Optional wizard/onboarding metadata for this specific auth method.
*
* Use this when one provider exposes multiple setup entries (for example API
* key + OAuth, or region-specific login flows). OpenClaw uses this to expose
* method-specific auth choices while keeping the provider id stable.
*/
wizard?: ProviderPluginWizardSetup;
run: (ctx: ProviderAuthContext) => Promise<ProviderAuthResult>;
runNonInteractive?: (
ctx: ProviderAuthMethodNonInteractiveContext,