mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
fix(models): preserve partial catalog visibility
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" } } };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user