From aa0bad1ebe0ec0a094acb0bd04fb7def94f79c47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 15:26:37 -0700 Subject: [PATCH] fix(models): honor replace mode in list output (#103103) * fix(models): honor replace mode in list output * fix(models): make replace rows provider-authoritative --- src/commands/models/list.configured.test.ts | 133 ++++++++++++++++++ .../list.list-command.forward-compat.test.ts | 111 ++++++++++++--- src/commands/models/list.row-sources.ts | 10 ++ src/commands/models/list.rows.test.ts | 23 +++ src/commands/models/list.rows.ts | 40 +++++- 5 files changed, 296 insertions(+), 21 deletions(-) diff --git a/src/commands/models/list.configured.test.ts b/src/commands/models/list.configured.test.ts index 77e599391939..0578a9749c39 100644 --- a/src/commands/models/list.configured.test.ts +++ b/src/commands/models/list.configured.test.ts @@ -44,6 +44,7 @@ import { appendConfiguredModelRowSources } from "./list.row-sources.js"; import type { ModelRow } from "./list.types.js"; afterEach(() => { + vi.clearAllMocks(); vi.unstubAllEnvs(); }); @@ -203,6 +204,138 @@ describe("resolveConfiguredEntries", () => { }); describe("configured model list rows", () => { + it("keeps raw alias auth for self-prefixed implicit models in replace mode", async () => { + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions")); + const catalogEntry = { + id: "glm-4.7", + name: "GLM 4.7", + provider: "zai", + input: ["text"] as const, + contextWindow: 128_000, + }; + mocks.loadPreparedModelCatalogSnapshot.mockResolvedValue({ + entries: [catalogEntry], + routeVariants: [catalogEntry], + }); + const cfg = { + agents: { + defaults: { + model: { primary: "z.ai/glm-4.7", fallbacks: ["google/gemini-stale"] }, + models: { "openai/gpt-stale": {} }, + }, + }, + models: { + mode: "replace" as const, + providers: { + "z.ai": { + baseUrl: "https://api.z.ai/v1", + models: [ + { + id: "z.ai/glm-4.7", + name: "GLM 4.7", + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "z.ai/glm-4.8", + name: "GLM 4.8", + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + }; + const { entries } = resolveConfiguredEntries(cfg); + const evaluateModelAuth = vi.fn((provider: string) => ({ + availability: provider === "z.ai", + routeResolution: null, + })); + const rows: ModelRow[] = []; + + await appendConfiguredModelRowSources({ + rows, + entries, + context: { + cfg, + agentDir: "/tmp/openclaw-agent", + authIndex: { evaluateModelAuth }, + configuredByKey: new Map(entries.map((entry) => [entry.key, entry])), + discoveredKeys: new Set(), + filter: {}, + skipRuntimeModelSuppression: true, + }, + }); + + expect(rows.map((row) => row.key)).toEqual(["zai/glm-4.7", "zai/glm-4.8"]); + expect(rows[0]).toMatchObject({ name: "GLM 4.7", available: true }); + expect(rows[1]).toMatchObject({ name: "GLM 4.8", available: true }); + expect(mocks.loadPreparedModelCatalogSnapshot).not.toHaveBeenCalled(); + expect(evaluateModelAuth).toHaveBeenCalledWith( + "z.ai", + expect.objectContaining({ modelId: "glm-4.7" }), + ); + expect(evaluateModelAuth).toHaveBeenCalledWith( + "z.ai", + expect.objectContaining({ modelId: "glm-4.8" }), + ); + }); + + it("drops a stale default outside models.providers in replace mode", async () => { + const cfg = { + agents: { defaults: { model: { primary: "google/gemini-stale" } } }, + models: { + mode: "replace" as const, + providers: { + xiaomi: { + baseUrl: "https://api.xiaomi.example/v1", + models: [ + { + id: "mimo-v2.5", + name: "MiMo V2.5", + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + }; + const { entries } = resolveConfiguredEntries(cfg); + const rows: ModelRow[] = []; + + await appendConfiguredModelRowSources({ + rows, + entries, + context: { + cfg, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ availability: true, routeResolution: null }), + }, + configuredByKey: new Map(entries.map((entry) => [entry.key, entry])), + discoveredKeys: new Set(), + filter: {}, + skipRuntimeModelSuppression: true, + }, + }); + + expect(rows).toMatchObject([ + { key: "xiaomi/mimo-v2.5", name: "MiMo V2.5", tags: [], available: true }, + ]); + expect(mocks.loadPreparedModelCatalogSnapshot).not.toHaveBeenCalled(); + }); + it("renders plugin-catalog metadata for a fallback ref instead of default placeholders", async () => { const catalogEntry = { id: "k3", diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 4640ccec955a..aab20e541091 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -769,13 +769,23 @@ describe("modelsListCommand forward-compat", () => { }); }); - it("includes configured provider and auth-backed catalog rows in configured-mode lists", async () => { + it.each([ + { + mode: "merge" as const, + expectedKeys: ["xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "google/gemini-3.1-flash-lite"], + }, + { + mode: "replace" as const, + expectedKeys: ["xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5"], + }, + ])("honors $mode mode in configured lists", async ({ mode, expectedKeys }) => { const config = { agents: { defaults: { model: { primary: "xiaomi/mimo-v2.5-pro" } } }, models: { + mode, providers: { xiaomi: { - api: "openai-completions", + ...(mode === "merge" ? { api: "openai-completions" as const } : {}), apiKey: "tp-fixture", baseUrl: "https://api.xiaomi.example/v1", models: [ @@ -815,6 +825,22 @@ describe("modelsListCommand forward-compat", () => { tags: new Set(["default"]), aliases: [], }, + ...(mode === "replace" + ? [ + { + key: "google/gemini-stale", + ref: { provider: "google", model: "gemini-stale" }, + tags: new Set(["fallback#1"]), + aliases: [], + }, + { + key: "openai/gpt-stale", + ref: { provider: "openai", model: "gpt-stale" }, + tags: new Set(["configured"]), + aliases: [], + }, + ] + : []), ], }); mocks.loadModelCatalog.mockResolvedValueOnce([ @@ -831,25 +857,76 @@ describe("modelsListCommand forward-compat", () => { await modelsListCommand({ json: true }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); - expect(mocks.loadModelCatalog).toHaveBeenCalledWith( - expect.objectContaining({ - providerDiscoveryProviderIds: ["google", "openai", "xiaomi"], - providerRuntimeDiscoveryProviderIds: [], - providerManifestFallbackProviderIds: ["google", "openai"], - }), - ); + if (mode === "merge") { + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: ["google", "openai", "xiaomi"], + providerRuntimeDiscoveryProviderIds: [], + providerManifestFallbackProviderIds: ["google", "openai"], + }), + ); + } else { + expect(mocks.loadModelCatalog).not.toHaveBeenCalled(); + } const rows = lastPrintedRows<{ key: string; name: string; available: boolean }>(); - expectRowKeys(rows, [ - "xiaomi/mimo-v2.5-pro", - "xiaomi/mimo-v2.5", - "google/gemini-3.1-flash-lite", - ]); + expectRowKeys(rows, expectedKeys); expectRowFields(rows, "xiaomi/mimo-v2.5-pro", { name: "MiMo V2.5 Pro" }); expectRowFields(rows, "xiaomi/mimo-v2.5", { name: "MiMo V2.5" }); - expectRowFields(rows, "google/gemini-3.1-flash-lite", { - name: "Gemini 3.1 Flash Lite", - available: true, + if (mode === "merge") { + expectRowFields(rows, "google/gemini-3.1-flash-lite", { + name: "Gemini 3.1 Flash Lite", + available: true, + }); + } + }); + + it.each([ + { name: "--all", options: { all: true } }, + { name: "--provider", options: { provider: "google" } }, + ])("keeps explicit $name browsing in replace mode", async ({ options }) => { + const config = { + agents: { defaults: { model: { primary: "xiaomi/mimo-v2.5-pro" } } }, + models: { + mode: "replace" as const, + providers: { + xiaomi: { + baseUrl: "https://api.xiaomi.example/v1", + models: [{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", input: ["text"] }], + }, + }, + }, + }; + mocks.loadModelsConfigWithSource.mockResolvedValueOnce({ + sourceConfig: config, + resolvedConfig: config, + diagnostics: [], }); + mocks.resolveConfiguredEntries.mockReturnValueOnce({ + entries: [ + { + key: "xiaomi/mimo-v2.5-pro", + ref: { provider: "xiaomi", model: "mimo-v2.5-pro" }, + tags: new Set(["default"]), + aliases: [], + }, + ], + }); + mocks.loadModelCatalog.mockResolvedValueOnce([ + { + provider: "google", + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + input: ["text"], + contextWindow: 1_000_000, + }, + ]); + const runtime = createRuntime(); + + await modelsListCommand({ ...options, json: true }, runtime as never); + + expect(lastPrintedRows<{ key: string }>().map((row) => row.key)).toContain( + "google/gemini-3.1-flash-lite", + ); }); it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index 19bfb51b3624..533b5138e30d 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -70,6 +70,16 @@ export async function appendConfiguredModelRowSources(params: { modelRegistry?: ModelRegistry; context: RowBuilderContext; }): Promise { + if (params.context.cfg.models?.mode === "replace") { + // In replace mode models.providers is the complete catalog. Starting from + // default/fallback refs would reintroduce rows absent from that catalog. + await appendConfiguredProviderRows({ + rows: params.rows, + context: params.context, + seenKeys: new Set(), + }); + return; + } // Configured rows are emitted first for tag ordering, so they must read the // same committed generation the catalog rows below use; otherwise a ref that // only exists in a plugin catalog renders default placeholder metadata. diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 8fa2a4db789f..dbaa33223aa9 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -948,6 +948,29 @@ describe("appendConfiguredProviderRows", () => { }); describe("appendAuthenticatedCatalogRows", () => { + it("does not append authenticated catalog rows in replace mode", async () => { + const rows: ModelRow[] = []; + + await appendAuthenticatedCatalogRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { models: { mode: "replace" } }, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ availability: true, routeResolution: null }), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: {}, + }, + }); + + expect(rows).toEqual([]); + expect(mocks.loadModelCatalogSnapshot).not.toHaveBeenCalled(); + expect(mocks.loadScopedModelCatalogSnapshot).not.toHaveBeenCalled(); + }); + it("keeps runnable synthetic local catalog rows", async () => { const entries = [ { diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index ee7097e57d71..d38792635ff1 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -3,13 +3,17 @@ import { normalizeProviderId, normalizeProviderIdForAuth, } from "@openclaw/model-catalog-core/provider-id"; +import { stripSelfProviderModelPrefix } from "@openclaw/model-catalog-core/provider-model-id-normalization"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { projectModelCatalogEntryForRoute, resolveConfiguredModelCatalogOverrides, } from "../../agents/model-catalog-route.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; -import { modelKey } from "../../agents/model-ref-shared.js"; +import { + modelKey, + normalizeConfiguredProviderCatalogModelId, +} from "../../agents/model-ref-shared.js"; import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; import { shouldSuppressBuiltInModel, @@ -512,25 +516,50 @@ export async function appendConfiguredProviderRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { + const replaceMode = params.context.cfg.models?.mode === "replace"; for (const [provider, providerConfig] of Object.entries( params.context.cfg.models?.providers ?? {}, )) { for (const configuredModel of providerConfig.models ?? []) { - if (!shouldListConfiguredProviderModel({ providerConfig, model: configuredModel })) { + if ( + !replaceMode && + !shouldListConfiguredProviderModel({ providerConfig, model: configuredModel }) + ) { continue; } - const key = modelKey(provider, configuredModel.id); + // Strip a self-prefix against the source provider before display aliasing. + // Auth stays on the source provider so alias-backed profiles remain valid. + const modelId = replaceMode + ? normalizeConfiguredProviderCatalogModelId( + provider, + stripSelfProviderModelPrefix(provider, configuredModel.id), + { + manifestPlugins: params.context.metadataSnapshot?.manifestRegistry.plugins, + }, + ) + : configuredModel.id; + const displayProvider = replaceMode + ? canonicalizeModelCatalogProviderAlias(provider, { + cfg: params.context.cfg, + metadataSnapshot: params.context.metadataSnapshot, + }) + : provider; + const key = modelKey(displayProvider, modelId); const model = toConfiguredProviderListModel({ provider, providerConfig, - model: configuredModel, + model: { ...configuredModel, id: modelId }, }); + const authEvaluation = replaceMode + ? params.context.authIndex.evaluateModelAuth(provider, toModelAuthRef(model)) + : undefined; await appendVisibleRow({ rows: params.rows, model, key, context: params.context, seenKeys: params.seenKeys, + ...(authEvaluation ? { authEvaluation } : {}), allowAuthAvailabilityOverride: true, normalizeWithProviderPlugin: true, }); @@ -545,6 +574,9 @@ export async function appendAuthenticatedCatalogRows(params: { seenKeys: Set; catalogSnapshot?: ModelCatalogSnapshot; }): Promise { + if (params.context.cfg.models?.mode === "replace") { + return; + } const { entries: catalog, routeVariants } = params.catalogSnapshot ?? (await loadListModelCatalogSnapshot(params.context)); const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants);