From 97f9978fa66685f78d2da19ae22e20c46d125f74 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Jul 2026 12:46:49 +0200 Subject: [PATCH] feat(coding-agent): add model catalog refresh flag --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 2 +- packages/ai/src/models.ts | 12 ++- packages/ai/test/models-runtime.test.ts | 7 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/packages.md | 1 + packages/coding-agent/docs/usage.md | 1 + packages/coding-agent/src/cli/args.ts | 2 +- .../src/core/remote-catalog-provider.ts | 1 + .../coding-agent/src/package-manager-cli.ts | 73 +++++++++++++++++-- .../test/package-command-paths.test.ts | 37 ++++++++++ .../test/remote-catalog-provider.test.ts | 25 +++++-- 13 files changed, 148 insertions(+), 19 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 02d58490..1734415e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,6 +17,7 @@ - Added neutral auth-flow information/link events and provider-owned Amazon Bedrock and Google Vertex AI credential selection flows. - Added `ModelsStore` with an in-memory default for restoring and persisting dynamic provider catalogs. - Added the dynamic Radius `pi-messages` gateway provider with OAuth and credential-specific catalog refresh. +- Added `Models.refresh({ force: true })` to let providers bypass freshness checks for explicit refreshes. ### Changed diff --git a/packages/ai/README.md b/packages/ai/README.md index fcb272a2..e1e03582 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1047,7 +1047,7 @@ if (result.aborted) console.log('refresh cancelled'); for (const [provider, error] of result.errors) console.error(provider, error); ``` -Use `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access. Model reads stay synchronous and return the last restored or refreshed list. +Use `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access, or `models.refresh({ force: true })` to bypass provider freshness checks. Model reads stay synchronous and return the last restored or refreshed list. Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags. `Models.getAuth(model)` includes those model headers, and stream methods merge them before explicit request headers and `transformHeaders`. See [OpenAI Compatibility Settings](#openai-compatibility-settings). diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 7938ef12..d5eed1a4 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -38,11 +38,15 @@ export interface RefreshModelsContext { store: ProviderModelsStore; /** False during offline/cache-only initialization. */ allowNetwork: boolean; + /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ + force?: boolean; signal?: AbortSignal; } export interface ModelsRefreshOptions { allowNetwork?: boolean; + /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ + force?: boolean; signal?: AbortSignal; } @@ -290,7 +294,13 @@ class ModelsImpl implements MutableModels { stored = await this.readCredential(provider.id); const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal); if (!credential) return; - await provider.refreshModels({ credential, store, allowNetwork, signal: options.signal }); + await provider.refreshModels({ + credential, + store, + allowNetwork, + force: options.force, + signal: options.signal, + }); } catch (error) { if (!options.signal?.aborted) { errors.set( diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index d0d8da18..eaa6a164 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -283,8 +283,9 @@ describe("Models runtime", () => { expect(offline.getModel("dynamic", "fetched")).toBeDefined(); }); - it("passes effective API-key credentials and skips unconfigured providers", async () => { + it("passes effective API-key credentials and refresh options while skipping unconfigured providers", async () => { let effectiveCredential: unknown; + let forceRefresh: boolean | undefined; let unconfiguredRefreshes = 0; const models = createModels(); models.setProvider( @@ -293,6 +294,7 @@ describe("Models runtime", () => { auth: { apiKey: envKeyAuth("ambient-key") }, refreshModels: async (context) => { effectiveCredential = context.credential; + forceRefresh = context.force; }, }), ); @@ -306,8 +308,9 @@ describe("Models runtime", () => { }), ); - await models.refresh(); + await models.refresh({ force: true }); expect(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined }); + expect(forceRefresh).toBe(true); expect(unconfiguredRefreshes).toBe(0); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 584d82e3..27579d2e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - Added provider-owned `/login` discovery directly from registered pi-ai providers, including ambient auth status and informational links. - Added file-backed dynamic catalogs in `models-store.json`, per-provider pi.dev catalog overlays, and Radius gateway support including offline migration from legacy credential-cached catalogs. - Added extension provider `refreshModels(context)` support for dynamic model discovery with optional provider-controlled persistence. +- Added `pi update --models` to force an immediate model catalog refresh without updating pi or extensions. ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1bf18be1..c452fd36 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -96,7 +96,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write` ## Providers & Models -For each built-in provider, pi maintains a list of tool-capable models, updated with every release. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). +For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). **Subscriptions:** - Anthropic Claude Pro/Max @@ -421,6 +421,7 @@ pi list pi update # update pi only pi update --all # update pi and packages pi update --extensions # update packages only +pi update --models # refresh model catalogs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/pi-tools # update one package @@ -519,6 +520,7 @@ pi uninstall [-l] # Alias for remove pi update [source|self|pi] # Update pi only, or one package source pi update --all # Update pi and packages pi update --extensions # Update packages only +pi update --models # Refresh model catalogs only pi update --self # Update pi only pi update --self --force # Reinstall pi even if current pi update --extension # Update one package diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index a5a0cae9..b2f493b4 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -31,6 +31,7 @@ pi list # show installed packages from settings pi update # update pi only pi update --all # update pi, update packages, and reconcile pinned git refs pi update --extensions # update packages and reconcile pinned git refs only +pi update --models # refresh model catalogs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/bar # update one package diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 49b41927..7d0b85ec 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -151,6 +151,7 @@ pi uninstall [-l] # Alias for remove pi update [source|self|pi] # Update pi only, or one package source pi update --all # Update pi and packages; reconcile pinned git refs pi update --extensions # Update packages only; reconcile pinned git refs +pi update --models # Refresh model catalogs only pi update --self # Update pi only pi update --extension # Update one package pi list # List installed packages diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d06a4752..0ca33969 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -229,7 +229,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove - ${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions) + ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index 411f2122..6667916d 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -44,6 +44,7 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id); if (!context.allowNetwork || context.signal?.aborted) return; if ( + !context.force && stored?.checkedAt !== undefined && Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS ) { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 5c1a0a1c..2fe66563 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { selectConfig } from "./cli/config-selector.ts"; @@ -16,6 +17,7 @@ import { VERSION, } from "./config.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; +import { ModelRuntime } from "./core/model-runtime.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; @@ -30,7 +32,7 @@ import { export type PackageCommand = "install" | "remove" | "update" | "list"; -type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string }; +type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), @@ -81,7 +83,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension ] [--approve|--no-approve] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self|--extensions|--models|--all] [--extension ] [--approve|--no-approve] [--force]`; case "list": return `${APP_NAME} list [--approve|--no-approve]`; } @@ -149,11 +151,12 @@ Examples: console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("update")} -Update pi and installed packages. +Update pi, installed packages, or model catalogs. Options: --self Update pi only (default when no target is given) --extensions Update installed packages only + --models Refresh model catalogs only --all Update pi and installed packages --extension Update one package only -a, --approve Trust project-local files for this command @@ -163,6 +166,7 @@ Options: Short forms: ${APP_NAME} update Update pi only ${APP_NAME} update --all Update pi and all extensions + ${APP_NAME} update --models Refresh model catalogs only ${APP_NAME} update Update one package ${APP_NAME} update pi Update pi only (self works as alias to pi) `); @@ -205,6 +209,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let source: string | undefined; let selfFlag = false; let extensionsFlag = false; + let modelsFlag = false; let allFlag = false; let extensionFlagSource: string | undefined; @@ -242,6 +247,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--models") { + if (command === "update") { + modelsFlag = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + if (arg === "--all") { if (command === "update") { allFlag = true; @@ -304,15 +318,24 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let updateTarget: UpdateTarget | undefined; let showExtensionsSkippedNote = false; if (command === "update") { - if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) { + if (allFlag && (selfFlag || extensionsFlag || modelsFlag || extensionFlagSource)) { conflictingOptions = - conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension"; + conflictingOptions ?? "--all cannot be combined with --self, --extensions, --models, or --extension"; } if (allFlag && source) { conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source"; } - if (extensionFlagSource) { + if (modelsFlag) { + if (selfFlag || extensionsFlag || allFlag || extensionFlagSource) { + conflictingOptions = + conflictingOptions ?? "--models cannot be combined with --self, --extensions, --all, or --extension"; + } + if (source) { + conflictingOptions = conflictingOptions ?? "--models cannot be combined with a positional source"; + } + updateTarget = { type: "models" }; + } else if (extensionFlagSource) { if (selfFlag || extensionsFlag || allFlag) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all"; @@ -371,6 +394,33 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean { return target.type === "all" || target.type === "extensions"; } +async function refreshModelCatalogs(agentDir: string): Promise { + const modelRuntime = await ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork: false, + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const result = await modelRuntime.refresh({ + allowNetwork: true, + force: true, + signal: controller.signal, + }); + if (result.aborted) { + throw new Error("Model catalog refresh timed out."); + } + if (result.errors.size > 0) { + const details = Array.from(result.errors, ([provider, error]) => `${provider}: ${error.message}`).join("; "); + throw new Error(`Could not refresh model catalogs: ${details}`); + } + } finally { + clearTimeout(timeout); + } + console.log(chalk.green("Model catalogs refreshed")); +} + function printSelfUpdateUnavailable( npmCommand?: string[], updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME, @@ -673,6 +723,17 @@ export async function handlePackageCommand( return true; } + if (options.command === "update" && options.updateTarget?.type === "models") { + try { + await refreshModelCatalogs(getAgentDir()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown model catalog refresh error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + } + return true; + } + const cwd = process.cwd(); const agentDir = getAgentDir(); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 8e202253..4158f3f0 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; import type { ResolvedPaths } from "../src/core/package-manager.ts"; import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; import { ProjectTrustStore } from "../src/core/trust-manager.ts"; @@ -371,6 +372,42 @@ describe("package commands", () => { } }); + it("refreshes only model catalogs with update --models", async () => { + const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() })); + const create = vi.spyOn(ModelRuntime, "create").mockResolvedValue({ refresh } as unknown as ModelRuntime); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--models"])).resolves.toBeUndefined(); + + expect(create).toHaveBeenCalledWith({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork: false, + }); + expect(refresh).toHaveBeenCalledWith({ + allowNetwork: true, + force: true, + signal: expect.any(AbortSignal), + }); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("Model catalogs refreshed"); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects update --models combined with another update target", async () => { + const create = vi.spyOn(ModelRuntime, "create"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--models", "--self"])).resolves.toBeUndefined(); + + expect(create).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "--models cannot be combined with --self", + ); + expect(process.exitCode).toBe(1); + }); + it("cycles project package overrides in config local mode", async () => { const storage = new InMemorySettingsStorage(); storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] })); diff --git a/packages/coding-agent/test/remote-catalog-provider.test.ts b/packages/coding-agent/test/remote-catalog-provider.test.ts index cf4d68fa..c8c5f869 100644 --- a/packages/coding-agent/test/remote-catalog-provider.test.ts +++ b/packages/coding-agent/test/remote-catalog-provider.test.ts @@ -21,12 +21,13 @@ function model(id: string): Model<"openai-completions"> { afterEach(() => vi.restoreAllMocks()); describe("remote catalog provider", () => { - it("parses keyed catalogs, sends version headers, and observes the refresh TTL", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ dynamic: model("dynamic") }), { - status: 200, - headers: { "content-type": "application/json" }, - }), + it("parses keyed catalogs, sends version headers, observes the refresh TTL, and supports forced refreshes", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(JSON.stringify({ dynamic: model("dynamic") }), { + status: 200, + headers: { "content-type": "application/json" }, + }), ); const provider = withRemoteCatalog( createProvider({ @@ -62,10 +63,20 @@ describe("remote catalog provider", () => { }, allowNetwork: true, }); + await provider.refreshModels?.({ + credential: { type: "api_key" }, + store: { + read: () => store.read(provider.id), + write: (entry) => store.write(provider.id, entry), + delete: () => store.delete(provider.id), + }, + allowNetwork: true, + force: true, + }); expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]); expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy.mock.calls[0]?.[1]?.headers).toMatchObject({ "User-Agent": expect.stringContaining(`pi/${VERSION}`), });