refactor(plugins): simplify provider auth choice metadata

This commit is contained in:
Peter Steinberger 2026-03-15 23:00:16 -07:00
parent c4b18ab3c9
commit ddd34b6cc3
No known key found for this signature in database
19 changed files with 415 additions and 98 deletions

View File

@ -42,8 +42,8 @@ Typical split:
- `auth[].run` / `auth[].runNonInteractive`: provider owns onboarding/login
flows for `openclaw onboard`, `openclaw models auth`, and headless setup
- `wizard.onboarding` / `wizard.modelPicker`: provider owns auth-choice labels,
hints, and setup entries in onboarding/model pickers
- `wizard.setup` / `wizard.modelPicker`: provider owns auth-choice labels,
legacy aliases, onboarding allowlist hints, and setup entries in onboarding/model pickers
- `catalog`: provider appears in `models.providers`
- `resolveDynamicModel`: provider accepts model ids not present in the local
static catalog yet

View File

@ -1275,6 +1275,7 @@ errors instead.
- `groupLabel`: group label
- `groupHint`: group hint
- `methodId`: auth method to run
- `modelAllowlist`: optional post-auth allowlist policy (`allowedKeys`, `initialSelections`, `message`)
`wizard.modelPicker` controls how a provider appears as a "set this up now"
entry in model selection:
@ -1435,8 +1436,13 @@ Notes:
for headless onboarding.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
- `wizard.setup` adds a provider choice to `openclaw onboard`.
- `wizard.setup` adds a provider choice to onboarding surfaces such as
`openclaw onboard` / `openclaw setup --wizard`.
- `wizard.setup.modelAllowlist` lets the provider narrow the follow-up model
allowlist prompt during onboarding/configure.
- `wizard.modelPicker` adds a “setup this provider” entry to the model picker.
- `deprecatedProfileIds` lets the provider own `openclaw doctor` cleanup for
retired auth-profile ids.
- `discovery.run` returns either `{ provider }` for the plugins own provider id
or `{ providers }` for multi-provider discovery.
- `discovery.order` controls when the provider runs relative to built-in

View File

