From 4c4eea97e98cf9f96417cdf97e02351fa6407e39 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 21 Mar 2026 08:30:55 -0700 Subject: [PATCH] fix(configure): tighten fresh setup provider UX --- ...re.gateway-auth.prompt-auth-config.test.ts | 20 ++++++++++- src/commands/configure.gateway-auth.ts | 13 ++++--- src/commands/doctor-memory-search.test.ts | 1 + src/commands/doctor-memory-search.ts | 4 +-- src/commands/model-picker.test.ts | 36 +++++++++++++++++++ src/commands/model-picker.ts | 34 ++++++++++++------ 6 files changed, 91 insertions(+), 17 deletions(-) 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 971429bb2bf..c83591396da 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({ promptCustomApiConfig: vi.fn(), resolvePluginProviders: vi.fn(() => []), resolveProviderPluginChoice: vi.fn<() => unknown>(() => null), + resolvePreferredProviderForAuthChoice: vi.fn<() => Promise>( + async () => undefined, + ), })); vi.mock("../agents/auth-profiles.js", () => ({ @@ -25,7 +28,7 @@ vi.mock("./auth-choice-prompt.js", () => ({ vi.mock("./auth-choice.js", () => ({ applyAuthChoice: mocks.applyAuthChoice, - resolvePreferredProviderForAuthChoice: vi.fn(async () => undefined), + resolvePreferredProviderForAuthChoice: mocks.resolvePreferredProviderForAuthChoice, })); vi.mock("./model-picker.js", async (importActual) => { @@ -157,4 +160,19 @@ describe("promptAuthConfig", () => { }), ); }); + + it("scopes the allowlist picker to the selected provider when available", async () => { + mocks.promptAuthChoiceGrouped.mockResolvedValue("openai-api-key"); + mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("openai"); + mocks.applyAuthChoice.mockResolvedValue({ config: {} }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(mocks.promptModelAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + preferredProvider: "openai", + }), + ); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 8963557e80a..98598583df4 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -110,6 +110,13 @@ export async function promptAuthConfig( }); let next = cfg; + const preferredProvider = + authChoice === "skip" + ? undefined + : await resolvePreferredProviderForAuthChoice({ + choice: authChoice, + config: cfg, + }); if (authChoice === "custom-api-key") { const customResult = await promptCustomApiConfig({ prompter, runtime, config: next }); next = customResult.config; @@ -129,10 +136,7 @@ export async function promptAuthConfig( allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: true, - preferredProvider: await resolvePreferredProviderForAuthChoice({ - choice: authChoice, - config: next, - }), + preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime, }); @@ -157,6 +161,7 @@ export async function promptAuthConfig( allowedKeys: modelAllowlist?.allowedKeys, initialSelections: modelAllowlist?.initialSelections, message: modelAllowlist?.message, + preferredProvider, }); if (allowlistSelection.models) { next = applyModelAllowlist(next, allowlistSelection.models); diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 0c01c1c7688..529787f2154 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -263,6 +263,7 @@ describe("noteMemorySearchHealth", () => { // provider: "local". So with no local file and no API keys, warn. expect(note).toHaveBeenCalledTimes(1); const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("needs at least one embedding provider"); expect(message).toContain("openclaw configure --section model"); }); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 4dd2914613f..34f75dcfc11 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -139,8 +139,8 @@ export async function noteMemorySearchHealth( note( [ - "Memory search is enabled but no embedding provider is configured.", - "Semantic recall will not work without an embedding provider.", + "Memory search is enabled, but no embedding provider is ready.", + "Semantic recall needs at least one embedding provider.", gatewayProbeWarning ? gatewayProbeWarning : null, "", "Fix (pick one):", diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index fc09d5a7f3c..7c6df642639 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -181,6 +181,42 @@ describe("promptModelAllowlist", () => { "anthropic/claude-opus-4-5", ]); }); + + it("scopes the initial allowlist picker to the preferred provider", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + { + provider: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + }, + { + provider: "openai", + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + }, + ]); + + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { agents: { defaults: {} } } as OpenClawConfig; + + await promptModelAllowlist({ + config, + prompter, + preferredProvider: "openai", + }); + + const options = multiselect.mock.calls[0]?.[0]?.options ?? []; + expect(options.map((opt: { value: string }) => opt.value)).toEqual([ + "openai/gpt-5.4", + "openai/gpt-5.4-mini", + ]); + }); }); describe("router model filtering", () => { diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index cea263f7e58..6517ec6a268 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -162,6 +162,16 @@ function addModelSelectOption(params: { params.seen.add(key); } +function matchesPreferredProvider(entryProvider: string, preferredProvider: string): boolean { + if (preferredProvider === "volcengine") { + return entryProvider === "volcengine" || entryProvider === "volcengine-plan"; + } + if (preferredProvider === "byteplus") { + return entryProvider === "byteplus" || entryProvider === "byteplus-plan"; + } + return entryProvider === preferredProvider; +} + async function promptManualModel(params: { prompter: WizardPrompter; allowBlank: boolean; @@ -261,15 +271,7 @@ export async function promptDefaultModel( } if (hasPreferredProvider && preferredProvider) { - models = models.filter((entry) => { - if (preferredProvider === "volcengine") { - return entry.provider === "volcengine" || entry.provider === "volcengine-plan"; - } - if (preferredProvider === "byteplus") { - return entry.provider === "byteplus" || entry.provider === "byteplus-plan"; - } - return entry.provider === preferredProvider; - }); + models = models.filter((entry) => matchesPreferredProvider(entry.provider, preferredProvider)); } const agentDir = params.agentDir; @@ -429,11 +431,16 @@ export async function promptModelAllowlist(params: { agentDir?: string; allowedKeys?: string[]; initialSelections?: string[]; + preferredProvider?: string; }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; + const preferredProviderRaw = params.preferredProvider?.trim(); + const preferredProvider = preferredProviderRaw + ? normalizeProviderId(preferredProviderRaw) + : undefined; const resolved = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, @@ -477,9 +484,16 @@ export async function promptModelAllowlist(params: { const options: WizardSelectOption[] = []; const seen = new Set(); - const filteredCatalog = allowedKeySet + const allowedCatalog = allowedKeySet ? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id))) : catalog; + const filteredCatalog = + preferredProvider && + allowedCatalog.some((entry) => matchesPreferredProvider(entry.provider, preferredProvider)) + ? allowedCatalog.filter((entry) => + matchesPreferredProvider(entry.provider, preferredProvider), + ) + : allowedCatalog; for (const entry of filteredCatalog) { addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth });