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

@@ -19,10 +19,12 @@ import {
import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js";
import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js";
import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { ProviderAuthResult } from "../../src/plugins/types.js";
import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js";
const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
@@ -313,6 +315,14 @@ const anthropicPlugin = {
label: "setup-token (claude)",
hint: "Paste a setup-token from `claude setup-token`",
kind: "token",
wizard: {
choiceId: "token",
choiceLabel: "Anthropic token (paste setup-token)",
choiceHint: "Run `claude setup-token` elsewhere, then paste the token here",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "setup-token + API key",
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx),
runNonInteractive: async (ctx) =>
await runAnthropicSetupTokenNonInteractive({
@@ -322,15 +332,26 @@ const anthropicPlugin = {
agentDir: ctx.agentDir,
}),
},
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Anthropic API key",
hint: "Direct Anthropic API key",
optionKey: "anthropicApiKey",
flagName: "--anthropic-api-key",
envVar: "ANTHROPIC_API_KEY",
promptMessage: "Enter Anthropic API key",
defaultModel: DEFAULT_ANTHROPIC_MODEL,
expectedProviders: ["anthropic"],
wizard: {
choiceId: "apiKey",
choiceLabel: "Anthropic API key",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "setup-token + API key",
},
}),
],
wizard: {
setup: {
choiceId: "token",
choiceLabel: "Anthropic token (paste setup-token)",
choiceHint: "Run `claude setup-token` elsewhere, then paste the token here",
methodId: "setup-token",
},
},
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
capabilities: {
providerFamily: "anthropic",

View File

@@ -3,7 +3,12 @@ import {
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
} from "../../src/commands/google-gemini-model-default.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
@@ -19,7 +24,28 @@ const googlePlugin = {
label: "Google AI Studio",
docsPath: "/providers/models",
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: "google",
methodId: "api-key",
label: "Google Gemini API key",
hint: "AI Studio / Gemini API key",
optionKey: "geminiApiKey",
flagName: "--gemini-api-key",
envVar: "GEMINI_API_KEY",
promptMessage: "Enter Gemini API key",
defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL,
expectedProviders: ["google"],
applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next,
wizard: {
choiceId: "gemini-api-key",
choiceLabel: "Google Gemini API key",
groupId: "google",
groupLabel: "Google",
groupHint: "Gemini API key + OAuth",
},
}),
],
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),

View File

@@ -12,7 +12,12 @@ import {
buildMinimaxPortalProvider,
buildMinimaxProvider,
} from "../../src/agents/models-config.providers.static.js";
import {
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
} from "../../src/commands/onboard-auth.config-minimax.js";
import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js";
const API_PROVIDER_ID = "minimax";
@@ -160,7 +165,54 @@ const minimaxPlugin = {
label: PROVIDER_LABEL,
docsPath: "/providers/minimax",
envVars: ["MINIMAX_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: API_PROVIDER_ID,
methodId: "api-global",
label: "MiniMax API key (Global)",
hint: "Global endpoint - api.minimax.io",
optionKey: "minimaxApiKey",
flagName: "--minimax-api-key",
envVar: "MINIMAX_API_KEY",
promptMessage:
"Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key",
profileId: "minimax:global",
defaultModel: modelRef(DEFAULT_MODEL),
expectedProviders: ["minimax"],
applyConfig: (cfg) => applyMinimaxApiConfig(cfg),
wizard: {
choiceId: "minimax-global-api",
choiceLabel: "MiniMax API key (Global)",
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
}),
createProviderApiKeyAuthMethod({
providerId: API_PROVIDER_ID,
methodId: "api-cn",
label: "MiniMax API key (CN)",
hint: "CN endpoint - api.minimaxi.com",
optionKey: "minimaxApiKey",
flagName: "--minimax-api-key",
envVar: "MINIMAX_API_KEY",
promptMessage:
"Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key",
profileId: "minimax:cn",
defaultModel: modelRef(DEFAULT_MODEL),
expectedProviders: ["minimax", "minimax-cn"],
applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg),
wizard: {
choiceId: "minimax-cn-api",
choiceLabel: "MiniMax API key (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
}),
],
catalog: {
order: "simple",
run: async (ctx) => resolveApiCatalog(ctx),
@@ -190,6 +242,14 @@ const minimaxPlugin = {
label: "MiniMax OAuth (Global)",
hint: "Global endpoint - api.minimax.io",
kind: "device_code",
wizard: {
choiceId: "minimax-global-oauth",
choiceLabel: "MiniMax OAuth (Global)",
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
run: createOAuthHandler("global"),
},
{
@@ -197,6 +257,14 @@ const minimaxPlugin = {
label: "MiniMax OAuth (CN)",
hint: "CN endpoint - api.minimaxi.com",
kind: "device_code",
wizard: {
choiceId: "minimax-cn-oauth",
choiceLabel: "MiniMax OAuth (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
run: createOAuthHandler("cn"),
},
],

View File

@@ -4,6 +4,11 @@ import {
} from "openclaw/plugin-sdk/core";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/model-selection.js";
import {
applyOpenAIConfig,
OPENAI_DEFAULT_MODEL,
} from "../../src/commands/openai-model-default.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import {
cloneFirstTemplateModel,
@@ -89,7 +94,28 @@ export function buildOpenAIProvider(): ProviderPlugin {
label: "OpenAI",
docsPath: "/providers/models",
envVars: ["OPENAI_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenAI API key",
hint: "Direct OpenAI API key",
optionKey: "openaiApiKey",
flagName: "--openai-api-key",
envVar: "OPENAI_API_KEY",
promptMessage: "Enter OpenAI API key",
defaultModel: OPENAI_DEFAULT_MODEL,
expectedProviders: ["openai"],
applyConfig: (cfg) => applyOpenAIConfig(cfg),
wizard: {
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Codex OAuth + API key",
},
}),
],
resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx),
normalizeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {

View File

@@ -44,6 +44,7 @@ export async function runProviderPluginAuthMethod(params: {
emitNotes?: boolean;
secretInputMode?: OnboardOptions["secretInputMode"];
allowSecretRefPrompt?: boolean;
opts?: Partial<OnboardOptions>;
}): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
const defaultAgentId = resolveDefaultAgentId(params.config);
@@ -64,6 +65,7 @@ export async function runProviderPluginAuthMethod(params: {
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
opts: params.opts,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote,
@@ -134,6 +136,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: true,
opts: params.opts,
});
let agentModelOverride: string | undefined;
@@ -213,6 +216,7 @@ export async function applyAuthChoicePluginProvider(
workspaceDir,
secretInputMode: params.opts?.secretInputMode,
allowSecretRefPrompt: true,
opts: params.opts,
});
nextConfig = applied.config;

View File

@@ -74,7 +74,7 @@ function resolveApiKeySecretInput(
return normalized;
}
function buildApiKeyCredential(
export function buildApiKeyCredential(
provider: string,
input: SecretInput,
metadata?: Record<string, string>,

View File

@@ -32,11 +32,11 @@ function createRuntime() {
}
describe("applyNonInteractiveAuthChoice", () => {
it("resolves builtin API key auth before plugin provider resolution", async () => {
it("resolves plugin provider auth before builtin API key fallbacks", async () => {
const runtime = createRuntime();
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const resolvedConfig = { auth: { profiles: { "openai:default": { mode: "api_key" } } } };
applySimpleNonInteractiveApiKeyChoice.mockResolvedValueOnce(resolvedConfig as never);
applyNonInteractivePluginProviderChoice.mockResolvedValueOnce(resolvedConfig as never);
const result = await applyNonInteractiveAuthChoice({
nextConfig,
@@ -47,7 +47,7 @@ describe("applyNonInteractiveAuthChoice", () => {
});
expect(result).toBe(resolvedConfig);
expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledOnce();
expect(applyNonInteractivePluginProviderChoice).not.toHaveBeenCalled();
expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce();
expect(applySimpleNonInteractiveApiKeyChoice).not.toHaveBeenCalled();
});
});

View File

@@ -156,6 +156,24 @@ export async function applyNonInteractiveAuthChoice(params: {
return null;
}
const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({
nextConfig,
authChoice,
opts,
runtime,
baseConfig,
resolveApiKey: (input) =>
resolveApiKey({
...input,
cfg: baseConfig,
runtime,
}),
toApiKeyCredential,
});
if (pluginProviderChoice !== undefined) {
return pluginProviderChoice;
}
const simpleApiKeyChoice = await applySimpleNonInteractiveApiKeyChoice({
authChoice,
nextConfig,
@@ -406,24 +424,6 @@ export async function applyNonInteractiveAuthChoice(params: {
}
}
const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({
nextConfig,
authChoice,
opts,
runtime,
baseConfig,
resolveApiKey: (input) =>
resolveApiKey({
...input,
cfg: baseConfig,
runtime,
}),
toApiKeyCredential,
});
if (pluginProviderChoice !== undefined) {
return pluginProviderChoice;
}
if (
authChoice === "oauth" ||
authChoice === "chutes" ||

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,