feat(coding-agent): add model catalog refresh flag
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
- Added neutral auth-flow information/link events and provider-owned Amazon Bedrock and Google Vertex AI credential selection flows.
|
- 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 `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 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1047,7 +1047,7 @@ if (result.aborted) console.log('refresh cancelled');
|
|||||||
for (const [provider, error] of result.errors) console.error(provider, error);
|
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,15 @@ export interface RefreshModelsContext {
|
|||||||
store: ProviderModelsStore;
|
store: ProviderModelsStore;
|
||||||
/** False during offline/cache-only initialization. */
|
/** False during offline/cache-only initialization. */
|
||||||
allowNetwork: boolean;
|
allowNetwork: boolean;
|
||||||
|
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
|
||||||
|
force?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelsRefreshOptions {
|
export interface ModelsRefreshOptions {
|
||||||
allowNetwork?: boolean;
|
allowNetwork?: boolean;
|
||||||
|
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
|
||||||
|
force?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +294,13 @@ class ModelsImpl implements MutableModels {
|
|||||||
stored = await this.readCredential(provider.id);
|
stored = await this.readCredential(provider.id);
|
||||||
const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal);
|
const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal);
|
||||||
if (!credential) return;
|
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) {
|
} catch (error) {
|
||||||
if (!options.signal?.aborted) {
|
if (!options.signal?.aborted) {
|
||||||
errors.set(
|
errors.set(
|
||||||
|
|||||||
@@ -283,8 +283,9 @@ describe("Models runtime", () => {
|
|||||||
expect(offline.getModel("dynamic", "fetched")).toBeDefined();
|
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 effectiveCredential: unknown;
|
||||||
|
let forceRefresh: boolean | undefined;
|
||||||
let unconfiguredRefreshes = 0;
|
let unconfiguredRefreshes = 0;
|
||||||
const models = createModels();
|
const models = createModels();
|
||||||
models.setProvider(
|
models.setProvider(
|
||||||
@@ -293,6 +294,7 @@ describe("Models runtime", () => {
|
|||||||
auth: { apiKey: envKeyAuth("ambient-key") },
|
auth: { apiKey: envKeyAuth("ambient-key") },
|
||||||
refreshModels: async (context) => {
|
refreshModels: async (context) => {
|
||||||
effectiveCredential = context.credential;
|
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(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined });
|
||||||
|
expect(forceRefresh).toBe(true);
|
||||||
expect(unconfiguredRefreshes).toBe(0);
|
expect(unconfiguredRefreshes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
- Added provider-owned `/login` discovery directly from registered pi-ai providers, including ambient auth status and informational links.
|
- 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 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 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write`
|
|||||||
|
|
||||||
## Providers & Models
|
## 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:**
|
**Subscriptions:**
|
||||||
- Anthropic Claude Pro/Max
|
- Anthropic Claude Pro/Max
|
||||||
@@ -421,6 +421,7 @@ pi list
|
|||||||
pi update # update pi only
|
pi update # update pi only
|
||||||
pi update --all # update pi and packages
|
pi update --all # update pi and packages
|
||||||
pi update --extensions # update packages only
|
pi update --extensions # update packages only
|
||||||
|
pi update --models # refresh model catalogs only
|
||||||
pi update --self # update pi only
|
pi update --self # update pi only
|
||||||
pi update --self --force # reinstall pi even if current
|
pi update --self --force # reinstall pi even if current
|
||||||
pi update npm:@foo/pi-tools # update one package
|
pi update npm:@foo/pi-tools # update one package
|
||||||
@@ -519,6 +520,7 @@ pi uninstall <source> [-l] # Alias for remove
|
|||||||
pi update [source|self|pi] # Update pi only, or one package source
|
pi update [source|self|pi] # Update pi only, or one package source
|
||||||
pi update --all # Update pi and packages
|
pi update --all # Update pi and packages
|
||||||
pi update --extensions # Update packages only
|
pi update --extensions # Update packages only
|
||||||
|
pi update --models # Refresh model catalogs only
|
||||||
pi update --self # Update pi only
|
pi update --self # Update pi only
|
||||||
pi update --self --force # Reinstall pi even if current
|
pi update --self --force # Reinstall pi even if current
|
||||||
pi update --extension <src> # Update one package
|
pi update --extension <src> # Update one package
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pi list # show installed packages from settings
|
|||||||
pi update # update pi only
|
pi update # update pi only
|
||||||
pi update --all # update pi, update packages, and reconcile pinned git refs
|
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 --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 # update pi only
|
||||||
pi update --self --force # reinstall pi even if current
|
pi update --self --force # reinstall pi even if current
|
||||||
pi update npm:@foo/bar # update one package
|
pi update npm:@foo/bar # update one package
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ pi uninstall <source> [-l] # Alias for remove
|
|||||||
pi update [source|self|pi] # Update pi only, or one package source
|
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 --all # Update pi and packages; reconcile pinned git refs
|
||||||
pi update --extensions # Update packages only; 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 --self # Update pi only
|
||||||
pi update --extension <src> # Update one package
|
pi update --extension <src> # Update one package
|
||||||
pi list # List installed packages
|
pi list # List installed packages
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ ${chalk.bold("Commands:")}
|
|||||||
${APP_NAME} install <source> [-l] Install extension source and add to settings
|
${APP_NAME} install <source> [-l] Install extension source and add to settings
|
||||||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||||
${APP_NAME} uninstall <source> [-l] Alias for remove
|
${APP_NAME} uninstall <source> [-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} list List installed extensions from settings
|
||||||
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
||||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
|
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
|
|||||||
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
|
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
|
||||||
if (!context.allowNetwork || context.signal?.aborted) return;
|
if (!context.allowNetwork || context.signal?.aborted) return;
|
||||||
if (
|
if (
|
||||||
|
!context.force &&
|
||||||
stored?.checkedAt !== undefined &&
|
stored?.checkedAt !== undefined &&
|
||||||
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
|
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { selectConfig } from "./cli/config-selector.ts";
|
import { selectConfig } from "./cli/config-selector.ts";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
VERSION,
|
VERSION,
|
||||||
} from "./config.ts";
|
} from "./config.ts";
|
||||||
import type { InlineExtension } from "./core/extensions/types.ts";
|
import type { InlineExtension } from "./core/extensions/types.ts";
|
||||||
|
import { ModelRuntime } from "./core/model-runtime.ts";
|
||||||
import { DefaultPackageManager } from "./core/package-manager.ts";
|
import { DefaultPackageManager } from "./core/package-manager.ts";
|
||||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||||
@@ -30,7 +32,7 @@ import {
|
|||||||
|
|
||||||
export type PackageCommand = "install" | "remove" | "update" | "list";
|
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 = {
|
const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = {
|
||||||
heading: (text) => chalk.bold(chalk.yellow(text)),
|
heading: (text) => chalk.bold(chalk.yellow(text)),
|
||||||
@@ -81,7 +83,7 @@ function getPackageCommandUsage(command: PackageCommand): string {
|
|||||||
case "remove":
|
case "remove":
|
||||||
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
|
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
|
||||||
case "update":
|
case "update":
|
||||||
return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension <source>] [--approve|--no-approve] [--force]`;
|
return `${APP_NAME} update [source|self|pi] [--self|--extensions|--models|--all] [--extension <source>] [--approve|--no-approve] [--force]`;
|
||||||
case "list":
|
case "list":
|
||||||
return `${APP_NAME} list [--approve|--no-approve]`;
|
return `${APP_NAME} list [--approve|--no-approve]`;
|
||||||
}
|
}
|
||||||
@@ -149,11 +151,12 @@ Examples:
|
|||||||
console.log(`${chalk.bold("Usage:")}
|
console.log(`${chalk.bold("Usage:")}
|
||||||
${getPackageCommandUsage("update")}
|
${getPackageCommandUsage("update")}
|
||||||
|
|
||||||
Update pi and installed packages.
|
Update pi, installed packages, or model catalogs.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--self Update pi only (default when no target is given)
|
--self Update pi only (default when no target is given)
|
||||||
--extensions Update installed packages only
|
--extensions Update installed packages only
|
||||||
|
--models Refresh model catalogs only
|
||||||
--all Update pi and installed packages
|
--all Update pi and installed packages
|
||||||
--extension <source> Update one package only
|
--extension <source> Update one package only
|
||||||
-a, --approve Trust project-local files for this command
|
-a, --approve Trust project-local files for this command
|
||||||
@@ -163,6 +166,7 @@ Options:
|
|||||||
Short forms:
|
Short forms:
|
||||||
${APP_NAME} update Update pi only
|
${APP_NAME} update Update pi only
|
||||||
${APP_NAME} update --all Update pi and all extensions
|
${APP_NAME} update --all Update pi and all extensions
|
||||||
|
${APP_NAME} update --models Refresh model catalogs only
|
||||||
${APP_NAME} update <source> Update one package
|
${APP_NAME} update <source> Update one package
|
||||||
${APP_NAME} update pi Update pi only (self works as alias to pi)
|
${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 source: string | undefined;
|
||||||
let selfFlag = false;
|
let selfFlag = false;
|
||||||
let extensionsFlag = false;
|
let extensionsFlag = false;
|
||||||
|
let modelsFlag = false;
|
||||||
let allFlag = false;
|
let allFlag = false;
|
||||||
let extensionFlagSource: string | undefined;
|
let extensionFlagSource: string | undefined;
|
||||||
|
|
||||||
@@ -242,6 +247,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg === "--models") {
|
||||||
|
if (command === "update") {
|
||||||
|
modelsFlag = true;
|
||||||
|
} else {
|
||||||
|
invalidOption = invalidOption ?? arg;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg === "--all") {
|
if (arg === "--all") {
|
||||||
if (command === "update") {
|
if (command === "update") {
|
||||||
allFlag = true;
|
allFlag = true;
|
||||||
@@ -304,15 +318,24 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||||||
let updateTarget: UpdateTarget | undefined;
|
let updateTarget: UpdateTarget | undefined;
|
||||||
let showExtensionsSkippedNote = false;
|
let showExtensionsSkippedNote = false;
|
||||||
if (command === "update") {
|
if (command === "update") {
|
||||||
if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) {
|
if (allFlag && (selfFlag || extensionsFlag || modelsFlag || extensionFlagSource)) {
|
||||||
conflictingOptions =
|
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) {
|
if (allFlag && source) {
|
||||||
conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional 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) {
|
if (selfFlag || extensionsFlag || allFlag) {
|
||||||
conflictingOptions =
|
conflictingOptions =
|
||||||
conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all";
|
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";
|
return target.type === "all" || target.type === "extensions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshModelCatalogs(agentDir: string): Promise<void> {
|
||||||
|
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(
|
function printSelfUpdateUnavailable(
|
||||||
npmCommand?: string[],
|
npmCommand?: string[],
|
||||||
updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME,
|
updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME,
|
||||||
@@ -673,6 +723,17 @@ export async function handlePackageCommand(
|
|||||||
return true;
|
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 cwd = process.cwd();
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
|
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { delimiter, join } from "node:path";
|
import { delimiter, join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
|
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 type { ResolvedPaths } from "../src/core/package-manager.ts";
|
||||||
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
|
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
|
||||||
import { ProjectTrustStore } from "../src/core/trust-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<string, Error>() }));
|
||||||
|
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 () => {
|
it("cycles project package overrides in config local mode", async () => {
|
||||||
const storage = new InMemorySettingsStorage();
|
const storage = new InMemorySettingsStorage();
|
||||||
storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] }));
|
storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] }));
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ function model(id: string): Model<"openai-completions"> {
|
|||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
describe("remote catalog provider", () => {
|
describe("remote catalog provider", () => {
|
||||||
it("parses keyed catalogs, sends version headers, and observes the refresh TTL", async () => {
|
it("parses keyed catalogs, sends version headers, observes the refresh TTL, and supports forced refreshes", async () => {
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||||
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
|
async () =>
|
||||||
status: 200,
|
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
|
||||||
headers: { "content-type": "application/json" },
|
status: 200,
|
||||||
}),
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const provider = withRemoteCatalog(
|
const provider = withRemoteCatalog(
|
||||||
createProvider({
|
createProvider({
|
||||||
@@ -62,10 +63,20 @@ describe("remote catalog provider", () => {
|
|||||||
},
|
},
|
||||||
allowNetwork: true,
|
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(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
|
||||||
expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["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({
|
expect(fetchSpy.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||||
"User-Agent": expect.stringContaining(`pi/${VERSION}`),
|
"User-Agent": expect.stringContaining(`pi/${VERSION}`),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user