From 5d1ad6e80b9200c7aa408c97cfdd355dad892d39 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 2 May 2026 07:46:45 +0200 Subject: [PATCH] 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 --- packages/ai/test/issue-887-repro.test.ts | 15 ++-- packages/coding-agent/src/cli/list-models.ts | 66 +++++++++++++++ packages/coding-agent/src/main.ts | 21 ++++- .../coding-agent/test/issue-905-repro.test.ts | 82 +++++++++++++++++++ 4 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/issue-905-repro.test.ts diff --git a/packages/ai/test/issue-887-repro.test.ts b/packages/ai/test/issue-887-repro.test.ts index d50492556..6ae43112b 100644 --- a/packages/ai/test/issue-887-repro.test.ts +++ b/packages/ai/test/issue-887-repro.test.ts @@ -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, diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index 4a1af2f80..d4191d617 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -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 `). */ + 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 { + 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); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 69e682f45..4a87a3c42 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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` 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"); +});