Merge remote-tracking branch 'origin/main' into release/2026.4.25

This commit is contained in:
Peter Steinberger
2026-04-26 11:52:07 +01:00
20 changed files with 486 additions and 219 deletions

View File

@@ -160,9 +160,13 @@ export OTEL_SERVICE_NAME="openclaw-gateway"
./scripts/docker/setup.sh
```
The official OpenClaw Docker release image includes `diagnostics-otel`
dependencies. To enable export, allow and enable the `diagnostics-otel` plugin
in config, then set `diagnostics.otel.enabled=true` or use the config example in
The official OpenClaw Docker release image includes the bundled
`diagnostics-otel` plugin source. Depending on the image and cache state, the
Gateway may still stage plugin-local OpenTelemetry runtime dependencies the
first time the plugin is enabled, so allow that first boot to reach the package
registry or prewarm the image in your release lane. To enable export, allow and
enable the `diagnostics-otel` plugin in config, then set
`diagnostics.otel.enabled=true` or use the config example in
[OpenTelemetry export](/gateway/opentelemetry). Collector auth headers are
configured through `diagnostics.otel.headers`, not through Docker environment
variables.

View File

@@ -170,6 +170,10 @@ vi.mock("../commands/models/list.js", () => ({
modelsStatusCommand:
mocks.modelsStatusCommand as typeof import("../commands/models/list.js").modelsStatusCommand,
}));
vi.mock("../commands/models/list.status-command.js", () => ({
modelsStatusCommand:
mocks.modelsStatusCommand as typeof import("../commands/models/list.status-command.js").modelsStatusCommand,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway as typeof import("../gateway/call.js").callGateway,

View File

@@ -13,7 +13,6 @@ import {
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { modelsStatusCommand } from "../commands/models/list.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -683,6 +682,7 @@ async function buildModelProviders() {
async function runModelAuthStatus() {
const captured: string[] = [];
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
await modelsStatusCommand(
{ json: true },
{

View File

@@ -49,6 +49,12 @@ vi.mock("../commands/models/list.js", () => ({
modelsListCommand: mocks.noopAsync,
modelsStatusCommand: mocks.modelsStatusCommand,
}));
vi.mock("../commands/models/list.list-command.js", () => ({
modelsListCommand: mocks.noopAsync,
}));
vi.mock("../commands/models/list.status-command.js", () => ({
modelsStatusCommand: mocks.modelsStatusCommand,
}));
vi.mock("../commands/models/auth.js", () => ({
modelsAuthAddCommand: mocks.modelsAuthAddCommand,
modelsAuthLoginCommand: mocks.modelsAuthLoginCommand,

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { modelsListCommand, modelsStatusCommand } from "../commands/models/list.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@@ -35,6 +34,7 @@ export function registerModelsCli(program: Command) {
.option("--plain", "Plain line output", false)
.action(async (opts) => {
await runModelsCommand(async () => {
const { modelsListCommand } = await import("../commands/models/list.list-command.js");
await modelsListCommand(opts, defaultRuntime);
});
});
@@ -71,6 +71,7 @@ export function registerModelsCli(program: Command) {
const agent =
resolveOptionFromCommand<string>(command, "agent") ?? (opts.agent as string | undefined);
await runModelsCommand(async () => {
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
await modelsStatusCommand(
{
json: Boolean(opts.json),
@@ -270,6 +271,7 @@ export function registerModelsCli(program: Command) {
models.action(async (opts) => {
await runModelsCommand(async () => {
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
await modelsStatusCommand(
{
json: Boolean(opts?.statusJson),

View File

@@ -56,6 +56,7 @@ export const loadPluginManifestRegistry: UnknownMock = vi.fn();
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn();
export const buildPluginInspectReport: UnknownMock = vi.fn();
export const buildAllPluginInspectReports: UnknownMock = vi.fn();
export const buildPluginDiagnosticsReport: UnknownMock = vi.fn();
export const buildPluginCompatibilityNotices: UnknownMock = vi.fn();
export const inspectPluginRegistry: AsyncUnknownMock = vi.fn();
@@ -248,6 +249,16 @@ vi.mock("../plugins/status.js", () => ({
buildPluginInspectReport,
...args,
)) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"],
buildAllPluginInspectReports: ((
...args: Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>
) =>
invokeMock<
Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>,
ReturnType<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>
>(
buildAllPluginInspectReports,
...args,
)) as (typeof import("../plugins/status.js"))["buildAllPluginInspectReports"],
buildPluginDiagnosticsReport: ((
...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]>
) =>

View File

@@ -5,51 +5,14 @@ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import { listMarketplacePlugins } from "../plugins/marketplace.js";
import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js";
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js";
import {
buildAllPluginInspectReports,
buildPluginDiagnosticsReport,
buildPluginCompatibilityNotices,
buildPluginInspectReport,
buildPluginRegistrySnapshotReport,
formatPluginCompatibilityNotice,
} from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
import {
applyPluginUninstallDirectoryRemoval,
formatUninstallActionLabels,
formatUninstallSlotResetPreview,
planPluginUninstall,
resolveUninstallChannelConfigKeys,
UNINSTALL_ACTION_LABELS,
} from "../plugins/uninstall.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import {
applySlotSelectionForPlugin,
createPluginInstallLogger,
logSlotWarnings,
} from "./plugins-command-helpers.js";
import { setPluginEnabledInConfig } from "./plugins-config.js";
import { runPluginInstallCommand } from "./plugins-install-command.js";
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
import { formatPluginLine } from "./plugins-list-format.js";
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js";
import { runPluginUpdateCommand } from "./plugins-update-command.js";
import { promptYesNo } from "./prompt.js";
export type PluginsListOptions = {
json?: boolean;
@@ -182,7 +145,8 @@ export function registerPluginsCli(program: Command) {
.option("--json", "Print JSON")
.option("--enabled", "Only show enabled plugins", false)
.option("--verbose", "Show detailed entries", false)
.action((opts: PluginsListOptions) => {
.action(async (opts: PluginsListOptions) => {
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const cfg = loadConfig();
const report = buildPluginRegistrySnapshotReport({
config: cfg,
@@ -290,6 +254,14 @@ export function registerPluginsCli(program: Command) {
.option("--all", "Inspect all plugins")
.option("--json", "Print JSON")
.action(async (id: string | undefined, opts: PluginInspectOptions) => {
const {
buildAllPluginInspectReports,
buildPluginDiagnosticsReport,
buildPluginInspectReport,
formatPluginCompatibilityNotice,
} = await import("../plugins/status.js");
const { loadInstalledPluginIndexInstallRecords } =
await import("../plugins/installed-plugin-index-records.js");
const cfg = loadConfig();
const installRecords = await loadInstalledPluginIndexInstallRecords();
const report = buildPluginDiagnosticsReport({
@@ -523,6 +495,11 @@ export function registerPluginsCli(program: Command) {
.description("Enable a plugin in config")
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const { enablePluginInConfig } = await import("../plugins/enable.js");
const { applySlotSelectionForPlugin, logSlotWarnings } =
await import("./plugins-command-helpers.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const enableResult = enablePluginInConfig(cfg, id);
@@ -557,6 +534,9 @@ export function registerPluginsCli(program: Command) {
.description("Disable a plugin in config")
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const { setPluginEnabledInConfig } = await import("./plugins-config.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const next = setPluginEnabledInConfig(cfg, id, false);
@@ -583,6 +563,27 @@ export function registerPluginsCli(program: Command) {
.option("--force", "Skip confirmation prompt", false)
.option("--dry-run", "Show what would be removed without making changes", false)
.action(async (id: string, opts: PluginUninstallOptions) => {
const {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} = await import("../plugins/installed-plugin-index-records.js");
const { buildPluginDiagnosticsReport } = await import("../plugins/status.js");
const {
formatUninstallActionLabels,
formatUninstallSlotResetPreview,
resolveUninstallChannelConfigKeys,
resolveUninstallDirectoryTarget,
UNINSTALL_ACTION_LABELS,
uninstallPlugin,
} = await import("../plugins/uninstall.js");
const { commitPluginInstallRecordsWithConfig } =
await import("./plugins-install-record-commit.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
const { promptYesNo } = await import("./prompt.js");
const snapshot = await readConfigFileSnapshot();
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const installRecords = await loadInstalledPluginIndexInstallRecords();
@@ -744,6 +745,7 @@ export function registerPluginsCli(program: Command) {
marketplace?: string;
},
) => {
const { runPluginInstallCommand } = await import("./plugins-install-command.js");
await runPluginInstallCommand({ raw, opts });
},
);
@@ -760,6 +762,7 @@ export function registerPluginsCli(program: Command) {
false,
)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
const { runPluginUpdateCommand } = await import("./plugins-update-command.js");
await runPluginUpdateCommand({ id, opts });
});
@@ -769,6 +772,8 @@ export function registerPluginsCli(program: Command) {
.option("--json", "Print JSON")
.option("--refresh", "Rebuild the persisted registry from current plugin manifests", false)
.action(async (opts: PluginRegistryOptions) => {
const { inspectPluginRegistry, refreshPluginRegistry } =
await import("../plugins/plugin-registry.js");
const cfg = loadConfig();
if (opts.refresh) {
@@ -825,7 +830,12 @@ export function registerPluginsCli(program: Command) {
plugins
.command("doctor")
.description("Report plugin load issues")
.action(() => {
.action(async () => {
const {
buildPluginCompatibilityNotices,
buildPluginDiagnosticsReport,
formatPluginCompatibilityNotice,
} = await import("../plugins/status.js");
const report = buildPluginDiagnosticsReport();
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
@@ -880,6 +890,8 @@ export function registerPluginsCli(program: Command) {
.argument("<source>", "Local marketplace path/repo or git/GitHub source")
.option("--json", "Print JSON")
.action(async (source: string, opts: PluginMarketplaceListOptions) => {
const { listMarketplacePlugins } = await import("../plugins/marketplace.js");
const { createPluginInstallLogger } = await import("./plugins-command-helpers.js");
const result = await listMarketplacePlugins({
marketplace: source,
logger: createPluginInstallLogger(),

View File

@@ -17,7 +17,8 @@ type RouteArgParser<TArgs> = (argv: string[]) => TArgs | null;
type ParsedRouteArgs<TParse extends RouteArgParser<unknown>> = Exclude<ReturnType<TParse>, null>;
type ConfigCliModule = typeof import("../config-cli.js");
type ModelsListModule = typeof import("../../commands/models/list.js");
type ModelsListCommandModule = typeof import("../../commands/models/list.list-command.js");
type ModelsStatusCommandModule = typeof import("../../commands/models/list.status-command.js");
export type RoutedCommandDefinition<TParse extends RouteArgParser<unknown>> = {
parseArgs: TParse;
@@ -36,16 +37,22 @@ function defineRoutedCommand<TParse extends RouteArgParser<unknown>>(
}
let configCliPromise: Promise<ConfigCliModule> | undefined;
let modelsListPromise: Promise<ModelsListModule> | undefined;
let modelsListCommandPromise: Promise<ModelsListCommandModule> | undefined;
let modelsStatusCommandPromise: Promise<ModelsStatusCommandModule> | undefined;
function loadConfigCli(): Promise<ConfigCliModule> {
configCliPromise ??= import("../config-cli.js");
return configCliPromise;
}
function loadModelsList(): Promise<ModelsListModule> {
modelsListPromise ??= import("../../commands/models/list.js");
return modelsListPromise;
function loadModelsListCommand(): Promise<ModelsListCommandModule> {
modelsListCommandPromise ??= import("../../commands/models/list.list-command.js");
return modelsListCommandPromise;
}
function loadModelsStatusCommand(): Promise<ModelsStatusCommandModule> {
modelsStatusCommandPromise ??= import("../../commands/models/list.status-command.js");
return modelsStatusCommandPromise;
}
export const routedCommandDefinitions = {
@@ -114,14 +121,14 @@ export const routedCommandDefinitions = {
"models-list": defineRoutedCommand({
parseArgs: parseModelsListRouteArgs,
runParsedArgs: async (args) => {
const { modelsListCommand } = await loadModelsList();
const { modelsListCommand } = await loadModelsListCommand();
await modelsListCommand(args, defaultRuntime);
},
}),
"models-status": defineRoutedCommand({
parseArgs: parseModelsStatusRouteArgs,
runParsedArgs: async (args) => {
const { modelsStatusCommand } = await loadModelsList();
const { modelsStatusCommand } = await loadModelsStatusCommand();
await modelsStatusCommand(args, defaultRuntime);
},
}),

View File

@@ -23,6 +23,12 @@ vi.mock("../../commands/models/list.js", () => ({
modelsListCommand: modelsListCommandMock,
modelsStatusCommand: modelsStatusCommandMock,
}));
vi.mock("../../commands/models/list.list-command.js", () => ({
modelsListCommand: modelsListCommandMock,
}));
vi.mock("../../commands/models/list.status-command.js", () => ({
modelsStatusCommand: modelsStatusCommandMock,
}));
vi.mock("../daemon-cli/status.js", () => ({
runDaemonStatus: runDaemonStatusMock,

View File

@@ -63,6 +63,7 @@ const mocks = vi.hoisted(() => {
loadModelCatalog: vi.fn(),
loadProviderCatalogModelsForList: vi.fn(),
loadStaticManifestCatalogRowsForList: vi.fn(),
loadProviderIndexCatalogRowsForList: vi.fn(),
hasProviderStaticCatalogForFilter: vi.fn(),
resolveConfiguredEntries: vi.fn(),
printModelTable: vi.fn(),
@@ -91,6 +92,7 @@ function resetMocks() {
mocks.loadModelCatalog.mockResolvedValue([]);
mocks.loadProviderCatalogModelsForList.mockResolvedValue([]);
mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]);
mocks.loadProviderIndexCatalogRowsForList.mockReturnValue([]);
mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false);
mocks.resolveConfiguredEntries.mockReturnValue({
entries: [
@@ -147,12 +149,17 @@ function installModelsListCommandForwardCompatMocks() {
vi.doMock("./list.provider-catalog.js", () => ({
hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter,
loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList,
}));
vi.doMock("./list.manifest-catalog.js", () => ({
loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList,
}));
vi.doMock("./list.provider-index-catalog.js", () => ({
loadProviderIndexCatalogRowsForList: mocks.loadProviderIndexCatalogRowsForList,
}));
vi.doMock("./list.registry-load.js", () => ({
loadListModelRegistry: async (
cfg: unknown,
@@ -190,14 +197,27 @@ function installModelsListCommandForwardCompatMocks() {
},
}));
vi.doMock("./list.runtime.js", () => ({
ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson,
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
vi.doMock("../../agents/auth-profiles/store.js", () => ({
loadAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore,
}));
vi.doMock("../../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
vi.doMock("../../agents/auth-profiles/profile-list.js", () => ({
listProfilesForProvider: mocks.listProfilesForProvider,
}));
vi.doMock("../../agents/model-catalog.js", () => ({
loadModelCatalog: mocks.loadModelCatalog,
loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList,
}));
vi.doMock("../../agents/pi-embedded-runner/model.js", () => ({
resolveModelWithRegistry: mocks.resolveModelWithRegistry,
}));
vi.doMock("../../agents/model-auth.js", () => ({
resolveEnvApiKey: vi.fn().mockReturnValue(undefined),
resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined),
hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false),
@@ -232,7 +252,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = {
),
filter: { provider: "openai-codex" },
};
const seenKeys = listRowsModule.appendDiscoveredRows({
const seenKeys = await listRowsModule.appendDiscoveredRows({
rows: rows as never,
models: loaded.models as never,
modelRegistry: loaded.registry as never,
@@ -256,17 +276,14 @@ beforeEach(() => {
describe("modelsListCommand forward-compat", () => {
describe("configured rows", () => {
it("passes provider filters into registry loading before row assembly", async () => {
it("keeps configured provider filters on the registry-free row path", async () => {
const runtime = createRuntime();
await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.objectContaining({
providerFilter: "moonshot",
}),
);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(mocks.printModelTable).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("No models found.");
});
it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => {
@@ -298,7 +315,6 @@ describe("modelsListCommand forward-compat", () => {
},
],
});
mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_MINI_MODEL });
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
@@ -327,7 +343,6 @@ describe("modelsListCommand forward-compat", () => {
},
],
});
mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_PRO_MODEL });
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
@@ -345,17 +360,12 @@ describe("modelsListCommand forward-compat", () => {
expect(codexPro?.tags).not.toContain("missing");
});
it("loads model registry without source config persistence input", async () => {
it("does not load the model registry for configured-mode listing", async () => {
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.not.objectContaining({
sourceConfig: expect.anything(),
}),
);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
});
it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => {
@@ -416,11 +426,6 @@ describe("modelsListCommand forward-compat", () => {
it("does not require the all-model registry result for configured-mode listing", async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [],
availableKeys: new Set<string>(),
registry: undefined,
});
const runtime = createRuntime();
let observedExitCode: number | undefined;
@@ -433,6 +438,7 @@ describe("modelsListCommand forward-compat", () => {
expect(runtime.error).not.toHaveBeenCalled();
expect(observedExitCode).toBeUndefined();
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(mocks.printModelTable).toHaveBeenCalled();
});
});
@@ -509,12 +515,27 @@ describe("modelsListCommand forward-compat", () => {
it("uses provider index preview rows when an installable provider is not installed", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false);
mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([
{
provider: "moonshot",
id: "kimi-k2.6",
ref: "moonshot/kimi-k2.6",
mergeKey: "moonshot::kimi-k2.6",
name: "Kimi K2.6",
source: "provider-index",
input: ["text", "image"],
reasoning: false,
status: "available",
baseUrl: "https://api.moonshot.ai/v1",
contextWindow: 262_144,
},
]);
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime as never);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled();
expect(mocks.loadProviderCatalogModelsForList).not.toHaveBeenCalled();
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
@@ -568,23 +589,20 @@ describe("modelsListCommand forward-compat", () => {
]);
});
it("keeps the registry path for provider filters without static catalog coverage", async () => {
it("does not fall back to the registry for provider filters without catalog coverage", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false);
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "openrouter", json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.objectContaining({
providerFilter: "openrouter",
}),
);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("No models found.");
});
it("includes provider-owned supplemental catalog rows with provider filters", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [],
availableKeys: new Set(["opencode-go/deepseek-v4-pro"]),
@@ -679,6 +697,7 @@ describe("modelsListCommand forward-compat", () => {
it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
@@ -748,7 +767,7 @@ describe("modelsListCommand forward-compat", () => {
it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
const rows: unknown[] = [];
listRowsModule.appendDiscoveredRows({
await listRowsModule.appendDiscoveredRows({
rows: rows as never,
models: [
{
@@ -796,6 +815,7 @@ describe("modelsListCommand forward-compat", () => {
describe("provider filter canonicalization", () => {
it("matches alias-valued discovered providers against canonical provider filters", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{

View File

@@ -5,13 +5,6 @@ import type { RuntimeEnv } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { resolveConfiguredEntries } from "./list.configured.js";
import { formatErrorWithStack } from "./list.errors.js";
import { hasProviderStaticCatalogForFilter } from "./list.provider-catalog.js";
import { loadConfiguredListModelRegistry, loadListModelRegistry } from "./list.registry-load.js";
import {
appendAllModelRowSources,
appendConfiguredModelRowSources,
modelRowSourcesRequireRegistry,
} from "./list.row-sources.js";
import { printModelTable } from "./list.table.js";
import type { ModelRow } from "./list.types.js";
import { loadModelsConfigWithSource } from "./load-config.js";
@@ -19,6 +12,45 @@ import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const;
type RegistryLoadModule = typeof import("./list.registry-load.js");
type RowSourcesModule = typeof import("./list.row-sources.js");
type ProviderCatalogModule = typeof import("./list.provider-catalog.js");
let registryLoadModulePromise: Promise<RegistryLoadModule> | undefined;
let rowSourcesModulePromise: Promise<RowSourcesModule> | undefined;
let providerCatalogModulePromise: Promise<ProviderCatalogModule> | undefined;
function loadRegistryLoadModule(): Promise<RegistryLoadModule> {
registryLoadModulePromise ??= import("./list.registry-load.js");
return registryLoadModulePromise;
}
function loadRowSourcesModule(): Promise<RowSourcesModule> {
rowSourcesModulePromise ??= import("./list.row-sources.js");
return rowSourcesModulePromise;
}
function loadProviderCatalogModule(): Promise<ProviderCatalogModule> {
providerCatalogModulePromise ??= import("./list.provider-catalog.js");
return providerCatalogModulePromise;
}
function modelRowSourcesRequireRegistry(params: {
all?: boolean;
providerFilter?: string;
useManifestCatalogFastPath: boolean;
useProviderCatalogFastPath: boolean;
useProviderIndexCatalogFastPath: boolean;
}): boolean {
if (!params.all) {
return false;
}
if (params.providerFilter) {
return false;
}
return true;
}
export async function modelsListCommand(
opts: {
all?: boolean;
@@ -48,12 +80,16 @@ export async function modelsListCommand(
if (providerFilter === null) {
return;
}
const { ensureAuthProfileStore, resolveOpenClawAgentDir } = await import("./list.runtime.js");
const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] =
await Promise.all([
import("../../agents/auth-profiles/store.js"),
import("../../agents/agent-paths.js"),
]);
const { resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
});
const authStore = ensureAuthProfileStore();
const authStore = loadAuthProfileStoreWithoutExternalProfiles();
const agentDir = resolveOpenClawAgentDir();
let modelRegistry: ModelRegistry | undefined;
@@ -69,16 +105,24 @@ export async function modelsListCommand(
manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter });
}
const useManifestCatalogFastPath = manifestCatalogRows.length > 0;
const useProviderCatalogFastPath =
!useManifestCatalogFastPath && opts.all && providerFilter
? await hasProviderStaticCatalogForFilter({ cfg, providerFilter })
: false;
if (!useManifestCatalogFastPath && !useProviderCatalogFastPath && opts.all && providerFilter) {
if (!useManifestCatalogFastPath && opts.all && providerFilter) {
const { loadProviderIndexCatalogRowsForList } =
await import("./list.provider-index-catalog.js");
providerIndexCatalogRows = loadProviderIndexCatalogRowsForList({ cfg, providerFilter });
}
const useProviderIndexCatalogFastPath = providerIndexCatalogRows.length > 0;
const useProviderCatalogFastPath = await (async () => {
if (
useManifestCatalogFastPath ||
useProviderIndexCatalogFastPath ||
!opts.all ||
!providerFilter
) {
return false;
}
const { hasProviderStaticCatalogForFilter } = await loadProviderCatalogModule();
return hasProviderStaticCatalogForFilter({ cfg, providerFilter });
})();
const shouldLoadRegistry = modelRowSourcesRequireRegistry({
all: opts.all,
providerFilter,
@@ -87,6 +131,7 @@ export async function modelsListCommand(
useProviderIndexCatalogFastPath,
});
const loadRegistryState = async () => {
const { loadListModelRegistry } = await loadRegistryLoadModule();
const loaded = await loadListModelRegistry(cfg, { providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
@@ -96,7 +141,8 @@ export async function modelsListCommand(
try {
if (shouldLoadRegistry) {
await loadRegistryState();
} else if (!opts.all) {
} else if (!opts.all && opts.local) {
const { loadConfiguredListModelRegistry } = await loadRegistryLoadModule();
const loaded = loadConfiguredListModelRegistry(cfg, entries, { providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
@@ -123,6 +169,7 @@ export async function modelsListCommand(
const rows: ModelRow[] = [];
if (opts.all) {
const { appendAllModelRowSources } = await loadRowSourcesModule();
let rowContext = buildRowContext(
useManifestCatalogFastPath || useProviderCatalogFastPath || useProviderIndexCatalogFastPath,
);
@@ -158,17 +205,12 @@ export async function modelsListCommand(
});
}
} else {
const registry = modelRegistry;
if (!registry) {
runtime.error("Model registry unavailable.");
process.exitCode = 1;
return;
}
appendConfiguredModelRowSources({
const { appendConfiguredModelRowSources } = await loadRowSourcesModule();
await appendConfiguredModelRowSources({
rows,
entries,
modelRegistry: registry,
context: buildRowContext(false),
modelRegistry,
context: buildRowContext(!modelRegistry),
});
}

View File

@@ -6,7 +6,7 @@ import {
} from "./list.provider-catalog.js";
const providerDiscoveryMocks = vi.hoisted(() => ({
loadPluginRegistrySnapshot: vi.fn(),
loadPluginRegistrySnapshotWithMetadata: vi.fn(),
resolvePluginContributionOwners: vi.fn(),
resolveProviderOwners: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
@@ -17,7 +17,8 @@ const providerDiscoveryMocks = vi.hoisted(() => ({
vi.mock("../../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }),
loadPluginRegistrySnapshot: providerDiscoveryMocks.loadPluginRegistrySnapshot,
loadPluginRegistrySnapshotWithMetadata:
providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata,
resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners,
resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners,
}));
@@ -115,8 +116,11 @@ const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider];
describe("loadProviderCatalogModelsForList", () => {
beforeEach(() => {
vi.clearAllMocks();
providerDiscoveryMocks.loadPluginRegistrySnapshot.mockReturnValue({
plugins: [],
providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: {
plugins: [],
},
diagnostics: [],
});
providerDiscoveryMocks.resolveProviderOwners.mockImplementation(
@@ -197,9 +201,10 @@ describe("loadProviderCatalogModelsForList", () => {
}),
).resolves.toEqual(["moonshot"]);
expect(providerDiscoveryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({
expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({
config: baseParams.cfg,
env: baseParams.env,
cache: true,
});
expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
});

View File

@@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
loadPluginRegistrySnapshot,
loadPluginRegistrySnapshotWithMetadata,
resolvePluginContributionOwners,
resolveProviderOwners,
type PluginRegistrySnapshot,
@@ -70,10 +70,15 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: {
env?: NodeJS.ProcessEnv;
providerFilter: string;
}): string[] | undefined {
const index = loadPluginRegistrySnapshot({
const snapshot = loadPluginRegistrySnapshotWithMetadata({
config: params.cfg,
env: params.env,
cache: true,
});
if (snapshot.source !== "persisted" && snapshot.source !== "provided") {
return [];
}
const index = snapshot.snapshot;
const pluginIds = [
...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg),
...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg),

View File

@@ -1,9 +1,10 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js";
import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadModelRegistry } from "./list.registry.js";
import { discoverAuthStorage, discoverModels, resolveOpenClawAgentDir } from "./list.runtime.js";
import type { ConfiguredEntry } from "./list.types.js";
import { modelKey } from "./shared.js";

View File

@@ -1,7 +1,15 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import { listProfilesForProvider } from "../../agents/auth-profiles/profile-list.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import {
hasUsableCustomProviderApiKey,
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js";
import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js";
import {
@@ -10,15 +18,6 @@ import {
shouldFallbackToAuthHeuristics,
} from "./list.errors.js";
import { toModelRow as toModelRowBase } from "./list.model-row.js";
import {
discoverAuthStorage,
discoverModels,
hasUsableCustomProviderApiKey,
listProfilesForProvider,
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
resolveOpenClawAgentDir,
} from "./list.runtime.js";
import type { ModelRow } from "./list.types.js";
import { modelKey } from "./shared.js";

View File

@@ -37,12 +37,7 @@ export function modelRowSourcesRequireRegistry(params: {
if (!params.all) {
return false;
}
if (
params.providerFilter &&
(params.useManifestCatalogFastPath ||
params.useProviderCatalogFastPath ||
params.useProviderIndexCatalogFastPath)
) {
if (params.providerFilter) {
return false;
}
return true;
@@ -58,14 +53,14 @@ export async function appendAllModelRowSources(
params.useProviderIndexCatalogFastPath)
) {
let seenKeys = new Set<string>();
appendConfiguredProviderRows({
await appendConfiguredProviderRows({
rows: params.rows,
context: params.context,
seenKeys,
});
let catalogRows = 0;
if (params.useManifestCatalogFastPath) {
catalogRows = appendManifestCatalogRows({
catalogRows = await appendManifestCatalogRows({
rows: params.rows,
context: params.context,
seenKeys,
@@ -81,7 +76,7 @@ export async function appendAllModelRowSources(
});
}
if (catalogRows === 0 && params.useProviderIndexCatalogFastPath) {
catalogRows = appendModelCatalogRows({
catalogRows = await appendModelCatalogRows({
rows: params.rows,
context: params.context,
seenKeys,
@@ -92,7 +87,7 @@ export async function appendAllModelRowSources(
if (!params.modelRegistry) {
return { requiresRegistryFallback: true };
}
appendDiscoveredRows({
await appendDiscoveredRows({
rows: params.rows,
models: params.modelRegistry.getAll(),
modelRegistry: params.modelRegistry,
@@ -102,14 +97,14 @@ export async function appendAllModelRowSources(
return { requiresRegistryFallback: false };
}
const seenKeys = appendDiscoveredRows({
const seenKeys = await appendDiscoveredRows({
rows: params.rows,
models: params.modelRegistry?.getAll() ?? [],
modelRegistry: params.modelRegistry,
context: params.context,
});
appendConfiguredProviderRows({
await appendConfiguredProviderRows({
rows: params.rows,
context: params.context,
seenKeys,
@@ -133,11 +128,11 @@ export async function appendAllModelRowSources(
return { requiresRegistryFallback: false };
}
export function appendConfiguredModelRowSources(params: {
export async function appendConfiguredModelRowSources(params: {
rows: ModelRow[];
entries: ConfiguredEntry[];
modelRegistry: ModelRegistry;
modelRegistry?: ModelRegistry;
context: RowBuilderContext;
}): void {
appendConfiguredRows(params);
}): Promise<void> {
await appendConfiguredRows(params);
}

View File

@@ -23,9 +23,15 @@ vi.mock("../../agents/model-suppression.js", () => ({
shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel,
}));
vi.mock("./list.runtime.js", () => ({
vi.mock("./list.provider-catalog.js", () => ({
loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList,
}));
vi.mock("../../agents/auth-profiles/profile-list.js", () => ({
listProfilesForProvider: mocks.listProfilesForProvider,
}));
vi.mock("../../agents/model-auth.js", () => ({
resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined),
resolveEnvApiKey: vi.fn().mockReturnValue(null),
hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false),

View File

@@ -2,22 +2,27 @@ import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
hasUsableCustomProviderApiKey,
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js";
import { normalizeProviderId } from "../../agents/provider-id.js";
import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js";
import type { ListRowModel } from "./list.model-row.js";
import { toModelRow } from "./list.registry.js";
import {
loadModelCatalog,
loadProviderCatalogModelsForList,
resolveModelWithRegistry,
} from "./list.runtime.js";
import { toModelRow } from "./list.model-row.js";
import type { ConfiguredEntry, ModelRow } from "./list.types.js";
import { isLocalBaseUrl, modelKey } from "./shared.js";
type ConfiguredByKey = Map<string, ConfiguredEntry>;
type ModelCatalogModule = typeof import("../../agents/model-catalog.js");
type ModelResolverModule = typeof import("../../agents/pi-embedded-runner/model.js");
type ProfileListModule = typeof import("../../agents/auth-profiles/profile-list.js");
type ProviderCatalogModule = typeof import("./list.provider-catalog.js");
type SyntheticAuthModule = typeof import("../../plugins/synthetic-auth.runtime.js");
type RowFilter = {
provider?: string;
@@ -35,6 +40,37 @@ export type RowBuilderContext = {
skipRuntimeModelSuppression?: boolean;
};
let modelCatalogModulePromise: Promise<ModelCatalogModule> | undefined;
let modelResolverModulePromise: Promise<ModelResolverModule> | undefined;
let profileListModulePromise: Promise<ProfileListModule> | undefined;
let providerCatalogModulePromise: Promise<ProviderCatalogModule> | undefined;
let syntheticAuthModulePromise: Promise<SyntheticAuthModule> | undefined;
function loadModelCatalogModule(): Promise<ModelCatalogModule> {
modelCatalogModulePromise ??= import("../../agents/model-catalog.js");
return modelCatalogModulePromise;
}
function loadModelResolverModule(): Promise<ModelResolverModule> {
modelResolverModulePromise ??= import("../../agents/pi-embedded-runner/model.js");
return modelResolverModulePromise;
}
function loadProfileListModule(): Promise<ProfileListModule> {
profileListModulePromise ??= import("../../agents/auth-profiles/profile-list.js");
return profileListModulePromise;
}
function loadProviderCatalogModule(): Promise<ProviderCatalogModule> {
providerCatalogModulePromise ??= import("./list.provider-catalog.js");
return providerCatalogModulePromise;
}
function loadSyntheticAuthModule(): Promise<SyntheticAuthModule> {
syntheticAuthModulePromise ??= import("../../plugins/synthetic-auth.runtime.js");
return syntheticAuthModulePromise;
}
function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) {
if (filter.provider && normalizeProviderId(model.provider) !== filter.provider) {
return false;
@@ -45,13 +81,44 @@ function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?
return true;
}
function buildRow(params: {
async function hasAuthForProvider(params: {
provider: string;
cfg: OpenClawConfig;
authStore: AuthProfileStore;
}): Promise<boolean> {
const { listProfilesForProvider } = await loadProfileListModule();
if (listProfilesForProvider(params.authStore, params.provider).length > 0) {
return true;
}
if (params.provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) {
return true;
}
if (resolveEnvApiKey(params.provider)) {
return true;
}
if (hasUsableCustomProviderApiKey(params.cfg, params.provider)) {
return true;
}
const { resolveRuntimeSyntheticAuthProviderRefs } = await loadSyntheticAuthModule();
return resolveRuntimeSyntheticAuthProviderRefs().includes(params.provider);
}
async function buildRow(params: {
model: ListRowModel;
key: string;
context: RowBuilderContext;
allowProviderAvailabilityFallback?: boolean;
}): ModelRow {
}): Promise<ModelRow> {
const configured = params.context.configuredByKey.get(params.key);
const shouldResolveProviderAuth =
params.context.availableKeys === undefined || params.allowProviderAvailabilityFallback === true;
const hasProviderAuth = shouldResolveProviderAuth
? await hasAuthForProvider({
provider: params.model.provider,
cfg: params.context.cfg,
authStore: params.context.authStore,
})
: false;
return toModelRow({
model: params.model,
key: params.key,
@@ -61,6 +128,7 @@ function buildRow(params: {
cfg: params.context.cfg,
authStore: params.context.authStore,
allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false,
hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined,
});
}
@@ -79,14 +147,14 @@ function shouldSuppressListModel(params: {
});
}
function appendVisibleRow(params: {
async function appendVisibleRow(params: {
rows: ModelRow[];
model: ListRowModel;
key: string;
context: RowBuilderContext;
seenKeys?: Set<string>;
allowProviderAvailabilityFallback?: boolean;
}): boolean {
}): Promise<boolean> {
if (params.seenKeys?.has(params.key)) {
return false;
}
@@ -97,7 +165,7 @@ function appendVisibleRow(params: {
return false;
}
params.rows.push(
buildRow({
await buildRow({
model: params.model,
key: params.key,
context: params.context,
@@ -153,13 +221,49 @@ function shouldListConfiguredProviderModel(params: {
return params.providerConfig.api !== undefined || params.model.api !== undefined;
}
export function appendDiscoveredRows(params: {
function findConfiguredProviderModel(params: {
cfg: OpenClawConfig;
provider: string;
modelId: string;
}): ListRowModel | undefined {
const providerConfig = params.cfg.models?.providers?.[params.provider];
const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId);
if (!providerConfig || !configuredModel) {
return undefined;
}
return toConfiguredProviderListModel({
provider: params.provider,
providerConfig,
model: configuredModel,
});
}
function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConfig): ListRowModel {
return (
findConfiguredProviderModel({
cfg,
provider: entry.ref.provider,
modelId: entry.ref.model,
}) ?? {
provider: entry.ref.provider,
id: entry.ref.model,
name: entry.ref.model,
input: ["text"],
contextWindow: DEFAULT_CONTEXT_TOKENS,
}
);
}
export async function appendDiscoveredRows(params: {
rows: ModelRow[];
models: Model<Api>[];
modelRegistry?: ModelRegistry;
context: RowBuilderContext;
}): Set<string> {
}): Promise<Set<string>> {
const seenKeys = new Set<string>();
const modelResolver = params.modelRegistry
? (await loadModelResolverModule()).resolveModelWithRegistry
: undefined;
const sorted = [...params.models].toSorted((a, b) => {
const providerCompare = a.provider.localeCompare(b.provider);
if (providerCompare !== 0) {
@@ -170,20 +274,21 @@ export function appendDiscoveredRows(params: {
for (const model of sorted) {
const key = modelKey(model.provider, model.id);
const resolvedModel = params.modelRegistry
? resolveModelWithRegistry({
provider: model.provider,
modelId: model.id,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
agentDir: params.context.agentDir,
})
: undefined;
const resolvedModel =
params.modelRegistry && modelResolver
? modelResolver({
provider: model.provider,
modelId: model.id,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
agentDir: params.context.agentDir,
})
: undefined;
const rowModel =
resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key
? resolvedModel
: model;
appendVisibleRow({
await appendVisibleRow({
rows: params.rows,
model: rowModel,
key,
@@ -195,11 +300,11 @@ export function appendDiscoveredRows(params: {
return seenKeys;
}
export function appendConfiguredProviderRows(params: {
export async function appendConfiguredProviderRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
}): void {
}): Promise<void> {
for (const [provider, providerConfig] of Object.entries(
params.context.cfg.models?.providers ?? {},
)) {
@@ -213,7 +318,7 @@ export function appendConfiguredProviderRows(params: {
providerConfig,
model: configuredModel,
});
appendVisibleRow({
await appendVisibleRow({
rows: params.rows,
model,
key,
@@ -225,17 +330,17 @@ export function appendConfiguredProviderRows(params: {
}
}
export function appendModelCatalogRows(params: {
export async function appendModelCatalogRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
catalogRows: readonly NormalizedModelCatalogRow[];
}): number {
}): Promise<number> {
let appended = 0;
for (const catalogRow of params.catalogRows) {
const key = modelKey(catalogRow.provider, catalogRow.id);
if (
appendVisibleRow({
await appendVisibleRow({
rows: params.rows,
model: toManifestCatalogListModel(catalogRow),
key,
@@ -255,7 +360,7 @@ export function appendManifestCatalogRows(params: {
context: RowBuilderContext;
seenKeys: Set<string>;
manifestRows: readonly NormalizedModelCatalogRow[];
}): number {
}): Promise<number> {
return appendModelCatalogRows({
...params,
catalogRows: params.manifestRows,
@@ -268,6 +373,10 @@ export async function appendCatalogSupplementRows(params: {
context: RowBuilderContext;
seenKeys: Set<string>;
}): Promise<void> {
const [{ loadModelCatalog }, { resolveModelWithRegistry }] = await Promise.all([
loadModelCatalogModule(),
loadModelResolverModule(),
]);
const catalog = await loadModelCatalog({ config: params.context.cfg, readOnly: true });
for (const entry of catalog) {
if (
@@ -286,7 +395,7 @@ export async function appendCatalogSupplementRows(params: {
if (!model) {
continue;
}
appendVisibleRow({
await appendVisibleRow({
rows: params.rows,
model,
key,
@@ -314,6 +423,7 @@ export async function appendProviderCatalogRows(params: {
staticOnly?: boolean;
}): Promise<number> {
let appended = 0;
const { loadProviderCatalogModelsForList } = await loadProviderCatalogModule();
for (const model of await loadProviderCatalogModelsForList({
cfg: params.context.cfg,
agentDir: params.context.agentDir,
@@ -322,7 +432,7 @@ export async function appendProviderCatalogRows(params: {
})) {
const key = modelKey(model.provider, model.id);
if (
appendVisibleRow({
await appendVisibleRow({
rows: params.rows,
model,
key,
@@ -337,12 +447,15 @@ export async function appendProviderCatalogRows(params: {
return appended;
}
export function appendConfiguredRows(params: {
export async function appendConfiguredRows(params: {
rows: ModelRow[];
entries: ConfiguredEntry[];
modelRegistry: ModelRegistry;
modelRegistry?: ModelRegistry;
context: RowBuilderContext;
}) {
}): Promise<void> {
const resolveModelWithRegistry = params.modelRegistry
? (await loadModelResolverModule()).resolveModelWithRegistry
: undefined;
for (const entry of params.entries) {
if (
params.context.filter.provider &&
@@ -350,12 +463,15 @@ export function appendConfiguredRows(params: {
) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.ref.provider,
modelId: entry.ref.model,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
});
const model =
params.modelRegistry && resolveModelWithRegistry
? resolveModelWithRegistry({
provider: entry.ref.provider,
modelId: entry.ref.model,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
})
: toFallbackConfiguredListModel(entry, params.context.cfg);
if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) {
continue;
}
@@ -365,6 +481,17 @@ export function appendConfiguredRows(params: {
if (model && shouldSuppressListModel({ model, context: params.context })) {
continue;
}
const shouldResolveProviderAuth =
model &&
(params.context.availableKeys === undefined ||
!params.context.discoveredKeys.has(modelKey(model.provider, model.id)));
const hasProviderAuth = shouldResolveProviderAuth
? await hasAuthForProvider({
provider: model.provider,
cfg: params.context.cfg,
authStore: params.context.authStore,
})
: false;
params.rows.push(
toModelRow({
model,
@@ -377,6 +504,7 @@ export function appendConfiguredRows(params: {
allowProviderAvailabilityFallback: model
? !params.context.discoveredKeys.has(modelKey(model.provider, model.id))
: false,
hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined,
}),
);
}

View File

@@ -2,26 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const getPluginRegistryState = vi.hoisted(() => vi.fn());
const pluginRegistryMocks = vi.hoisted(() => ({
loadPluginManifestRegistryForInstalledIndex: vi.fn(),
loadPluginRegistrySnapshot: vi.fn((_params?: unknown) => ({ plugins: [] })),
loadPluginRegistrySnapshotWithMetadata: vi.fn((_params?: unknown) => ({
source: "persisted",
snapshot: { plugins: [] },
diagnostics: [],
})),
}));
vi.mock("./runtime-state.js", () => ({
getPluginRegistryState,
}));
vi.mock("./manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndex:
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex,
}));
vi.mock("./plugin-registry.js", () => ({
loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot,
loadPluginManifestRegistryForPluginRegistry: () =>
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex({
index: pluginRegistryMocks.loadPluginRegistrySnapshot({ cache: true }),
includeDisabled: true,
}),
loadPluginRegistrySnapshotWithMetadata:
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata,
}));
import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtime.js";
@@ -29,19 +23,24 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtim
describe("synthetic auth runtime refs", () => {
beforeEach(() => {
getPluginRegistryState.mockReset();
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex
.mockReset()
.mockReturnValue({ plugins: [] });
pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset().mockReturnValue({ plugins: [] });
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset().mockReturnValue({
source: "persisted",
snapshot: { plugins: [] },
diagnostics: [],
});
});
it("uses manifest-owned synthetic auth refs before the runtime registry exists", () => {
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
plugins: [
{ syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] },
{ syntheticAuthRefs: ["remote-provider"] },
{ syntheticAuthRefs: [] },
],
it("uses persisted registry synthetic auth refs before the runtime registry exists", () => {
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: {
plugins: [
{ syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] },
{ syntheticAuthRefs: ["remote-provider"] },
{ syntheticAuthRefs: [] },
],
},
diagnostics: [],
});
expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([
@@ -49,13 +48,27 @@ describe("synthetic auth runtime refs", () => {
"local-cli",
"remote-provider",
]);
expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ cache: true });
expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({
index: expect.anything(),
includeDisabled: true,
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({
cache: true,
});
});
it("does not derive the registry just to resolve synthetic auth refs", () => {
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "derived",
snapshot: {
plugins: [
{ syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] },
{ syntheticAuthRefs: ["remote-provider"] },
{ syntheticAuthRefs: [] },
],
},
diagnostics: [],
});
expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([]);
});
it("prefers the active runtime registry when plugins are already loaded", () => {
getPluginRegistryState.mockReturnValue({
activeRegistry: {
@@ -84,7 +97,6 @@ describe("synthetic auth runtime refs", () => {
});
expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual(["runtime-provider", "runtime-cli"]);
expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
expect(pluginRegistryMocks.loadPluginRegistrySnapshot).not.toHaveBeenCalled();
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js";
import { getPluginRegistryState } from "./runtime-state.js";
function uniqueProviderRefs(values: readonly string[]): string[] {
@@ -18,10 +18,12 @@ function uniqueProviderRefs(values: readonly string[]): string[] {
}
function resolveManifestSyntheticAuthProviderRefs(): string[] {
const result = loadPluginRegistrySnapshotWithMetadata({ cache: true });
if (result.source !== "persisted" && result.source !== "provided") {
return [];
}
return uniqueProviderRefs(
loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true }).plugins.flatMap(
(plugin) => plugin.syntheticAuthRefs ?? [],
),
result.snapshot.plugins.flatMap((plugin) => plugin.syntheticAuthRefs ?? []),
);
}