diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index fc0656c0dd4..6adbb5d0f26 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c14f3c39f56..ec4084eeca6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 plugin’s own provider id or `{ providers }` for multi-provider discovery. - `discovery.order` controls when the provider runs relative to built-in diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 13758e7de46..a2491dfbd87 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -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) => diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 17ee1348de2..c0ae2c12210 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -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", diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 7386988a1f0..208b74fd09d 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -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), ); }); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index d3be2d6c131..f3391c2796e 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -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 ", "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, diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 5f4893b249c..76994d27b32 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -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( diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 798d8991199..bafa9122e25 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -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 { + const normalizedAuthChoice = + normalizeLegacyOnboardAuthChoice(params.authChoice) ?? params.authChoice; + const normalizedParams = + normalizedAuthChoice === params.authChoice + ? params + : { ...params, authChoice: normalizedAuthChoice }; const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ 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 }; } diff --git a/src/commands/auth-choice.preferred-provider.test.ts b/src/commands/auth-choice.preferred-provider.test.ts new file mode 100644 index 00000000000..6f84763a308 --- /dev/null +++ b/src/commands/auth-choice.preferred-provider.test.ts @@ -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", + ); + }); +}); diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 49251a88f87..a7faad5d3a4 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -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> = { - 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 { - 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; } diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 0657a77b3e1..b6ba81a432e 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -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", + }), + ); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ca56ee25275..8963557e80a 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -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); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index cf8267cebff..1f46ef28ba1 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -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 { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); - const deprecated = new Set(); - 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"); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 1ea838fdb27..6001ede2ea4 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -106,7 +106,12 @@ async function resolveModelsAuthContext(): Promise { 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; }; /** diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts index f0208aa1d2a..fe934aa6578 100644 --- a/src/plugins/provider-validation.test.ts +++ b/src/plugins/provider-validation.test.ts @@ -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", diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index 172fefc1777..f53abc8bd6d 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -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["setup"]; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): NonNullable["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 } : {}), diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index eff361ee1c9..d7b8348f9b2 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -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, }); }); diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index cbe90178056..0b95a07f2b5 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -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 }; } } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f533b1b80a1..6dc5788b9eb 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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. *