fix(coding-agent): load extensions before --list-models

The --list-models handler in runRootCommand short-circuited to
listModels() right after Settings.init and modelRegistry.refresh,
exiting before extension loading ran in createAgentSession. As a
result, providers contributed via pi.registerProvider() (from -e
paths or settings.extensions) never appeared in the listing.

Extract a runListModelsCommand entry point in cli/list-models.ts
that loads extensions (CLI -e paths and settings.extensions) into
the supplied ModelRegistry, mirroring sdk.ts's handoff of pending
provider registrations, and then delegates to listModels. The load
is intentionally narrow: no agent loop, no MCP servers, no custom
tools.

Fixes #905
This commit is contained in:
can1357
2026-05-02 07:46:45 +02:00
parent cb0d51b0f1
commit 5d1ad6e80b
4 changed files with 174 additions and 10 deletions
+8 -7
View File
@@ -28,13 +28,14 @@ describe("opencode-go resolver routes 404-ing ids to openai-completions (issue #
// would route them to /v1/messages on opencode.ai/zen/go which 404s.
const npmAnthropic: ModelsDevModel = { provider: { npm: "@ai-sdk/anthropic" }, tool_call: true };
test.each([["minimax-m2.7"], ["qwen3.5-plus"], ["qwen3.6-plus"]])(
"%s resolves to openai-completions on /v1/chat/completions",
modelId => {
const resolved = descriptor?.resolveApi?.(modelId, npmAnthropic);
expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE });
},
);
test.each([
["minimax-m2.7"],
["qwen3.5-plus"],
["qwen3.6-plus"],
])("%s resolves to openai-completions on /v1/chat/completions", modelId => {
const resolved = descriptor?.resolveApi?.(modelId, npmAnthropic);
expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE });
});
test("minimax-m2.5 (control: works empirically) also resolves to openai-completions", () => {
// models.dev currently lists minimax-m2.5 without an explicit provider.npm,
@@ -4,6 +4,8 @@
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
import { EventBus } from "../utils/event-bus";
import { fuzzyFilter } from "../utils/fuzzy";
interface ProviderRow {
@@ -126,3 +128,67 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s
});
}
}
/**
* Options for the `--list-models` command entry point.
*/
export interface RunListModelsOptions {
modelRegistry: ModelRegistry;
cwd: string;
/** CLI-supplied extension paths (e.g. from `-e <path>`). */
additionalExtensionPaths?: string[];
/** Extension paths configured under `extensions:` in user settings. */
settingsExtensions?: string[];
/** Disabled extension ids from settings (`disabledExtensions`). */
disabledExtensionIds?: string[];
/** When true, skip discovery and only load `additionalExtensionPaths`. */
disableExtensionDiscovery?: boolean;
searchPattern?: string;
}
/**
* Loads extensions (CLI `-e` paths and `settings.extensions`) and surfaces
* any provider/model registrations on the supplied `modelRegistry` before
* delegating to {@link listModels}. This is the single entry point used by
* `--list-models` and exists to ensure extension-contributed providers are
* visible in the listing (issue #905). The load is intentionally narrow:
* no agent loop, no MCP servers, no custom-tool registration.
*/
export async function runListModelsCommand(options: RunListModelsOptions): Promise<void> {
const {
modelRegistry,
cwd,
additionalExtensionPaths = [],
settingsExtensions = [],
disabledExtensionIds = [],
disableExtensionDiscovery = false,
searchPattern,
} = options;
const eventBus = new EventBus();
const extensionsResult = disableExtensionDiscovery
? await loadExtensions(additionalExtensionPaths, cwd, eventBus)
: await discoverAndLoadExtensions(
[...additionalExtensionPaths, ...settingsExtensions],
cwd,
eventBus,
disabledExtensionIds,
);
for (const { path: extPath, error } of extensionsResult.errors) {
process.stderr.write(`Failed to load extension: ${extPath}: ${error}\n`);
}
// Mirror sdk.ts: drain pending provider registrations into the registry.
const activeSources = extensionsResult.extensions.map(extension => extension.path);
modelRegistry.syncExtensionSources(activeSources);
for (const sourceId of new Set(activeSources)) {
modelRegistry.clearSourceRegistrations(sourceId);
}
for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
modelRegistry.registerProvider(name, config, sourceId);
}
extensionsResult.runtime.pendingProviderRegistrations = [];
await listModels(modelRegistry, searchPattern);
}
+18 -3
View File
@@ -17,7 +17,7 @@ import { invalidate as invalidateFsCache } from "./capability/fs";
import type { Args } from "./cli/args";
import { processFileArguments } from "./cli/file-processor";
import { buildInitialMessage } from "./cli/initial-message";
import { listModels } from "./cli/list-models";
import { runListModelsCommand } from "./cli/list-models";
import { selectSession } from "./cli/session-picker";
import { findConfigFile } from "./config";
import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
@@ -604,10 +604,25 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
}
if (parsedArgs.listModels !== undefined) {
await logger.time("settings:init:list-models", Settings.init, { cwd: getProjectDir() });
const settingsInstance = await logger.time("settings:init:list-models", Settings.init, {
cwd: getProjectDir(),
});
await modelRegistry.refresh("online");
const cliExtensionPaths = parsedArgs.noExtensions
? []
: [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
const settingsExtensions = settingsInstance.get("extensions") ?? [];
const disabledExtensionIds = settingsInstance.get("disabledExtensions") ?? [];
const searchPattern = typeof parsedArgs.listModels === "string" ? parsedArgs.listModels : undefined;
await listModels(modelRegistry, searchPattern);
await runListModelsCommand({
modelRegistry,
cwd: getProjectDir(),
additionalExtensionPaths: cliExtensionPaths,
settingsExtensions,
disabledExtensionIds,
disableExtensionDiscovery: Boolean(parsedArgs.noExtensions),
searchPattern,
});
process.exit(0);
}
@@ -0,0 +1,82 @@
/**
* Regression test for issue #905.
*
* `omp --list-models` did not include providers contributed by extensions
* (via `pi.registerProvider(...)`), regardless of whether the extension was
* supplied via `-e <path>` or configured under `extensions:` in the user
* settings. The `--list-models` short-circuit in `runRootCommand` exited
* before extensions were loaded.
*
* Contract under test: the public list-models entry point loads extensions
* (CLI `-e` paths and configured `settings.extensions`) before listing, so
* extension-registered providers/models appear in the output.
*/
import { afterAll, beforeAll, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage } from "@oh-my-pi/pi-ai";
import { runListModelsCommand } from "../src/cli/list-models";
import { ModelRegistry } from "../src/config/model-registry";
let tmp: string;
let extPath: string;
let dbPath: string;
beforeAll(async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "issue-905-"));
extPath = path.join(tmp, "ext.ts");
dbPath = path.join(tmp, "auth.db");
await fs.writeFile(
extPath,
`export default function (pi) {
pi.registerProvider("test-gw", {
baseUrl: "https://example.com/v1",
apiKey: "literal-test-key",
api: "openai-completions",
models: [{
id: "test-model",
name: "Test Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
}],
});
}
`,
);
});
afterAll(async () => {
await fs.rm(tmp, { recursive: true, force: true });
});
test("--list-models surfaces extension-registered providers (issue #905)", async () => {
const authStorage = await AuthStorage.create(dbPath);
const modelRegistry = new ModelRegistry(authStorage);
const captured: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: string | Uint8Array) => {
captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stdout.write;
try {
await runListModelsCommand({
modelRegistry,
cwd: tmp,
additionalExtensionPaths: [extPath],
disableExtensionDiscovery: true,
});
} finally {
process.stdout.write = originalWrite;
}
const output = captured.join("");
expect(output).toContain("test-gw");
expect(output).toContain("test-model");
});