diff --git a/CHANGELOG.md b/CHANGELOG.md index 094bc06d8b5..f28860de015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index a507356f80a..ff0f5407f14 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -133,7 +133,8 @@ export async function promptAuthConfig( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 93f63f95a16..70407fa1d92 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -360,6 +360,40 @@ describe("promptDefaultModel", () => { expect.arrayContaining([expect.objectContaining({ value: "legacy-entry" })]), ); }); + + it("keeps skip-auth model selection cold when catalog loading is disabled", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + loadCatalog: false, + agentDir: "/tmp/openclaw-agent", + runtime: {} as never, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled(); + expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "openai/gpt-5.5" }), + ]); + }); }); describe("promptModelAllowlist", () => { @@ -607,6 +641,63 @@ describe("promptModelAllowlist", () => { scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"], }); }); + + it("uses configured provider-scoped seeds without loading the full catalog", async () => { + const multiselect = vi.fn(async (params) => params.initialValues ?? []); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + preferredProvider: "openai-codex", + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(multiselect.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "openai-codex/gpt-5.5" }), + ]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5"], + scopeKeys: ["openai-codex/gpt-5.5"], + }); + }); + + it("uses explicit allowed model keys without loading the full catalog", async () => { + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + allowedKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + preferredProvider: "openai-codex", + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect( + multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value), + ).toEqual(["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + scopeKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + }); + }); }); describe("runtime model picker visibility", () => { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index bc3e7d7274e..28885a97186 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -45,6 +45,7 @@ export type PromptDefaultModelParams = { includeManual?: boolean; includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; + loadCatalog?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -229,6 +230,45 @@ function addModelSelectOption(params: { params.seen.add(key); } +function splitModelKey(key: string): { provider: string; id: string } | undefined { + const slashIndex = key.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= key.length - 1) { + return undefined; + } + return { + provider: key.slice(0, slashIndex), + id: key.slice(slashIndex + 1), + }; +} + +function addModelKeySelectOption(params: { + key: string; + options: WizardSelectOption[]; + seen: Set; + aliasIndex: ReturnType; + hasAuth: (provider: string) => boolean; + fallbackHint: string; +}) { + const entry = splitModelKey(params.key); + if (!entry) { + return; + } + const before = params.seen.size; + addModelSelectOption({ + entry, + options: params.options, + seen: params.seen, + aliasIndex: params.aliasIndex, + hasAuth: params.hasAuth, + }); + if (params.seen.size > before) { + const option = params.options.at(-1); + if (option && !option.hint) { + option.hint = params.fallbackHint; + } + } +} + function createPreferredProviderMatcher(params: { preferredProvider: string; cfg: OpenClawConfig; @@ -467,6 +507,7 @@ export async function promptDefaultModel( const allowKeep = params.allowKeep ?? true; const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; + const loadCatalog = params.loadCatalog ?? true; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw @@ -481,10 +522,58 @@ export async function promptDefaultModel( const resolvedKey = modelKey(resolved.provider, resolved.model); const configuredKey = configuredRaw ? resolvedKey : ""; + if (!loadCatalog) { + const options: WizardSelectOption[] = []; + if (allowKeep) { + options.push({ + 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" }); + } + if (configuredKey && !options.some((option) => option.value === configuredKey)) { + options.push({ + value: configuredKey, + label: configuredKey, + hint: "current", + }); + } + if (options.length === 0) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: allowKeep, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + return { model: selection }; + } + const catalogProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { catalogProgress.stop(); } @@ -650,6 +739,7 @@ export async function promptModelAllowlist(params: { }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); + const configuredRaw = resolveConfiguredModelRaw(cfg); const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); @@ -685,11 +775,71 @@ export async function promptModelAllowlist(params: { ...fallbackKeys, ...(params.initialSelections ?? []), ]); + const hasRealSeed = + existingKeys.length > 0 || + fallbackKeys.length > 0 || + (params.initialSelections?.length ?? 0) > 0 || + configuredRaw.length > 0; + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + const matchesPreferredProvider = preferredProvider + ? createPreferredProviderMatcher({ + preferredProvider, + cfg, + }) + : undefined; + + const scopedFastKeys = + allowedKeys.length > 0 + ? allowedKeys + : preferredProvider && hasRealSeed + ? initialSeeds.filter((key) => { + const entry = splitModelKey(key); + return entry ? matchesPreferredProvider?.(entry.provider) === true : false; + }) + : []; + if (scopedFastKeys.length > 0) { + const scopeKeys = allowedKeys.length > 0 ? allowedKeys : scopedFastKeys; + const scopeKeySet = new Set(scopeKeys); + const initialKeys = normalizeModelKeys(initialSeeds.filter((key) => scopeKeySet.has(key))); + const options: WizardSelectOption[] = []; + const seen = new Set(); + for (const key of scopeKeys) { + addModelKeySelectOption({ + key, + options, + seen, + aliasIndex, + hasAuth, + fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured", + }); + } + if (options.length === 0) { + return {}; + } + const selection = await params.prompter.multiselect({ + message: params.message ?? "Models in /model picker (multi-select)", + options, + initialValues: initialKeys.length > 0 ? initialKeys : undefined, + searchable: true, + }); + const selected = normalizeModelKeys(selection); + if (selected.length > 0) { + return { models: selected, scopeKeys }; + } + const confirmScopedClear = await params.prompter.confirm({ + message: "Remove these provider models from the /model picker?", + initialValue: false, + }); + if (!confirmScopedClear) { + return {}; + } + return { models: [], scopeKeys }; + } const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { allowlistProgress.stop(); } @@ -713,14 +863,6 @@ export async function promptModelAllowlist(params: { return { models: normalizeModelKeys(parsed) }; } - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); - const matchesPreferredProvider = preferredProvider - ? createPreferredProviderMatcher({ - preferredProvider, - cfg, - }) - : undefined; - const options: WizardSelectOption[] = []; const seen = new Set(); const allowedCatalog = ( diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9f996af4c54..86d2db022ea 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1766,6 +1766,8 @@ describe("provider-runtime", () => { cache: false, }), ); + expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); }); it("does not stack-overflow when provider hook resolution reenters the same plugin load", () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 87180cf72c3..4c1ed4b5d4e 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -14,9 +14,8 @@ import { sanitizeForLog } from "../terminal/ansi.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { __testing as providerHookRuntimeTesting, - clearProviderRuntimeHookCache, + clearProviderRuntimeHookCache as clearProviderHookRuntimeCache, prepareProviderExtraParams, - resetProviderRuntimeHookCacheForTest, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, @@ -34,6 +33,7 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, } from "./providers.js"; +import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; import type { @@ -86,6 +86,14 @@ import type { const log = createSubsystemLogger("plugins/provider-runtime"); const warnedExternalAuthFallbackPluginIds = new Set(); let catalogHookProvidersCache = new WeakMap>(); +let catalogHookProviderIdCacheWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void { catalogHookProvidersCache = new WeakMap>(); } +function clearCatalogHookProviderIdCache(): void { + catalogHookProviderIdCacheWithoutConfig = new WeakMap>(); + catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + +function resolveCatalogHookProviderIdCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Map { + if (!params.config) { + let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + catalogHookProviderIdCacheByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildCatalogHookProviderIdCacheKey(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const { roots } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`; +} + +function resolveCachedCatalogHookProviderPluginIds(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string[] { + const env = params.env ?? process.env; + const bucket = resolveCatalogHookProviderIdCacheBucket({ + config: params.config, + env, + }); + const key = buildCatalogHookProviderIdCacheKey({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + const cached = bucket.get(key); + if (cached) { + return cached; + } + const resolved = resolveCatalogHookProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + bucket.set(key, resolved); + return resolved; +} + +export function clearProviderRuntimeHookCache(): void { + resetCatalogHookProvidersCacheForTest(); + clearCatalogHookProviderIdCache(); + clearProviderHookRuntimeCache(); +} + +export function resetProviderRuntimeHookCacheForTest(): void { + clearProviderRuntimeHookCache(); +} + export { - clearProviderRuntimeHookCache, prepareProviderExtraParams, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, - resetProviderRuntimeHookCacheForTest, resolveProviderRuntimePlugin, wrapProviderStreamFn, }; @@ -147,6 +237,7 @@ export const __testing = { ...providerHookRuntimeTesting, resetExternalAuthFallbackWarningCacheForTest, resetCatalogHookProvidersCacheForTest, + resetProviderRuntimeHookCacheForTest, } as const; function resolveProviderPluginsForCatalogHooks(params: { @@ -169,7 +260,7 @@ function resolveProviderPluginsForCatalogHooks(params: { if (cached) { return cached; } - const onlyPluginIds = resolveCatalogHookProviderPluginIds({ + const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({ config: params.config, workspaceDir, env, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 8058a539de9..fb280ae3c45 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -557,7 +557,8 @@ export async function runSetupWizard( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, workspaceDir, runtime, });