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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user