diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index 08dd0a5d3856..e03a99d6a5ac 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -68,19 +68,35 @@ describe("loadModelCatalogForBrowse", () => { expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false }); }); - it("uses the full catalog when configured visibility has provider wildcards", async () => { + it("uses the read-only catalog when configured visibility has provider wildcards", async () => { const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) => readOnly ? readOnlyCatalog : fullCatalog, ); await expect( loadModelCatalogForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }), + ).resolves.toBe(readOnlyCatalog); + + expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); + }); + + it("uses the full catalog for configured views with provider wildcards", async () => { + const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) => + readOnly ? readOnlyCatalog : fullCatalog, + ); + + await expect( + loadModelCatalogForBrowse({ + cfg: config({ providerWildcard: true }), + view: "configured", + loadCatalog, + }), ).resolves.toBe(fullCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false }); }); - it("returns an empty catalog when read-only catalog loading times out", async () => { + it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => { const onTimeout = vi.fn(); const timeoutHandle = { unref: vi.fn() } as unknown as NodeJS.Timeout; const clearTimeout = vi.fn(); @@ -94,7 +110,7 @@ describe("loadModelCatalogForBrowse", () => { const loadCatalog = vi.fn(() => new Promise(() => {})); const resultPromise = loadModelCatalogForBrowse({ - cfg: config(), + cfg: config({ providerWildcard: true }), loadCatalog, timeoutMs: 5, onTimeout, diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 77dfe254daf4..37ba09c39681 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -36,13 +36,6 @@ export function restoreModelCatalogBrowseTestDeps(): void { modelCatalogBrowseDeps.clearTimeout = globalThis.clearTimeout; } -function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number { - return ( - clampTimerTimeoutMs(value, 1) ?? - resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1) - ); -} - /** True when a browse view cannot be answered from read-only cached catalog entries. */ export function modelCatalogBrowseRequiresFullDiscovery(params: { cfg: OpenClawConfig; @@ -51,7 +44,15 @@ export function modelCatalogBrowseRequiresFullDiscovery(params: { const view = params.view ?? "default"; return ( view === "all" || - parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0 + (view === "configured" && + parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0) + ); +} + +function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number { + return ( + clampTimerTimeoutMs(value, 1) ?? + resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1) ); } @@ -65,7 +66,6 @@ export async function loadModelCatalogForBrowse(params: { }): Promise { const view = params.view ?? "default"; if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) { - // Wildcards depend on provider discovery; read-only cached entries can hide matching models. return await params.loadCatalog({ readOnly: false }); } diff --git a/src/agents/model-picker-visibility.ts b/src/agents/model-picker-visibility.ts index cb9b9e6567d1..09d77c748f4b 100644 --- a/src/agents/model-picker-visibility.ts +++ b/src/agents/model-picker-visibility.ts @@ -10,6 +10,11 @@ import { isCliRuntimeProvider } from "./model-runtime-aliases.js"; // model picker choices. Hide them while keeping real provider/model refs visible. const RETIRED_MODEL_PICKER_PROVIDERS = new Set(["codex", "codex-cli"]); +/** True for retired provider ids that should stay out of model selection surfaces. */ +export function isRetiredModelPickerProvider(provider: string): boolean { + return RETIRED_MODEL_PICKER_PROVIDERS.has(normalizeProviderId(provider)); +} + /** Creates a provider visibility predicate for model picker rendering. */ export function createModelPickerVisibleProviderPredicate( params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; includeSetupRegistry?: boolean } = {}, @@ -23,7 +28,7 @@ export function createModelPickerVisibleProviderPredicate( ); return (provider: string): boolean => { const normalized = normalizeProviderId(provider); - return !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && !cliRuntimeProviders.has(normalized); + return !isRetiredModelPickerProvider(normalized) && !cliRuntimeProviders.has(normalized); }; } @@ -31,7 +36,7 @@ export function createModelPickerVisibleProviderPredicate( export function isModelPickerVisibleProvider(provider: string): boolean { const normalized = normalizeProviderId(provider); return ( - !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && + !isRetiredModelPickerProvider(normalized) && !isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) ); } diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts index d234936dcbce..bccca65feeb7 100644 --- a/src/agents/model-provider-auth.test.ts +++ b/src/agents/model-provider-auth.test.ts @@ -234,6 +234,19 @@ describe("prepared provider auth state", () => { ).resolves.toBe(false); expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2); + // Bounded browse callers may explicitly consume the prepared broad answer + // while keeping slow fallback discovery disabled. + await expect( + hasAuthForModelProvider({ + provider: "openai", + cfg, + discoverExternalCliAuth: false, + allowPluginSyntheticAuth: false, + allowPreparedRuntimeAuth: true, + }), + ).resolves.toBe(true); + expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2); + // Broad-scope caller (default flags) still hits the prepared map. await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true); expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2); diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index f401c99b1e74..c527462397b0 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -127,6 +127,7 @@ export async function hasAuthForModelProvider(params: { store?: AuthProfileStore; allowPluginSyntheticAuth?: boolean; discoverExternalCliAuth?: boolean; + allowPreparedRuntimeAuth?: boolean; runtimeAuthLookup?: RuntimeProviderAuthLookup; resolveRuntimeAuthLookup?: () => RuntimeProviderAuthLookup; }): Promise { @@ -162,8 +163,8 @@ export async function hasAuthForModelProvider(params: { configFingerprint === preparedState.configFingerprint && workspaceDir === expectedWorkspaceDir && (params.agentDir === undefined || params.agentDir === expectedAgentDir) && - params.discoverExternalCliAuth !== false && - params.allowPluginSyntheticAuth !== false && + (params.allowPreparedRuntimeAuth === true || + (params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false)) && params.env === undefined && params.store === undefined && params.modelApi === undefined; @@ -227,6 +228,7 @@ export function createProviderAuthChecker(params: { env?: NodeJS.ProcessEnv; allowPluginSyntheticAuth?: boolean; discoverExternalCliAuth?: boolean; + allowPreparedRuntimeAuth?: boolean; }): (provider: string, modelApi?: string) => Promise { const authCache = new Map(); let runtimeAuthLookup: RuntimeProviderAuthLookup | undefined; @@ -247,6 +249,7 @@ export function createProviderAuthChecker(params: { env: params.env, allowPluginSyntheticAuth: params.allowPluginSyntheticAuth, discoverExternalCliAuth: params.discoverExternalCliAuth, + allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth, resolveRuntimeAuthLookup: () => (runtimeAuthLookup ??= createRuntimeProviderAuthLookup({ cfg: params.cfg, diff --git a/src/agents/model-runtime-aliases.test.ts b/src/agents/model-runtime-aliases.test.ts index 622ca386b04c..e7aa09b3c16f 100644 --- a/src/agents/model-runtime-aliases.test.ts +++ b/src/agents/model-runtime-aliases.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { testing as cliBackendsTesting } from "./cli-backends.js"; -import { createModelPickerVisibleProviderPredicate } from "./model-picker-visibility.js"; +import { + createModelPickerVisibleProviderPredicate, + isRetiredModelPickerProvider, +} from "./model-picker-visibility.js"; import { areRuntimeModelRefsEquivalent, isCliRuntimeProvider, @@ -169,6 +172,20 @@ describe("resolveCliRuntimeExecutionProvider", () => { expect(isCliRuntimeProvider("acme-cli")).toBe(false); expect(isVisibleProvider("acme-cli")).toBe(true); }); + + it("recognizes retired picker providers without loading CLI backend metadata", () => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => { + throw new Error("retired provider checks should not load setup metadata"); + }, + resolveRuntimeCliBackends: () => { + throw new Error("retired provider checks should not load runtime metadata"); + }, + }); + + expect(isRetiredModelPickerProvider("CODEX-CLI")).toBe(true); + expect(isRetiredModelPickerProvider("anthropic")).toBe(false); + }); }); describe("areRuntimeModelRefsEquivalent", () => { diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index 2ee12830af10..a784aecf64ee 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -29,6 +29,16 @@ const modelProviderAuthMocks = vi.hoisted(() => { return state; }); const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn()); +const pluginMetadataMocks = vi.hoisted(() => ({ + snapshot: undefined as + | { + plugins: unknown[]; + owners: { + cliBackends: Map; + }; + } + | undefined, +})); const MODELS_ADD_DEPRECATED_TEXT = "⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models."; @@ -83,6 +93,10 @@ vi.mock("../../agents/provider-model-normalization.runtime.js", () => ({ normalizeProviderModelIdWithRuntimeMock(params), })); +vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ + getCurrentPluginMetadataSnapshot: () => pluginMetadataMocks.snapshot, +})); + const telegramModelsTestPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ id: "telegram", @@ -160,6 +174,7 @@ beforeEach(() => { modelAuthLabelMocks.resolveModelAuthLabel.mockReset(); modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined); normalizeProviderModelIdWithRuntimeMock.mockReset(); + pluginMetadataMocks.snapshot = undefined; modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]); modelProviderAuthMocks.createProviderAuthChecker.mockClear(); const registry = createTestRegistry([ @@ -252,6 +267,12 @@ function firstAuthCheckerParams() { return modelProviderAuthMocks.createProviderAuthChecker.mock.calls[0]?.[0]; } +function preparedAuthCheckerParams() { + return modelProviderAuthMocks.createProviderAuthChecker.mock.calls + .map(([params]) => params) + .find((params) => params.allowPreparedRuntimeAuth === true); +} + describe("handleModelsCommand", () => { it("shows a simple providers menu on text surfaces", async () => { const result = await handleModelsCommand(buildParams("/models"), true); @@ -264,7 +285,7 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).toContain("Use: /models "); expect(result?.reply?.text).toContain("Switch: /model "); expect(result?.reply?.text).not.toContain("Add: /models add"); - const authCheckerParams = firstAuthCheckerParams(); + const authCheckerParams = preparedAuthCheckerParams(); expect(authCheckerParams?.workspaceDir).toBe("/tmp"); }); @@ -272,9 +293,10 @@ describe("handleModelsCommand", () => { await handleModelsCommand(buildParams("/models"), true); expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true); - const authCheckerParams = firstAuthCheckerParams(); + const authCheckerParams = preparedAuthCheckerParams(); expect(authCheckerParams?.allowPluginSyntheticAuth).toBe(false); expect(authCheckerParams?.discoverExternalCliAuth).toBe(false); + expect(authCheckerParams?.allowPreparedRuntimeAuth).toBe(true); }); it("does not block default browse when read-only catalog loading is slow", async () => { @@ -302,6 +324,25 @@ describe("handleModelsCommand", () => { expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false); }); + it("reuses the current plugin metadata snapshot for read-only catalog loading", async () => { + const metadataSnapshot = { + plugins: [], + owners: { + cliBackends: new Map(), + }, + }; + pluginMetadataMocks.snapshot = metadataSnapshot; + + await handleModelsCommand(buildParams("/models"), true); + + expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + readOnly: true, + metadataSnapshot, + }), + ); + }); + it("hides unauthenticated providers by default and keeps all as explicit browse", async () => { modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic"]); @@ -375,7 +416,7 @@ describe("handleModelsCommand", () => { true, ); - expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false); + expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true); expect(result?.reply?.text).toContain("- openai (2)"); expect(result?.reply?.text).toContain("- vllm (2)"); expect(result?.reply?.text).not.toContain("- anthropic"); @@ -449,6 +490,50 @@ describe("handleModelsCommand", () => { ]); }); + it("does not treat standalone CLI backends as canonical provider aliases", async () => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [ + { + id: "acme-cli", + pluginId: "acme", + config: { command: "acme" }, + bundleMcp: false, + }, + ], + }); + pluginMetadataMocks.snapshot = { + plugins: [], + owners: { + cliBackends: new Map([["acme-cli", "acme"]]), + }, + }; + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { provider: "acme-cli", id: "acme-model", name: "Acme Model" }, + ]); + modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "acme-cli"]); + + const data = await buildModelsProviderData({ + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-7" }, + models: { + "anthropic/*": {}, + }, + }, + }, + } as OpenClawConfig); + + expect(data.byProvider.has("acme-cli")).toBe(false); + }); + it("keeps non-CLI configured provider model lists scoped to user config", async () => { modelCatalogMocks.loadModelCatalog.mockResolvedValue([ { provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" }, diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index ad50c9d3fb52..a4cc2b665829 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -15,9 +15,8 @@ import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalogForBrowse } from "../../agents/model-catalog-browse.js"; import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; -import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js"; +import { isRetiredModelPickerProvider } from "../../agents/model-picker-visibility.js"; import { createProviderAuthChecker } from "../../agents/model-provider-auth.js"; -import { isCliRuntimeProvider } from "../../agents/model-runtime-aliases.js"; import { buildModelAliasIndex, normalizeProviderId, @@ -34,6 +33,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { resolveAgentRuntimeLabel } from "../../status/agent-runtime-label.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; @@ -78,15 +78,14 @@ type ParsedModelsCommand = }; function isModelsBrowseVisibleProvider(provider: string): boolean { - const normalized = normalizeProviderId(provider); - return ( - isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) || - isModelPickerVisibleProvider(normalized) - ); + return !isRetiredModelPickerProvider(provider); } -function usesUnfilteredCatalogModels(provider: string): boolean { - return isCliRuntimeProvider(provider, { includeSetupRegistry: true }); +function usesUnfilteredCatalogModels( + provider: string, + cliRuntimeProviders: ReadonlySet, +): boolean { + return cliRuntimeProviders.has(normalizeProviderId(provider)); } function normalizeRuntimeChoiceId(runtime: string | undefined): string { @@ -155,11 +154,24 @@ export async function buildModelsProviderData( cfg, agentId, }); + const workspaceDir = + options.workspaceDir ?? + (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? + resolveDefaultAgentWorkspaceDir(); + const metadataSnapshot = getCurrentPluginMetadataSnapshot({ + config: cfg, + workspaceDir, + env: process.env, + allowScopedSnapshot: true, + }); + const cliRuntimeProviders = new Set( + listCliRuntimeModelBackendBindings().map((binding) => normalizeProviderId(binding.runtime)), + ); const catalog = await loadModelCatalogForBrowse({ cfg, view: options.view ?? "default", - loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly }), + loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly, metadataSnapshot }), }); const visibilityPolicy = createModelVisibilityPolicy({ cfg, @@ -169,18 +181,27 @@ export async function buildModelsProviderData( agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); + const hasAuth: (provider: string) => Promise = + options.view === "all" + ? async () => true + : createProviderAuthChecker({ + cfg, + workspaceDir, + agentId, + allowPluginSyntheticAuth: false, + discoverExternalCliAuth: false, + allowPreparedRuntimeAuth: true, + }); const visibleCatalog = await resolveVisibleModelCatalog({ cfg, catalog, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, agentId, - workspaceDir: - options.workspaceDir ?? - (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? - resolveDefaultAgentWorkspaceDir(), + workspaceDir, view: options.view, runtimeAuthDiscovery: false, + providerAuthChecker: hasAuth, }); const aliasIndex = buildModelAliasIndex({ @@ -198,7 +219,7 @@ export async function buildModelsProviderData( } if ( restrictToProviderWildcards && - !usesUnfilteredCatalogModels(key) && + !usesUnfilteredCatalogModels(key, cliRuntimeProviders) && !visibilityPolicy.allows({ provider: key, model: m }) ) { return; @@ -258,20 +279,11 @@ export async function buildModelsProviderData( add(entry.provider, entry.id); } - const hasAuth: (provider: string) => Promise = - options.view === "all" - ? async () => true - : createProviderAuthChecker({ - cfg, - workspaceDir: - options.workspaceDir ?? - (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? - resolveDefaultAgentWorkspaceDir(), - agentId, - }); - for (const entry of catalog) { - if (usesUnfilteredCatalogModels(entry.provider) && (await hasAuth(entry.provider))) { + if ( + usesUnfilteredCatalogModels(entry.provider, cliRuntimeProviders) && + (await hasAuth(entry.provider)) + ) { add(entry.provider, entry.id); } } diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index c4e0c235c490..daaf1d44f28b 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -19,7 +19,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin, })); -import { appendProviderCatalogRows } from "./list.rows.js"; +import { appendConfiguredProviderRows, appendProviderCatalogRows } from "./list.rows.js"; const authIndex = { hasProviderAuth: (provider: string) => provider === "codex", @@ -79,6 +79,7 @@ describe("appendProviderCatalogRows", () => { models: { providers: {} }, }, }); + expect(mocks.normalizeProviderResolvedModelWithPlugin).not.toHaveBeenCalled(); const row = requireOnlyRow(rows); expect(row.key).toBe("codex/gpt-5.5"); expect(row.available).toBe(true); @@ -189,3 +190,53 @@ describe("appendProviderCatalogRows", () => { expect(row.tags).toEqual(["configured"]); }); }); + +describe("appendConfiguredProviderRows", () => { + it("keeps provider normalization for configured provider models", async () => { + mocks.normalizeProviderResolvedModelWithPlugin.mockReturnValueOnce({ + provider: "anthropic", + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + input: ["text", "image"], + contextWindow: 200_000, + } as never); + const rows: ModelRow[] = []; + + await appendConfiguredProviderRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + models: { + providers: { + anthropic: { + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + authIndex, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "anthropic", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(mocks.normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledOnce(); + expect(requireOnlyRow(rows).input).toBe("text+image"); + }); +}); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 57154308995a..8c1afd6a4cdb 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -145,6 +145,7 @@ function normalizeListRowWithProviderPlugin(params: { provider: params.model.provider, config: params.context.cfg, workspaceDir: params.context.workspaceDir, + pluginMetadataSnapshot: params.context.metadataSnapshot, context: { config: params.context.cfg, agentDir: params.context.agentDir, @@ -177,6 +178,7 @@ async function appendVisibleRow(params: { seenKeys?: Set; allowProviderAvailabilityFallback?: boolean; skipSuppression?: boolean; + normalizeWithProviderPlugin?: boolean; }): Promise { if (params.seenKeys?.has(params.key)) { return false; @@ -184,21 +186,18 @@ async function appendVisibleRow(params: { if (!matchesRowFilter(params.context, params.model)) { return false; } - const normalizedModel = normalizeListRowWithProviderPlugin({ - model: params.model, - context: params.context, - }); - // Normalize provider-owned runtime model ids before suppression/filtering so - // list output matches the model ids users can actually select. - if ( - !params.skipSuppression && - shouldSuppressListModel({ model: normalizedModel, context: params.context }) - ) { + const model = params.normalizeWithProviderPlugin + ? normalizeListRowWithProviderPlugin({ + model: params.model, + context: params.context, + }) + : params.model; + if (!params.skipSuppression && shouldSuppressListModel({ model, context: params.context })) { return false; } params.rows.push( await buildRow({ - model: normalizedModel, + model, key: params.key, context: params.context, allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback, @@ -375,6 +374,7 @@ export async function appendConfiguredProviderRows(params: { context: params.context, seenKeys: params.seenKeys, allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + normalizeWithProviderPlugin: true, }); } } diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e3dd03a758bd..d35201dce904 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -21,6 +21,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; +import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { clearProviderRuntimePluginCacheForTest, @@ -327,6 +328,7 @@ export function normalizeProviderResolvedModelWithPlugin(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginMetadataSnapshot?: PluginMetadataRegistryView; context: { config?: OpenClawConfig; agentDir?: string;