From bd567b349cbcedfda37a7a3c73f1d5240486ff7f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 15:13:52 +0200 Subject: [PATCH] fix(models): preserve partial catalog visibility --- src/agents/prepared-model-runtime.facts.ts | 6 +- .../prepared-model-runtime.scoped-catalog.ts | 38 ++++- ...pared-model-runtime.startup-static.test.ts | 36 +++- src/commands/models.list.e2e.test.ts | 8 + .../models/list.manifest-catalog.test.ts | 111 +++++++++++- src/commands/models/list.manifest-catalog.ts | 84 +++++++++ .../models/list.scoped-catalog.test.ts | 160 ++++++++++++++++-- src/commands/models/list.scoped-catalog.ts | 20 ++- 8 files changed, 428 insertions(+), 35 deletions(-) diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 5ba4b4f17414..749e9c438b78 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -691,6 +691,7 @@ export async function prepareAgentCatalogSource( workspaceFacts: PreparedModelRuntimeWorkspaceFacts, catalogMode: PreparedModelRuntimeCatalogMode, persist = true, + sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {}, ): Promise { const { env, input, providerIds } = agentFacts; const options = { @@ -703,10 +704,13 @@ export async function prepareAgentCatalogSource( ...(catalogMode === "static" ? { providerDiscoveryEntriesOnly: true as const, - providerDiscoveryProviderIds: providerIds, + providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds ?? providerIds, } : { providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS, + ...(sourceOptions.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds } + : {}), }), }; if (!persist) { diff --git a/src/agents/prepared-model-runtime.scoped-catalog.ts b/src/agents/prepared-model-runtime.scoped-catalog.ts index d01704a80052..ea6569d65a92 100644 --- a/src/agents/prepared-model-runtime.scoped-catalog.ts +++ b/src/agents/prepared-model-runtime.scoped-catalog.ts @@ -4,17 +4,22 @@ import { prepareFullCatalogFacts, prepareWorkspaceBuildGroup, } from "./prepared-model-runtime.facts.js"; -import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; +import type { + PreparedModelRuntimeCatalogMode, + PreparedModelRuntimeInput, +} from "./prepared-model-runtime.types.js"; -/** Builds a request-scoped read-only catalog from configured and auth-candidate providers. */ -export async function prepareScopedReadOnlyModelCatalog( +async function prepareScopedReadOnlyModelCatalogWithMode( input: PreparedModelRuntimeInput, providerDiscoveryProviderIds: readonly string[], + catalogMode: PreparedModelRuntimeCatalogMode, ): Promise { const scopedInput = input.readOnly ? input : { ...input, readOnly: true }; - const { agentFacts, workspaceFacts } = await prepareWorkspaceBuildGroup([scopedInput], "static", { - providerDiscoveryProviderIds, - }); + const { agentFacts, workspaceFacts } = await prepareWorkspaceBuildGroup( + [scopedInput], + catalogMode, + { providerDiscoveryProviderIds }, + ); const agentFactsForInput = agentFacts[0]; if (!agentFactsForInput) { throw new Error("scoped prepared model catalog facts are missing"); @@ -22,10 +27,27 @@ export async function prepareScopedReadOnlyModelCatalog( const catalogSource = await prepareAgentCatalogSource( agentFactsForInput, workspaceFacts, - "static", + catalogMode, false, + catalogMode === "live" ? { providerDiscoveryProviderIds } : {}, ); return ( - await prepareFullCatalogFacts(agentFactsForInput, workspaceFacts, "static", catalogSource) + await prepareFullCatalogFacts(agentFactsForInput, workspaceFacts, catalogMode, catalogSource) ).modelCatalog; } + +/** Builds a request-scoped read-only catalog without executing live provider discovery. */ +export function prepareScopedReadOnlyModelCatalog( + input: PreparedModelRuntimeInput, + providerDiscoveryProviderIds: readonly string[], +): Promise { + return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "static"); +} + +/** Builds a request-scoped read-only catalog with live discovery for selected providers. */ +export function prepareScopedReadOnlyLiveModelCatalog( + input: PreparedModelRuntimeInput, + providerDiscoveryProviderIds: readonly string[], +): Promise { + return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "live"); +} diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index b29ebb0099ab..9b4ef483b90f 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -163,7 +163,7 @@ vi.mock("../logging/subsystem.js", () => ({ const { getPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots } = await import("./prepared-model-runtime.js"); -const { prepareScopedReadOnlyModelCatalog } = +const { prepareScopedReadOnlyLiveModelCatalog, prepareScopedReadOnlyModelCatalog } = await import("./prepared-model-runtime.scoped-catalog.js"); const { resetPreparedModelRuntimeSnapshotsForTest } = await import("./prepared-model-runtime.test-support.js"); @@ -210,6 +210,40 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); }); + it("uses live provider catalogs for an explicit read-only list scope", async () => { + const config = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + }; + + await prepareScopedReadOnlyLiveModelCatalog( + { + agentId: "default", + agentDir: "/tmp/prepared-live-agent", + config, + inheritedAuthDir: "/tmp/prepared-live-agent", + workspaceDir: "/tmp/prepared-live-workspace", + env: {}, + readOnly: true, + }, + ["anthropic"], + ); + + expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith( + config, + "/tmp/prepared-live-agent", + expect.objectContaining({ + providerDiscoveryProviderIds: ["anthropic"], + providerDiscoveryTimeoutMs: expect.any(Number), + }), + ); + expect(mocks.planOpenClawModelsJsonSource).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ providerDiscoveryEntriesOnly: true }), + ); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + it("does not publish a static catalog generation superseded while its hook is running", async () => { const staleConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; const latestConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } }; diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index e570224ceba3..63c62a03c3e4 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -55,6 +55,7 @@ const modelRegistryState = { findError: undefined as unknown, }; let previousExitCode: typeof process.exitCode; +let previousOpenAiApiKey: string | undefined; vi.mock("./models/load-config.js", () => ({ loadModelsConfigWithSource: vi.fn(async () => { @@ -251,6 +252,8 @@ async function loadSourceConfigSnapshotForTest(fallback: unknown): Promise { previousExitCode = process.exitCode; process.exitCode = undefined; + previousOpenAiApiKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; modelRegistryState.models = []; modelRegistryState.available = []; modelRegistryState.getAllError = undefined; @@ -274,6 +277,11 @@ beforeEach(() => { afterEach(() => { process.exitCode = previousExitCode; + if (previousOpenAiApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previousOpenAiApiKey; + } }); describe("models list/status", () => { diff --git a/src/commands/models/list.manifest-catalog.test.ts b/src/commands/models/list.manifest-catalog.test.ts index de93ea8edee8..c2b37c624ee2 100644 --- a/src/commands/models/list.manifest-catalog.test.ts +++ b/src/commands/models/list.manifest-catalog.test.ts @@ -9,8 +9,11 @@ const mocks = vi.hoisted(() => ({ getRemoteModelCatalogOverlay: vi.fn(), })); -vi.mock("../../plugins/plugin-registry.js", () => ({ +vi.mock("../../plugins/plugin-registry-contributions.js", () => ({ resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, +})); + +vi.mock("../../plugins/plugin-registry-snapshot.js", () => ({ getPluginRecord: mocks.getPluginRecord, isPluginEnabled: mocks.isPluginEnabled, })); @@ -26,6 +29,7 @@ vi.mock("../../model-catalog/remote-overlay.js", () => ({ const moonshotPlugin = { id: "moonshot", + origin: "bundled", providers: ["moonshot"], modelCatalog: { providers: { @@ -41,6 +45,7 @@ const moonshotPlugin = { const openrouterPlugin = { id: "openrouter", + origin: "bundled", providers: ["openrouter"], modelCatalog: { providers: { @@ -56,6 +61,7 @@ const openrouterPlugin = { const openaiRuntimePlugin = { id: "openai", + origin: "bundled", providers: ["openai"], modelCatalog: { providers: { @@ -69,6 +75,23 @@ const openaiRuntimePlugin = { }, }; +const anthropicRuntimeAugmentPlugin = { + id: "anthropic", + origin: "bundled", + providers: ["anthropic"], + modelCatalog: { + runtimeAugment: true, + providers: { + anthropic: { + models: [{ id: "claude-known", name: "Known Claude" }], + }, + }, + discovery: { + anthropic: "refreshable", + }, + }, +}; + describe("loadStaticManifestCatalogRowsForList", () => { beforeEach(() => { vi.clearAllMocks(); @@ -193,3 +216,89 @@ describe("loadStaticManifestCatalogRowsForList", () => { expect(mocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); }); + +describe("resolveManifestCatalogCoverageForList", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPluginRecord.mockImplementation(({ pluginId }: { pluginId: string }) => ({ + pluginId, + })); + mocks.isPluginEnabled.mockReturnValue(true); + mocks.resolvePluginContributionOwners.mockImplementation(({ matches }: { matches: string }) => [ + matches, + ]); + }); + + it("marks only static non-augment owners as complete", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [ + anthropicRuntimeAugmentPlugin, + moonshotPlugin, + openrouterPlugin, + openaiRuntimePlugin, + ], + diagnostics: [], + }, + }; + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: {}, + providerIds: new Set(["anthropic", "moonshot", "openrouter", "openai"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.ownedProviderIds).toEqual( + new Set(["anthropic", "moonshot", "openrouter", "openai"]), + ); + expect(coverage.completeProviderIds).toEqual(new Set(["moonshot"])); + }); + + it("requires runtime augmentation for external provider plugins", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const externalPlugin = { + ...moonshotPlugin, + id: "external-moonshot", + origin: "external", + }; + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [externalPlugin], + diagnostics: [], + }, + }; + mocks.resolvePluginContributionOwners.mockReturnValue(["external-moonshot"]); + mocks.getPluginRecord.mockReturnValue(undefined); + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: {}, + providerIds: new Set(["moonshot"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.ownedProviderIds).toEqual(new Set(["moonshot"])); + expect(coverage.completeProviderIds).toEqual(new Set()); + }); + + it("treats replace mode as explicit opt-out from provider discovery", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [openaiRuntimePlugin], + diagnostics: [], + }, + }; + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: { models: { mode: "replace" } }, + providerIds: new Set(["openai"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.completeProviderIds).toEqual(new Set(["openai"])); + }); +}); diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index 32c9718af6d2..0d2ed1a898fe 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -1,6 +1,7 @@ /** Manifest-backed model catalog row loaders for `openclaw models list`. */ import { normalizeModelCatalogProviderId } from "@openclaw/model-catalog-core/model-catalog-refs"; import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { planEffectiveModelCatalogRows } from "../../model-catalog/index.js"; import type { ManifestModelCatalogRowSelection } from "../../model-catalog/manifest-planner.js"; @@ -74,6 +75,89 @@ function resolveDeclaredModelCatalogPluginIds(params: { }); } +function resolveModelCatalogPluginIdsForProvider(params: { + cfg: OpenClawConfig; + index: PluginRegistrySnapshot; + provider: string; +}): readonly string[] { + return [ + ...new Set([ + ...resolveConventionModelCatalogPluginIds({ + cfg: params.cfg, + index: params.index, + providerFilter: params.provider, + }), + ...resolveDeclaredModelCatalogPluginIds({ + cfg: params.cfg, + index: params.index, + providerFilter: params.provider, + }), + ]), + ]; +} + +/** + * Resolves provider ownership and whether static manifest rows require runtime + * augmentation before they can be treated as complete catalog coverage. + */ +export function resolveManifestCatalogCoverageForList(params: { + cfg: OpenClawConfig; + providerIds: ReadonlySet; + env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; +}): { + ownedProviderIds: ReadonlySet; + completeProviderIds: ReadonlySet; +} { + const snapshot = + params.metadataSnapshot ?? + loadManifestMetadataSnapshot({ + config: params.cfg, + env: params.env ?? process.env, + }); + const pluginsById = new Map( + snapshot.manifestRegistry.plugins.map((plugin) => [plugin.id, plugin]), + ); + const ownedProviderIds = new Set(); + const completeProviderIds = new Set(); + for (const rawProvider of params.providerIds) { + const provider = normalizeProviderId(rawProvider); + if (!provider) { + continue; + } + const pluginIds = resolveModelCatalogPluginIdsForProvider({ + cfg: params.cfg, + index: snapshot.index, + provider, + }); + if (pluginIds.length === 0) { + continue; + } + ownedProviderIds.add(provider); + const complete = pluginIds.every((pluginId) => { + const plugin = pluginsById.get(pluginId); + if (!plugin) { + return false; + } + if (plugin.modelCatalog?.runtimeAugment === true || plugin.origin !== "bundled") { + return false; + } + const aliasTarget = plugin.modelCatalog?.aliases?.[provider]?.provider; + const discoveryProvider = normalizeProviderId(aliasTarget ?? provider); + return plugin.modelCatalog?.discovery?.[discoveryProvider] === "static"; + }); + if (complete) { + completeProviderIds.add(provider); + } + } + if (params.cfg.models?.mode === "replace") { + for (const provider of params.providerIds) { + completeProviderIds.add(normalizeProviderId(provider)); + } + } + return { ownedProviderIds, completeProviderIds }; +} + function loadManifestCatalogRowsForListSelection(params: { cfg: OpenClawConfig; providerFilter?: string; diff --git a/src/commands/models/list.scoped-catalog.test.ts b/src/commands/models/list.scoped-catalog.test.ts index 019f4dcf71d6..f176f5715005 100644 --- a/src/commands/models/list.scoped-catalog.test.ts +++ b/src/commands/models/list.scoped-catalog.test.ts @@ -3,13 +3,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loadManifestCatalogRowsForList: vi.fn(), loadStaticManifestCatalogRowsForList: vi.fn(), + resolveManifestCatalogCoverageForList: vi.fn(), loadPersistedListCatalogEntries: vi.fn(), - prepareScopedReadOnlyModelCatalog: vi.fn(), + prepareScopedReadOnlyLiveModelCatalog: vi.fn(), })); vi.mock("./list.manifest-catalog.js", () => ({ loadManifestCatalogRowsForList: mocks.loadManifestCatalogRowsForList, loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, + resolveManifestCatalogCoverageForList: mocks.resolveManifestCatalogCoverageForList, })); vi.mock("./list.persisted-catalog.js", () => ({ @@ -17,7 +19,7 @@ vi.mock("./list.persisted-catalog.js", () => ({ })); vi.mock("../../agents/prepared-model-runtime.scoped-catalog.js", () => ({ - prepareScopedReadOnlyModelCatalog: mocks.prepareScopedReadOnlyModelCatalog, + prepareScopedReadOnlyLiveModelCatalog: mocks.prepareScopedReadOnlyLiveModelCatalog, })); import { loadScopedListModelCatalogSnapshot } from "./list.scoped-catalog.js"; @@ -53,8 +55,12 @@ beforeEach(() => { vi.clearAllMocks(); mocks.loadManifestCatalogRowsForList.mockReturnValue([runtimeRow, staticRow]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([staticRow]); + mocks.resolveManifestCatalogCoverageForList.mockReturnValue({ + ownedProviderIds: new Set(), + completeProviderIds: new Set(), + }); mocks.loadPersistedListCatalogEntries.mockReturnValue([]); - mocks.prepareScopedReadOnlyModelCatalog.mockResolvedValue({ + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValue({ entries: [], routeVariants: [], }); @@ -77,10 +83,10 @@ describe("loadScopedListModelCatalogSnapshot", () => { expect(mocks.loadManifestCatalogRowsForList).not.toHaveBeenCalled(); expect(mocks.loadStaticManifestCatalogRowsForList).not.toHaveBeenCalled(); expect(mocks.loadPersistedListCatalogEntries).not.toHaveBeenCalled(); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); }); - it("uses runtime manifest rows to enrich configured model ids", async () => { + it("uses runtime manifest rows as seeds while retaining live provider discovery", async () => { const snapshot = await loadScopedListModelCatalogSnapshot({ cfg: {}, agentDir: "/tmp/openclaw-agent", @@ -98,10 +104,17 @@ describe("loadScopedListModelCatalogSnapshot", () => { ]); expect(snapshot.routeVariants).toEqual(snapshot.entries); expect(snapshot.staticEntries).toEqual([]); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); }); it("keeps static rows in the scoped snapshot", async () => { + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["moonshot"]), + completeProviderIds: new Set(["moonshot"]), + }); const snapshot = await loadScopedListModelCatalogSnapshot({ cfg: {}, agentDir: "/tmp/openclaw-agent", @@ -113,10 +126,10 @@ describe("loadScopedListModelCatalogSnapshot", () => { expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }), ]); expect(snapshot.staticEntries).toEqual(snapshot.entries); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); }); - it("uses persisted runtime rows and manifest metadata without loading provider runtimes", async () => { + it("keeps persisted rows as seeds while adding runtime-only models", async () => { mocks.loadPersistedListCatalogEntries.mockReturnValueOnce([ { provider: "openai", @@ -126,6 +139,17 @@ describe("loadScopedListModelCatalogSnapshot", () => { baseUrl: "https://chatgpt.com/backend-api/codex", }, ]); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "openai", + id: "gpt-runtime-only", + name: "Runtime-only GPT", + api: "openai-responses", + }, + ], + routeVariants: [], + }); const snapshot = await loadScopedListModelCatalogSnapshot({ cfg: {}, @@ -142,6 +166,10 @@ describe("loadScopedListModelCatalogSnapshot", () => { api: "openai-chatgpt-responses", contextWindow: 1_050_000, }), + expect.objectContaining({ + provider: "openai", + id: "gpt-runtime-only", + }), ]); expect(snapshot.routeVariants).toEqual( expect.arrayContaining([ @@ -149,10 +177,13 @@ describe("loadScopedListModelCatalogSnapshot", () => { expect.objectContaining({ api: "openai-responses" }), ]), ); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); }); - it("does not discover providers whose explicit models are emitted by the configured row source", async () => { + it("does not discover unowned providers whose explicit models are emitted by the configured row source", async () => { mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); @@ -160,7 +191,7 @@ describe("loadScopedListModelCatalogSnapshot", () => { cfg: { models: { providers: { - openai: { + custom: { baseUrl: "http://127.0.0.1:3000/v1", api: "openai-responses", models: [ @@ -179,8 +210,8 @@ describe("loadScopedListModelCatalogSnapshot", () => { }, }, agentDir: "/tmp/openclaw-agent", - providerIds: ["openai"], - configuredKeys: ["openai/gpt-5.5"], + providerIds: ["custom"], + configuredKeys: ["custom/gpt-5.5"], }); expect(snapshot).toEqual({ @@ -188,7 +219,100 @@ describe("loadScopedListModelCatalogSnapshot", () => { routeVariants: [], staticEntries: [], }); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("still discovers owned providers when explicit config contains only part of the catalog", async () => { + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["openai"]), + completeProviderIds: new Set(), + }); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "openai", + id: "gpt-runtime-only", + name: "Runtime-only GPT", + api: "openai-responses", + }, + ], + routeVariants: [], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-configured", + name: "Configured GPT", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + providerIds: ["openai"], + runtimeProviderIds: ["openai"], + configuredKeys: ["openai/gpt-configured"], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ provider: "openai", id: "gpt-runtime-only" }), + ]); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); + }); + + it("runtime-augments partial static catalogs instead of hiding runtime-only rows", async () => { + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["moonshot"]), + completeProviderIds: new Set(), + }); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "moonshot", + id: "kimi-runtime-only", + name: "Runtime-only Kimi", + api: "openai-completions", + }, + ], + routeVariants: [], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["moonshot"], + runtimeProviderIds: ["moonshot"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }), + expect.objectContaining({ provider: "moonshot", id: "kimi-runtime-only" }), + ]), + ); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["moonshot"], + ); }); it("still discovers configured providers without a listable API route", async () => { @@ -222,7 +346,7 @@ describe("loadScopedListModelCatalogSnapshot", () => { configuredKeys: ["custom/custom-model"], }); - expect(mocks.prepareScopedReadOnlyModelCatalog).toHaveBeenCalledWith( + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( expect.objectContaining({ readOnly: true }), ["custom"], ); @@ -245,7 +369,7 @@ describe("loadScopedListModelCatalogSnapshot", () => { routeVariants: [], staticEntries: [], }); - expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); }); it("falls back to scoped provider discovery only for providers with no lightweight rows", async () => { @@ -257,7 +381,7 @@ describe("loadScopedListModelCatalogSnapshot", () => { }; mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); - mocks.prepareScopedReadOnlyModelCatalog.mockResolvedValueOnce({ + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ entries: [discovered], routeVariants: [discovered], }); @@ -273,7 +397,7 @@ describe("loadScopedListModelCatalogSnapshot", () => { }); expect(snapshot.entries).toEqual([discovered]); - expect(mocks.prepareScopedReadOnlyModelCatalog).toHaveBeenCalledWith( + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( { config: {}, agentId: "main", diff --git a/src/commands/models/list.scoped-catalog.ts b/src/commands/models/list.scoped-catalog.ts index 2b16788c05d9..beb2d601e318 100644 --- a/src/commands/models/list.scoped-catalog.ts +++ b/src/commands/models/list.scoped-catalog.ts @@ -9,6 +9,7 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { loadManifestCatalogRowsForList, loadStaticManifestCatalogRowsForList, + resolveManifestCatalogCoverageForList, } from "./list.manifest-catalog.js"; type PersistedCatalogModule = typeof import("./list.persisted-catalog.js"); @@ -60,18 +61,20 @@ function routeKey(entry: ModelCatalogEntry): string { function resolveConfiguredProviderCoverage( cfg: OpenClawConfig, providerIds: ReadonlySet, + ownedProviderIds: ReadonlySet, ): ReadonlySet { const coveredProviders = new Set(); for (const [provider, providerConfig] of Object.entries(cfg.models?.providers ?? {})) { const normalizedProvider = normalizeProviderId(provider); if ( providerIds.has(normalizedProvider) && + !ownedProviderIds.has(normalizedProvider) && (providerConfig.models ?? []).some( (model) => providerConfig.api !== undefined || model.api !== undefined, ) ) { - // Explicit provider rows are emitted by appendConfiguredProviderRows. - // Runtime discovery here would duplicate that source and load the full provider runtime. + // Unowned custom providers have no runtime catalog beyond their explicit rows. + // Plugin-owned providers remain discoverable unless models.mode is replace. coveredProviders.add(normalizedProvider); } } @@ -174,6 +177,11 @@ export async function loadScopedListModelCatalogSnapshot(params: { providerIds, ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), }).map((entry) => enrichPersistedEntry(entry, manifestByKey.get(entryKey(entry)))); + const { ownedProviderIds, completeProviderIds } = resolveManifestCatalogCoverageForList({ + cfg: params.cfg, + providerIds, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), + }); const admittedEntries = new Map(); for (const entry of [...staticEntries, ...persistedEntries]) { admittedEntries.set(entryKey(entry), entry); @@ -196,8 +204,8 @@ export async function loadScopedListModelCatalogSnapshot(params: { staticEntries, }; const coveredProviders = new Set([ - ...lightweightSnapshot.entries.map((entry) => normalizeProviderId(entry.provider)), - ...resolveConfiguredProviderCoverage(params.cfg, providerIds), + ...completeProviderIds, + ...resolveConfiguredProviderCoverage(params.cfg, providerIds, ownedProviderIds), ]); const runtimeProviderIds = new Set( (params.runtimeProviderIds ?? params.providerIds) @@ -210,8 +218,8 @@ export async function loadScopedListModelCatalogSnapshot(params: { if (uncoveredProviders.length === 0) { return mergeSnapshotEntries([lightweightSnapshot]); } - const { prepareScopedReadOnlyModelCatalog } = await preparedScopedCatalogModuleLoader.load(); - const fallbackSnapshot = await prepareScopedReadOnlyModelCatalog( + const { prepareScopedReadOnlyLiveModelCatalog } = await preparedScopedCatalogModuleLoader.load(); + const fallbackSnapshot = await prepareScopedReadOnlyLiveModelCatalog( { config: params.cfg, ...(params.agentId ? { agentId: params.agentId } : {}),