@ -5,7 +5,11 @@ import {
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { listProfilesForProvider, upsertAuthProfile } from "../../src/agents/auth-profiles.js";
import {
CLAUDE_CLI_PROFILE_ID,
listProfilesForProvider,
upsertAuthProfile,
} from "../../src/agents/auth-profiles.js";
import { suggestOAuthProfileIdForLegacyDefault } from "../../src/agents/auth-profiles/repair.js";
import type { AuthProfileStore } from "../../src/agents/auth-profiles/types.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
@ -38,6 +42,13 @@ const ANTHROPIC_MODERN_MODEL_PREFIXES = [
"claude-sonnet-4-5",
"claude-haiku-4-5",
] as const;
const ANTHROPIC_OAUTH_ALLOWLIST = [
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
] as const;
function cloneFirstTemplateModel(params: {
modelId: string;
@ -309,6 +320,7 @@ const anthropicPlugin = {
label: "Anthropic",
docsPath: "/providers/models",
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID],
auth: [
{
id: "setup-token",
@ -322,6 +334,11 @@ const anthropicPlugin = {
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "setup-token + API key",
modelAllowlist: {
allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST],
initialSelections: ["anthropic/claude-sonnet-4-6"],
message: "Anthropic OAuth models",
},
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx),
runNonInteractive: async (ctx) =>

View File

@ -4,6 +4,7 @@ import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js";
import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js";
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js";
@ -194,6 +195,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
id: PROVIDER_ID,
label: "OpenAI Codex",
docsPath: "/providers/models",
deprecatedProfileIds: [CODEX_CLI_PROFILE_ID],
auth: [
{
id: "oauth",

View File

@ -2,18 +2,17 @@ import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
const githubCopilotLoginCommand = vi.fn();
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
const noopAsync = vi.fn(async () => undefined);
const modelsAuthLoginCommand = vi.fn().mockResolvedValue(undefined);
vi.mock("../commands/models.js", () => ({
githubCopilotLoginCommand,
modelsStatusCommand,
modelsAliasesAddCommand: noopAsync,
modelsAliasesListCommand: noopAsync,
modelsAliasesRemoveCommand: noopAsync,
modelsAuthAddCommand: noopAsync,
modelsAuthLoginCommand: noopAsync,
modelsAuthLoginCommand,
modelsAuthOrderClearCommand: noopAsync,
modelsAuthOrderGetCommand: noopAsync,
modelsAuthOrderSetCommand: noopAsync,
@ -42,7 +41,7 @@ describe("models cli", () => {
});
beforeEach(() => {
githubCopilotLoginCommand.mockClear();
modelsAuthLoginCommand.mockClear();
modelsStatusCommand.mockClear();
});
@ -74,9 +73,13 @@ describe("models cli", () => {
from: "user",
});
expect(githubCopilotLoginCommand).toHaveBeenCalledTimes(1);
expect(githubCopilotLoginCommand).toHaveBeenCalledWith(
expect.objectContaining({ yes: true }),
expect(modelsAuthLoginCommand).toHaveBeenCalledTimes(1);
expect(modelsAuthLoginCommand).toHaveBeenCalledWith(
expect.objectContaining({
provider: "github-copilot",
method: "device",
yes: true,
}),
expect.any(Object),
);
});

View File

@ -1,6 +1,5 @@
import type { Command } from "commander";
import {
githubCopilotLoginCommand,
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
@ -364,13 +363,13 @@ export function registerModelsCli(program: Command) {
auth
.command("login-github-copilot")
.description("Login to GitHub Copilot via GitHub device flow (TTY required)")
.option("--profile-id <id>", "Auth profile id (default: github-copilot:github)")
.option("--yes", "Overwrite existing profile without prompting", false)
.action(async (opts) => {
await runModelsCommand(async () => {
await githubCopilotLoginCommand(
await modelsAuthLoginCommand(
{
profileId: opts.profileId as string | undefined,
provider: "github-copilot",
method: "device",
yes: Boolean(opts.yes),
},
defaultRuntime,

View File

@ -117,7 +117,12 @@ export async function applyAuthChoiceLoadedPluginProvider(
resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir();
const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } =
await loadPluginProviderRuntime();
const providers = resolvePluginProviders({ config: params.config, workspaceDir });
const providers = resolvePluginProviders({
config: params.config,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const resolved = resolveProviderPluginChoice({
providers,
choice: params.authChoice,
@ -190,7 +195,12 @@ export async function applyAuthChoicePluginProvider(
const { resolvePluginProviders, runProviderModelSelectedHook } =
await loadPluginProviderRuntime();
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
const providers = resolvePluginProviders({
config: nextConfig,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {
await params.prompter.note(

View File

@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
@ -28,6 +29,12 @@ export type ApplyAuthChoiceResult = {
export async function applyAuthChoice(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult> {
const normalizedAuthChoice =
normalizeLegacyOnboardAuthChoice(params.authChoice) ?? params.authChoice;
const normalizedParams =
normalizedAuthChoice === params.authChoice
? params
: { ...params, authChoice: normalizedAuthChoice };
const handlers: Array<(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>> = [
applyAuthChoiceLoadedPluginProvider,
applyAuthChoiceAnthropic,
@ -38,11 +45,11 @@ export async function applyAuthChoice(
];
for (const handler of handlers) {
const result = await handler(params);
const result = await handler(normalizedParams);
if (result) {
return result;
}
}
return { config: params.config };
return { config: normalizedParams.config };
}

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveProviderPluginChoice = vi.hoisted(() => vi.fn());
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
vi.mock("../plugins/provider-wizard.js", () => ({
resolveProviderPluginChoice,
}));
vi.mock("../plugins/providers.js", () => ({
resolvePluginProviders,
}));
import { resolvePreferredProviderForAuthChoice } from "./auth-choice.preferred-provider.js";
describe("resolvePreferredProviderForAuthChoice", () => {
beforeEach(() => {
vi.clearAllMocks();
resolvePluginProviders.mockReturnValue([]);
resolveProviderPluginChoice.mockReturnValue(null);
});
it("normalizes legacy auth choices before plugin lookup", async () => {
resolveProviderPluginChoice.mockReturnValue({
provider: { id: "anthropic", label: "Anthropic", auth: [] },
method: { id: "setup-token", label: "setup-token", kind: "token" },
});
await expect(resolvePreferredProviderForAuthChoice({ choice: "claude-cli" })).resolves.toBe(
"anthropic",
);
expect(resolveProviderPluginChoice).toHaveBeenCalledWith(
expect.objectContaining({
choice: "setup-token",
}),
);
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
}),
);
});
it("falls back to static core choices when no provider plugin claims the choice", async () => {
await expect(resolvePreferredProviderForAuthChoice({ choice: "chutes" })).resolves.toBe(
"chutes",
);
});
});

View File

@ -1,15 +1,12 @@
import type { OpenClawConfig } from "../config/config.js";
import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js";
import type { AuthChoice } from "./onboard-types.js";
const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
oauth: "anthropic",
"setup-token": "anthropic",
"claude-cli": "anthropic",
chutes: "chutes",
token: "anthropic",
apiKey: "anthropic",
"openai-codex": "openai-codex",
"codex-cli": "openai-codex",
chutes: "chutes",
"openai-api-key": "openai",
"openrouter-api-key": "openrouter",
"kilocode-api-key": "kilocode",
@ -57,11 +54,7 @@ export async function resolvePreferredProviderForAuthChoice(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[params.choice];
if (preferred) {
return preferred;
}
const choice = normalizeLegacyOnboardAuthChoice(params.choice) ?? params.choice;
const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([
import("../plugins/provider-wizard.js"),
import("../plugins/providers.js"),
@ -70,9 +63,20 @@ export async function resolvePreferredProviderForAuthChoice(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
return resolveProviderPluginChoice({
const pluginResolved = resolveProviderPluginChoice({
providers,
choice: params.choice,
})?.provider.id;
choice,
});
if (pluginResolved) {
return pluginResolved.provider.id;
}
const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
if (preferred) {
return preferred;
}
return undefined;
}

View File

@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
promptModelAllowlist: vi.fn(),
promptDefaultModel: vi.fn(),
promptCustomApiConfig: vi.fn(),
resolvePluginProviders: vi.fn(() => []),
resolveProviderPluginChoice: vi.fn<() => unknown>(() => null),
}));
vi.mock("../agents/auth-profiles.js", () => ({
@ -39,6 +41,14 @@ vi.mock("./onboard-custom.js", () => ({
promptCustomApiConfig: mocks.promptCustomApiConfig,
}));
vi.mock("../plugins/providers.js", () => ({
resolvePluginProviders: mocks.resolvePluginProviders,
}));
vi.mock("../plugins/provider-wizard.js", () => ({
resolveProviderPluginChoice: mocks.resolveProviderPluginChoice,
}));
import { promptAuthConfig } from "./configure.gateway-auth.js";
function makeRuntime(): RuntimeEnv {
@ -94,6 +104,8 @@ async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false)
mocks.promptModelAllowlist.mockResolvedValue({
models: ["kilocode/kilo/auto"],
});
mocks.resolvePluginProviders.mockReturnValue([]);
mocks.resolveProviderPluginChoice.mockReturnValue(null);
return promptAuthConfig({}, makeRuntime(), noopPrompter);
}
@ -118,4 +130,31 @@ describe("promptAuthConfig", () => {
"MiniMax-M2.5",
]);
});
it("uses plugin-owned allowlist metadata for provider auth choices", async () => {
mocks.promptAuthChoiceGrouped.mockResolvedValue("token");
mocks.applyAuthChoice.mockResolvedValue({ config: {} });
mocks.promptModelAllowlist.mockResolvedValue({ models: undefined });
mocks.resolveProviderPluginChoice.mockReturnValue({
provider: { id: "anthropic", label: "Anthropic", auth: [] },
method: { id: "setup-token", label: "setup-token", kind: "token" },
wizard: {
modelAllowlist: {
allowedKeys: ["anthropic/claude-sonnet-4-6"],
initialSelections: ["anthropic/claude-sonnet-4-6"],
message: "Anthropic OAuth models",
},
},
});
await promptAuthConfig({}, makeRuntime(), noopPrompter);
expect(mocks.promptModelAllowlist).toHaveBeenCalledWith(
expect.objectContaining({
allowedKeys: ["anthropic/claude-sonnet-4-6"],
initialSelections: ["anthropic/claude-sonnet-4-6"],
message: "Anthropic OAuth models",
}),
);
});
});

View File

@ -2,6 +2,8 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";
import { resolveProviderPluginChoice } from "../plugins/provider-wizard.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
@ -30,13 +32,30 @@ function sanitizeTokenValue(value: unknown): string | undefined {
return trimmed;
}
const ANTHROPIC_OAUTH_MODEL_KEYS = [
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
];
function resolveProviderChoiceModelAllowlist(params: {
authChoice: string;
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}):
| {
allowedKeys?: string[];
initialSelections?: string[];
message?: string;
}
| undefined {
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
return resolveProviderPluginChoice({
providers,
choice: params.authChoice,
})?.wizard?.modelAllowlist;
}
export function buildGatewayAuthConfig(params: {
existing?: GatewayAuthConfig;
@ -125,16 +144,19 @@ export async function promptAuthConfig(
}
}
const anthropicOAuth =
authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth";
if (authChoice !== "custom-api-key") {
const modelAllowlist = resolveProviderChoiceModelAllowlist({
authChoice,
config: next,
workspaceDir: resolveDefaultAgentWorkspaceDir(),
env: process.env,
});
const allowlistSelection = await promptModelAllowlist({
config: next,
prompter,
allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined,
initialSelections: anthropicOAuth ? ["anthropic/claude-sonnet-4-6"] : undefined,
message: anthropicOAuth ? "Anthropic OAuth models" : undefined,
allowedKeys: modelAllowlist?.allowedKeys,
initialSelections: modelAllowlist?.initialSelections,
message: modelAllowlist?.message,
});
if (allowlistSelection.models) {
next = applyModelAllowlist(next, allowlistSelection.models);

View File

@ -15,6 +15,7 @@ import {
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import {
@ -119,30 +120,36 @@ export async function maybeRemoveDeprecatedCliAuthProfiles(
prompter: DoctorPrompter,
): Promise<OpenClawConfig> {
const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const deprecated = new Set<string>();
if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) {
deprecated.add(CLAUDE_CLI_PROFILE_ID);
}
if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) {
deprecated.add(CODEX_CLI_PROFILE_ID);
}
const providers = resolvePluginProviders({
config: cfg,
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const deprecatedEntries = providers.flatMap((provider) =>
(provider.deprecatedProfileIds ?? [])
.filter((profileId) => store.profiles[profileId] || cfg.auth?.profiles?.[profileId])
.map((profileId) => ({
profileId,
providerId: provider.id,
providerLabel: provider.label,
})),
);
const deprecated = new Set(deprecatedEntries.map((entry) => entry.profileId));
if (deprecated.size === 0) {
return cfg;
}
const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"];
if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) {
for (const entry of deprecatedEntries) {
const authCommand =
resolveProviderAuthLoginCommand({ provider: "anthropic" }) ??
formatCliCommand("openclaw configure");
lines.push(`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use ${authCommand}`);
}
if (deprecated.has(CODEX_CLI_PROFILE_ID)) {
const authCommand =
resolveProviderAuthLoginCommand({ provider: "openai-codex" }) ??
formatCliCommand("openclaw configure");
lines.push(`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use ${authCommand}`);
resolveProviderAuthLoginCommand({
provider: entry.providerId,
config: cfg,
env: process.env,
}) ?? formatCliCommand("openclaw configure");
lines.push(`- ${entry.profileId} (${entry.providerLabel}): use ${authCommand}`);
}
note(lines.join("\n"), "Auth profiles");

View File

@ -106,7 +106,12 @@ async function resolveModelsAuthContext(): Promise<ResolvedModelsAuthContext> {
const agentDir = resolveAgentDir(config, defaultAgentId);
const workspaceDir =
resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir();
const providers = resolvePluginProviders({ config, workspaceDir });
const providers = resolvePluginProviders({
config,
workspaceDir,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
return { config, agentDir, workspaceDir, providers };
}
@ -490,6 +495,7 @@ type LoginOptions = {
provider?: string;
method?: string;
setDefault?: boolean;
yes?: boolean;
};
/**

View File

@ -32,12 +32,21 @@ describe("normalizeRegisteredProvider", () => {
id: " demo ",
label: " Demo Provider ",
aliases: [" alias-one ", "alias-one", ""],
deprecatedProfileIds: [" demo:legacy ", "demo:legacy", ""],
envVars: [" DEMO_API_KEY ", "DEMO_API_KEY"],
auth: [
{
id: " primary ",
label: " Primary ",
kind: "custom",
wizard: {
choiceId: " demo-primary ",
modelAllowlist: {
allowedKeys: [" demo/model ", "demo/model"],
initialSelections: [" demo/model "],
message: " Demo models ",
},
},
run: async () => ({ profiles: [] }),
},
{
@ -66,8 +75,22 @@ describe("normalizeRegisteredProvider", () => {
id: "demo",
label: "Demo Provider",
aliases: ["alias-one"],
deprecatedProfileIds: ["demo:legacy"],
envVars: ["DEMO_API_KEY"],
auth: [{ id: "primary", label: "Primary" }],
auth: [
{
id: "primary",
label: "Primary",
wizard: {
choiceId: "demo-primary",
modelAllowlist: {
allowedKeys: ["demo/model"],
initialSelections: ["demo/model"],
message: "Demo models",
},
},
},
],
wizard: {
setup: {
choiceId: "demo-choice",

View File

@ -27,6 +27,80 @@ function normalizeTextList(values: string[] | undefined): string[] | undefined {
return normalized.length > 0 ? normalized : undefined;
}
function normalizeProviderWizardSetup(params: {
providerId: string;
pluginId: string;
source: string;
auth: ProviderAuthMethod[];
setup: NonNullable<ProviderPlugin["wizard"]>["setup"];
pushDiagnostic: (diag: PluginDiagnostic) => void;
}): NonNullable<ProviderPlugin["wizard"]>["setup"] {
const hasAuthMethods = params.auth.length > 0;
if (!params.setup) {
return undefined;
}
if (!hasAuthMethods) {
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`,
pushDiagnostic: params.pushDiagnostic,
});
return undefined;
}
const methodId = normalizeText(params.setup.methodId);
if (methodId && !params.auth.some((method) => method.id === methodId)) {
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`,
pushDiagnostic: params.pushDiagnostic,
});
}
return {
...(normalizeText(params.setup.choiceId)
? { choiceId: normalizeText(params.setup.choiceId) }
: {}),
...(normalizeText(params.setup.choiceLabel)
? { choiceLabel: normalizeText(params.setup.choiceLabel) }
: {}),
...(normalizeText(params.setup.choiceHint)
? { choiceHint: normalizeText(params.setup.choiceHint) }
: {}),
...(normalizeText(params.setup.groupId)
? { groupId: normalizeText(params.setup.groupId) }
: {}),
...(normalizeText(params.setup.groupLabel)
? { groupLabel: normalizeText(params.setup.groupLabel) }
: {}),
...(normalizeText(params.setup.groupHint)
? { groupHint: normalizeText(params.setup.groupHint) }
: {}),
...(methodId && params.auth.some((method) => method.id === methodId) ? { methodId } : {}),
...(params.setup.modelAllowlist
? {
modelAllowlist: {
...(normalizeTextList(params.setup.modelAllowlist.allowedKeys)
? { allowedKeys: normalizeTextList(params.setup.modelAllowlist.allowedKeys) }
: {}),
...(normalizeTextList(params.setup.modelAllowlist.initialSelections)
? {
initialSelections: normalizeTextList(
params.setup.modelAllowlist.initialSelections,
),
}
: {}),
...(normalizeText(params.setup.modelAllowlist.message)
? { message: normalizeText(params.setup.modelAllowlist.message) }
: {}),
},
}
: {}),
};
}
function normalizeProviderAuthMethods(params: {
providerId: string;
pluginId: string;
@ -60,11 +134,20 @@ function normalizeProviderAuthMethods(params: {
continue;
}
seenMethodIds.add(methodId);
const wizard = normalizeProviderWizardSetup({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
auth: [{ ...method, id: methodId }],
setup: method.wizard,
pushDiagnostic: params.pushDiagnostic,
});
normalized.push({
...method,
id: methodId,
label: normalizeText(method.label) ?? methodId,
...(normalizeText(method.hint) ? { hint: normalizeText(method.hint) } : {}),
...(wizard ? { wizard } : {}),
});
}
@ -92,37 +175,14 @@ function normalizeProviderWizard(params: {
if (!setup) {
return undefined;
}
if (!hasAuthMethods) {
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`,
pushDiagnostic: params.pushDiagnostic,
});
return undefined;
}
const methodId = normalizeText(setup.methodId);
if (methodId && !hasMethod(methodId)) {
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`,
pushDiagnostic: params.pushDiagnostic,
});
}
return {
...(normalizeText(setup.choiceId) ? { choiceId: normalizeText(setup.choiceId) } : {}),
...(normalizeText(setup.choiceLabel)
? { choiceLabel: normalizeText(setup.choiceLabel) }
: {}),
...(normalizeText(setup.choiceHint) ? { choiceHint: normalizeText(setup.choiceHint) } : {}),
...(normalizeText(setup.groupId) ? { groupId: normalizeText(setup.groupId) } : {}),
...(normalizeText(setup.groupLabel) ? { groupLabel: normalizeText(setup.groupLabel) } : {}),
...(normalizeText(setup.groupHint) ? { groupHint: normalizeText(setup.groupHint) } : {}),
...(methodId && hasMethod(methodId) ? { methodId } : {}),
};
return normalizeProviderWizardSetup({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
auth: params.auth,
setup,
pushDiagnostic: params.pushDiagnostic,
});
};
const normalizeModelPicker = () => {
@ -195,6 +255,7 @@ export function normalizeRegisteredProvider(params: {
});
const docsPath = normalizeText(params.provider.docsPath);
const aliases = normalizeTextList(params.provider.aliases);
const deprecatedProfileIds = normalizeTextList(params.provider.deprecatedProfileIds);
const envVars = normalizeTextList(params.provider.envVars);
const wizard = normalizeProviderWizard({
providerId: id,
@ -230,6 +291,7 @@ export function normalizeRegisteredProvider(params: {
label: normalizeText(params.provider.label) ?? id,
...(docsPath ? { docsPath } : {}),
...(aliases ? { aliases } : {}),
...(deprecatedProfileIds ? { deprecatedProfileIds } : {}),
...(envVars ? { envVars } : {}),
auth,
...(catalog ? { catalog } : {}),

View File

@ -61,6 +61,7 @@ describe("provider wizard boundaries", () => {
).toEqual({
provider,
method: provider.auth[0],
wizard: provider.wizard?.setup,
});
});
@ -101,6 +102,41 @@ describe("provider wizard boundaries", () => {
).toEqual({
provider,
method: provider.auth[0],
wizard: provider.auth[0]?.wizard,
});
});
it("returns method wizard metadata for canonical choices", () => {
const provider = makeProvider({
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "setup-token",
label: "setup-token",
kind: "token",
wizard: {
choiceId: "token",
modelAllowlist: {
allowedKeys: ["anthropic/claude-sonnet-4-6"],
initialSelections: ["anthropic/claude-sonnet-4-6"],
message: "Anthropic OAuth models",
},
},
run: vi.fn(),
},
],
});
expect(
resolveProviderPluginChoice({
providers: [provider],
choice: "token",
}),
).toEqual({
provider,
method: provider.auth[0],
wizard: provider.auth[0]?.wizard,
});
});

View File

@ -190,7 +190,11 @@ export function resolveProviderModelPickerEntries(params: {
export function resolveProviderPluginChoice(params: {
providers: ProviderPlugin[];
choice: string;
}): { provider: ProviderPlugin; method: ProviderAuthMethod } | null {
}): {
provider: ProviderPlugin;
method: ProviderAuthMethod;
wizard?: ProviderPluginWizardSetup;
} | null {
const choice = params.choice.trim();
if (!choice) {
return null;
@ -216,7 +220,7 @@ export function resolveProviderPluginChoice(params: {
const choiceId =
wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id);
if (normalizeChoiceId(choiceId) === choice) {
return { provider, method };
return { provider, method, wizard };
}
}
const setup = provider.wizard?.setup;
@ -225,7 +229,7 @@ export function resolveProviderPluginChoice(params: {
if (normalizeChoiceId(setupChoiceId) === choice) {
const method = resolveMethodById(provider, setup.methodId);
if (method) {
return { provider, method };
return { provider, method, wizard: setup };
}
}
}

View File

@ -553,6 +553,18 @@ export type ProviderPluginWizardSetup = {
groupLabel?: string;
groupHint?: string;
methodId?: string;
/**
* Optional model-allowlist prompt policy applied after this auth choice is
* selected in configure/onboarding flows.
*
* Keep this UI-facing and static. Provider logic that needs runtime state
* should stay in `run`/`runNonInteractive`.
*/
modelAllowlist?: {
allowedKeys?: string[];
initialSelections?: string[];
message?: string;
};
};
export type ProviderPluginWizardModelPicker = {
@ -773,6 +785,14 @@ export type ProviderPlugin = {
* bearer token (for example Gemini CLI's `{ token, projectId }` payload).
*/
formatApiKey?: (cred: AuthProfileCredential) => string;
/**
* Legacy auth-profile ids that should be retired by `openclaw doctor`.
*
* Use this when a provider plugin replaces an older core-managed profile id
* and wants cleanup/migration messaging to live with the provider instead of
* in hardcoded doctor tables.
*/
deprecatedProfileIds?: string[];
/**
* Provider-owned OAuth refresh.
*