fix(models): preserve partial catalog visibility

This commit is contained in:
Vincent Koc
2026-08-01 15:13:52 +02:00
parent 2698f91ba6
commit bd567b349c
8 changed files with 428 additions and 35 deletions

View File

@@ -691,6 +691,7 @@ export async function prepareAgentCatalogSource(
workspaceFacts: PreparedModelRuntimeWorkspaceFacts,
catalogMode: PreparedModelRuntimeCatalogMode,
persist = true,
sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {},
): Promise<PreparedModelRuntimeCatalogSource> {
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) {

View File

@@ -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<ModelCatalogSnapshot> {
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<ModelCatalogSnapshot> {
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<ModelCatalogSnapshot> {
return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "live");
}

View File

@@ -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" } } };

View File

@@ -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<unkno
beforeEach(() => {
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", () => {

View File

@@ -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"]));
});
});

View File

@@ -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<string>;
env?: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
}): {
ownedProviderIds: ReadonlySet<string>;
completeProviderIds: ReadonlySet<string>;
} {
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<string>();
const completeProviderIds = new Set<string>();
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;

View File

@@ -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",

View File

@@ -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<string>,
ownedProviderIds: ReadonlySet<string>,
): ReadonlySet<string> {
const coveredProviders = new Set<string>();
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<string, ModelCatalogEntry>();
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 } : {}),