From 44f45d8729f538fdc76bb7dd04a62360d70ebc5f Mon Sep 17 00:00:00 2001 From: samson910022 Date: Sat, 13 Jun 2026 17:04:55 +0800 Subject: [PATCH] fix(models): bound model browse discovery (#92247) Bounds default model browsing to configured/read-only discovery while preserving explicit full-catalog browsing. Reuses prepared plugin metadata and auth state without triggering external CLI discovery on the picker hot path, while retaining provider normalization and canonical runtime aliases. Verified with focused model tests, official OpenAI and Anthropic transport suites, fresh live tool calls for both providers, a full build, AWS check:changed, remote Docker OpenAI tools E2E, and green PR CI. Fixes #91809. Co-authored-by: samson1357924 <98934496+samson1357924@users.noreply.github.com> --- src/agents/model-catalog-browse.test.ts | 22 ++++- src/agents/model-catalog-browse.ts | 18 ++-- src/agents/model-picker-visibility.ts | 9 +- src/agents/model-provider-auth.test.ts | 13 +++ src/agents/model-provider-auth.ts | 7 +- src/agents/model-runtime-aliases.test.ts | 19 +++- src/auto-reply/reply/commands-models.test.ts | 91 +++++++++++++++++++- src/auto-reply/reply/commands-models.ts | 68 +++++++++------ src/commands/models/list.rows.test.ts | 53 +++++++++++- src/commands/models/list.rows.ts | 22 ++--- src/plugins/provider-runtime.ts | 2 + 11 files changed, 264 insertions(+), 60 deletions(-) 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;