refactor(plugins): simplify provider auth choice metadata
This commit is contained in:
parent
c4b18ab3c9
commit
ddd34b6cc3
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
50
src/commands/auth-choice.preferred-provider.test.ts
Normal file
50
src/commands/auth-choice.preferred-provider.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user