mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix: keep onboarding model prompts scoped
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ export async function promptAuthConfig(
|
||||
prompter,
|
||||
allowKeep: true,
|
||||
ignoreAllowlist: true,
|
||||
includeProviderPluginSetups: true,
|
||||
includeProviderPluginSetups: false,
|
||||
loadCatalog: false,
|
||||
preferredProvider,
|
||||
workspaceDir: resolveDefaultAgentWorkspaceDir(),
|
||||
runtime,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string>;
|
||||
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
|
||||
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<ReturnType<typeof loadModelCatalog>>;
|
||||
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<PromptModelAllowlistResult> {
|
||||
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<string>();
|
||||
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<ReturnType<typeof loadModelCatalog>>;
|
||||
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<string>();
|
||||
const allowedCatalog = (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string>();
|
||||
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
|
||||
NodeJS.ProcessEnv,
|
||||
Map<string, string[]>
|
||||
>();
|
||||
let catalogHookProviderIdCacheByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
|
||||
>();
|
||||
|
||||
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
@@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void {
|
||||
catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
}
|
||||
|
||||
function clearCatalogHookProviderIdCache(): void {
|
||||
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
catalogHookProviderIdCacheByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
|
||||
>();
|
||||
}
|
||||
|
||||
function resolveCatalogHookProviderIdCacheBucket(params: {
|
||||
config?: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Map<string, string[]> {
|
||||
if (!params.config) {
|
||||
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string[]>();
|
||||
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
|
||||
if (!envBuckets) {
|
||||
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
|
||||
}
|
||||
let bucket = envBuckets.get(params.env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string[]>();
|
||||
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,
|
||||
|
||||
@@ -557,7 +557,8 @@ export async function runSetupWizard(
|
||||
prompter,
|
||||
allowKeep: true,
|
||||
ignoreAllowlist: true,
|
||||
includeProviderPluginSetups: true,
|
||||
includeProviderPluginSetups: false,
|
||||
loadCatalog: false,
|
||||
workspaceDir,
|
||||
runtime,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user