diff --git a/CHANGELOG.md b/CHANGELOG.md index f28860de015..de5fa94a078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd. - Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts new file mode 100644 index 00000000000..b6e61286b98 --- /dev/null +++ b/src/commands/auth-choice.model-check.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js"; +import { makePrompter } from "./setup/__tests__/test-utils.js"; + +const loadModelCatalog = vi.hoisted(() => vi.fn()); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog, +})); + +const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} }))); +const listProfilesForProvider = vi.hoisted(() => vi.fn(() => [])); +vi.mock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore, + listProfilesForProvider, +})); + +const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); +vi.mock("../agents/model-auth.js", () => ({ + resolveEnvApiKey, + hasUsableCustomProviderApiKey, +})); + +describe("warnIfModelConfigLooksOff", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadModelCatalog.mockResolvedValue([]); + }); + + it("skips catalog validation when requested while keeping auth checks", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(ensureAuthProfileStore).toHaveBeenCalledOnce(); + expect(listProfilesForProvider).toHaveBeenCalledWith( + expect.objectContaining({ profiles: {} }), + "openai-codex", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining('No auth configured for provider "openai-codex"'), + "Model check", + ); + }); + + it("keeps full catalog validation enabled by default", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter); + + expect(loadModelCatalog).toHaveBeenCalledWith({ + config, + useCache: false, + }); + }); +}); diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index 0cced85226f..8624f3547af 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -9,25 +9,27 @@ import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; export async function warnIfModelConfigLooksOff( config: OpenClawConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string }, + options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean }, ) { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); const warnings: string[] = []; - const catalog = await loadModelCatalog({ - config, - useCache: false, - }); - if (catalog.length > 0) { - const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, - ); - if (!known) { - warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + if (options?.validateCatalog !== false) { + const catalog = await loadModelCatalog({ + config, + useCache: false, + }); + if (catalog.length > 0) { + const known = catalog.some( + (entry) => entry.provider === ref.provider && entry.id === ref.model, ); + if (!known) { + warnings.push( + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + ); + } } } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 70407fa1d92..c6e7da673bf 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -233,6 +233,87 @@ describe("promptDefaultModel", () => { ); }); + it("keeps current preferred-provider models cold until browsing is requested", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]).toMatchObject({ + searchable: false, + initialValue: "__keep__", + }); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "__browse__" }), + ]); + }); + + it("loads the full model catalog when the user chooses to browse", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + }, + { + provider: "openai-codex", + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + }, + ]); + const select = vi + .fn() + .mockResolvedValueOnce("__browse__") + .mockImplementationOnce(async (params) => { + const option = params.options.find( + (entry: { value: string }) => entry.value === "openai-codex/gpt-5.5-pro", + ); + return option?.value ?? params.initialValue; + }); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result.model).toBe("openai-codex/gpt-5.5-pro"); + expect(loadModelCatalog).toHaveBeenCalledOnce(); + expect(select).toHaveBeenCalledTimes(2); + expect(select.mock.calls[1]?.[0]?.searchable).toBe(true); + }); + it("supports configuring vLLM during setup", async () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 28885a97186..6adfed8c619 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -33,6 +33,7 @@ export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; +const BROWSE_VALUE = "__browse__"; const PROVIDER_FILTER_THRESHOLD = 30; // Internal router models are valid defaults during auth/setup but not manual API targets. @@ -46,6 +47,7 @@ export type PromptDefaultModelParams = { includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; loadCatalog?: boolean; + browseCatalogOnDemand?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -508,20 +510,70 @@ export async function promptDefaultModel( const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; const loadCatalog = params.loadCatalog ?? true; + const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw ? normalizeProviderId(preferredProviderRaw) : undefined; const configuredRaw = resolveConfiguredModelRaw(cfg); + const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand; const resolved = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, + allowPluginNormalization: useStaticModelNormalization ? false : undefined, }); const resolvedKey = modelKey(resolved.provider, resolved.model); const configuredKey = configuredRaw ? resolvedKey : ""; + if ( + loadCatalog && + browseCatalogOnDemand && + preferredProvider && + allowKeep && + normalizeProviderId(resolved.provider) === preferredProvider + ) { + const options: WizardSelectOption[] = [ + { + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }, + ]; + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + options.push({ + value: BROWSE_VALUE, + label: "Browse all models", + hint: "loads provider catalogs", + }); + + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: KEEP_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + if (selection !== BROWSE_VALUE) { + return { model: selection }; + } + } + if (!loadCatalog) { const options: WizardSelectOption[] = []; if (allowKeep) { diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 088aedee456..8a178912854 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -118,13 +118,18 @@ const readConfigFileSnapshot = vi.hoisted(() => legacyIssues: [] as Array<{ path: string; message: string }>, })), ); +const createConfigIO = vi.hoisted(() => + vi.fn(() => ({ + readConfigFileSnapshot, + })), +); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const buildPluginCompatibilityNotices = vi.hoisted(() => +const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() => vi.fn((): PluginCompatibilityNotice[] => []), ); const formatPluginCompatibilityNotice = vi.hoisted(() => @@ -185,8 +190,8 @@ vi.mock("../commands/onboard-hooks.js", () => ({ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, + createConfigIO, resolveGatewayPort, - readConfigFileSnapshot, writeConfigFile, })); @@ -228,7 +233,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({ })); vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, })); @@ -405,6 +410,7 @@ describe("runSetupWizard", () => { const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = buildWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); + createConfigIO.mockClear(); ensureAuthProfileStore.mockClear(); await runSetupWizard( @@ -423,6 +429,7 @@ describe("runSetupWizard", () => { prompter, ); + expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" }); expect(select).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); @@ -623,6 +630,7 @@ describe("runSetupWizard", () => { it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); + warnIfModelConfigLooksOff.mockClear(); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "ollama", @@ -671,8 +679,14 @@ describe("runSetupWizard", () => { expect(promptDefaultModel).toHaveBeenCalledWith( expect.objectContaining({ allowKeep: false, + browseCatalogOnDemand: true, }), ); + expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ validateCatalog: false }), + ); }); it("re-prompts for auth when applyAuthChoice requests retry selection", async () => { @@ -744,7 +758,7 @@ describe("runSetupWizard", () => { }); it("shows plugin compatibility notices for an existing valid config", async () => { - buildPluginCompatibilityNotices.mockReturnValue([ + buildPluginCompatibilitySnapshotNotices.mockReturnValue([ { pluginId: "legacy-plugin", code: "legacy-before-agent-start", diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index fb280ae3c45..624600b7717 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -7,12 +7,12 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; +import { createConfigIO, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; import { - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -61,6 +61,10 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise 0) { await prompter.note( @@ -570,7 +574,7 @@ export async function runSetupWizard( } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); } break; } @@ -617,6 +621,7 @@ export async function runSetupWizard( ignoreAllowlist: true, includeProviderPluginSetups: true, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, workspaceDir, runtime, }); @@ -628,7 +633,7 @@ export async function runSetupWizard( } } - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; }