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 f13a1e1502e..b779ec38de0 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ promptCustomApiConfig: vi.fn(), resolvePluginProviders: vi.fn(() => []), resolveProviderPluginChoice: vi.fn<() => unknown>(() => null), + loadStaticManifestCatalogRowsForList: vi.fn(() => []), resolvePreferredProviderForAuthChoice: vi.fn<() => Promise>( async () => undefined, ), @@ -52,8 +53,16 @@ vi.mock("../plugins/provider-wizard.js", () => ({ resolveProviderPluginChoice: mocks.resolveProviderPluginChoice, })); +vi.mock("./models/list.manifest-catalog.js", () => ({ + loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, +})); + import { promptAuthConfig } from "./configure.gateway-auth.js"; +beforeEach(() => { + mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); +}); + function makeRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -311,6 +320,88 @@ describe("promptAuthConfig", () => { ); }); + it("loads plugin catalog when the selected provider allowlist requires it", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("github-copilot"); + mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("github-copilot"); + mocks.applyAuthChoice.mockResolvedValue({ + config: { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-7" }, + models: { + "github-copilot/claude-opus-4.7": {}, + }, + }, + }, + }, + }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + mocks.resolveProviderPluginChoice.mockReturnValue({ + provider: { + id: "github-copilot", + label: "GitHub Copilot", + auth: [], + wizard: { + setup: { + modelAllowlist: { + loadCatalog: true, + }, + }, + }, + }, + method: { id: "device", label: "GitHub device login", kind: "device_code" }, + }); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(mocks.promptModelAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + preferredProvider: "github-copilot", + loadCatalog: true, + }), + ); + }); + + it("loads catalog when the selected provider has manifest catalog rows", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("github-copilot"); + mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("github-copilot"); + mocks.applyAuthChoice.mockResolvedValue({ + config: { + agents: { + defaults: { + models: { + "github-copilot/claude-opus-4.7": {}, + }, + }, + }, + }, + }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolveProviderPluginChoice.mockReturnValue(null); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([ + { + provider: "github-copilot", + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + ref: "github-copilot/claude-opus-4.7", + mergeKey: "github-copilot:claude-opus-4.7", + source: "manifest", + input: ["text"], + reasoning: false, + status: "available", + }, + ]); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + const call = mocks.promptModelAllowlist.mock.calls[0]?.[0]; + expect(call?.preferredProvider).toBe("github-copilot"); + expect(call?.loadCatalog).toBe(true); + }); + it("returns to auth selection when plugin install onboarding asks for a retry", async () => { vi.clearAllMocks(); mocks.promptAuthChoiceGrouped diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 24692137ad0..537c0053904 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -13,10 +13,18 @@ import { promptDefaultModel, promptModelAllowlist, } from "./model-picker.js"; +import { loadStaticManifestCatalogRowsForList } from "./models/list.manifest-catalog.js"; import { promptCustomApiConfig } from "./onboard-custom.js"; import { randomToken } from "./onboard-helpers.js"; type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; +type ProviderChoiceModelPrompt = { + provider?: string; + allowedKeys?: string[]; + initialSelections?: string[]; + message?: string; + loadCatalog?: boolean; +}; /** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */ function sanitizeTokenValue(value: unknown): string | undefined { @@ -35,16 +43,7 @@ async function resolveProviderChoiceModelPrompt(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): Promise< - | { - provider?: string; - allowedKeys?: string[]; - initialSelections?: string[]; - message?: string; - loadCatalog?: boolean; - } - | undefined -> { +}): Promise { const { resolvePluginProviders, resolveProviderPluginChoice } = await import("../plugins/provider-auth-choice.runtime.js"); const providers = resolvePluginProviders({ @@ -58,12 +57,11 @@ async function resolveProviderChoiceModelPrompt(params: { choice: params.authChoice, }); const wizard = resolved?.provider.wizard?.setup; - const provider = resolved?.provider.id; if (!wizard) { - return provider ? { provider } : undefined; + return resolved?.provider.id ? { provider: resolved.provider.id } : undefined; } return { - provider, + provider: resolved.provider.id, ...wizard.modelAllowlist, ...(wizard.modelSelection?.promptWhenAuthChoiceProvided === true ? { loadCatalog: true } : {}), }; @@ -73,7 +71,25 @@ function hasConfiguredProviderModels(cfg: OpenClawConfig, provider: string | und if (!provider) { return false; } - return (cfg.models?.providers?.[provider]?.models?.length ?? 0) > 0; + if ((cfg.models?.providers?.[provider]?.models?.length ?? 0) > 0) { + return true; + } + const providerPrefix = `${provider}/`; + return Object.keys(cfg.agents?.defaults?.models ?? {}).some((key) => + key.trim().startsWith(providerPrefix), + ); +} + +function hasStaticManifestCatalogRows(cfg: OpenClawConfig, provider: string | undefined): boolean { + if (!provider) { + return false; + } + return ( + loadStaticManifestCatalogRowsForList({ + cfg, + providerFilter: provider, + }).length > 0 + ); } function listConfiguredModelProviders(cfg: OpenClawConfig): string[] { @@ -240,7 +256,9 @@ export async function promptAuthConfig( message: modelPrompt?.message, preferredProvider: promptProvider, loadCatalog: - modelPrompt?.loadCatalog ?? hasConfiguredProviderModels(next, promptProvider) ?? false, + modelPrompt?.loadCatalog ?? + (hasConfiguredProviderModels(next, promptProvider) || + hasStaticManifestCatalogRows(next, promptProvider)), }); if (allowlistSelection.models) { next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index f462ee505bd..551482904e6 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -13,6 +13,11 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog, })); +const loadStaticManifestCatalogRowsForList = vi.hoisted(() => vi.fn(() => [])); +vi.mock("./models/list.manifest-catalog.js", () => ({ + loadStaticManifestCatalogRowsForList, +})); + const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, @@ -111,6 +116,7 @@ function configuredTextModel(id: string, name: string) { beforeEach(() => { vi.clearAllMocks(); + loadStaticManifestCatalogRowsForList.mockReturnValue([]); providerModelPickerContributionRuntime.enabled = false; resolveOwningPluginIdsForProvider.mockImplementation(({ provider }: { provider: string }) => { if (provider === "byteplus" || provider === "byteplus-plan") { @@ -565,6 +571,41 @@ describe("promptModelAllowlist", () => { expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]); }); + it("uses static manifest catalog rows for a preferred provider without loading runtime catalog", async () => { + loadStaticManifestCatalogRowsForList.mockReturnValue([ + { + provider: "github-copilot", + id: "gpt-5.4", + name: "GPT-5.4", + ref: "github-copilot/gpt-5.4", + mergeKey: "github-copilot:gpt-5.4", + source: "manifest", + input: ["text"], + reasoning: true, + status: "available", + }, + ]); + + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { agents: { defaults: {} } } as OpenClawConfig; + + await promptModelAllowlist({ + config, + prompter, + preferredProvider: "github-copilot", + }); + + expect(loadStaticManifestCatalogRowsForList).toHaveBeenCalledWith({ + cfg: config, + providerFilter: "github-copilot", + }); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect( + multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value), + ).toEqual(["github-copilot/gpt-5.4"]); + }); + it("uses configured provider models without loading the full catalog in replace mode", async () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index d184206a940..f31f621f4eb 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -2,6 +2,7 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth- import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { isModelPickerVisibleModelRef, isModelPickerVisibleProvider, @@ -16,6 +17,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../agents/model-selection.js"; +import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { resolveAgentModelFallbackValues, @@ -116,11 +118,38 @@ function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { .filter((key) => key.length > 0); } -function loadPickerModelCatalog(cfg: OpenClawConfig): ReturnType { +function toPickerCatalogEntry( + row: ReturnType[number], +): ModelCatalogEntry { + return { + id: row.id, + name: row.name, + provider: row.provider, + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + reasoning: row.reasoning, + input: row.input, + }; +} + +function loadPickerModelCatalog( + cfg: OpenClawConfig, + opts: { preferredProvider?: string } = {}, +): ReturnType { if (cfg.models?.mode === "replace") { return Promise.resolve(buildConfiguredModelCatalog({ cfg })); } - return loadModelCatalog({ config: cfg }); + if (opts.preferredProvider) { + const manifestRows = loadStaticManifestCatalogRowsForList({ + cfg, + providerFilter: opts.preferredProvider, + }); + if (manifestRows.length > 0) { + return Promise.resolve(manifestRows.map(toPickerCatalogEntry)); + } + } + return loadModelCatalog({ + config: cfg, + }); } function normalizeModelKeys(values: string[]): string[] { @@ -905,7 +934,7 @@ export async function promptModelAllowlist(params: { const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadPickerModelCatalog(cfg); + catalog = await loadPickerModelCatalog(cfg, { preferredProvider }); } finally { allowlistProgress.stop(); }