diff --git a/package.json b/package.json index f66a3d4..eebb5ad 100644 --- a/package.json +++ b/package.json @@ -340,6 +340,12 @@ "Open Pi chats (new, resumed, and forked) in a new editor group split to the right of the active group." ], "description": "Where Pi chats open in the editor: as a tab in the active group, or split to the right." + }, + "pi-on-code.modelListRefreshMinutes": { + "type": "number", + "default": 30, + "minimum": 0, + "description": "Minutes after which opening the model list refreshes it from the provider registry. 0 disables auto refresh." } } }, diff --git a/src/model-refresh.ts b/src/model-refresh.ts new file mode 100644 index 0000000..7f10cb7 --- /dev/null +++ b/src/model-refresh.ts @@ -0,0 +1,50 @@ +/** + * Auto-refresh decision for the model list. Kept pure so the staleness rule is + * unit-testable without the SDK registry. + */ + +export const DEFAULT_MODEL_LIST_REFRESH_MINUTES = 30; + +/** Convert a configured minute value into a max-age in ms (0 = disabled). */ +export function modelListRefreshMaxAgeMs(minutes: unknown): number { + const value = typeof minutes === "number" && Number.isFinite(minutes) + ? minutes + : DEFAULT_MODEL_LIST_REFRESH_MINUTES; + if (value <= 0) { return 0; } + return value * 60_000; +} + +/** + * True when the cached model list is stale enough to refresh before it is + * shown. A max age of 0 disables auto refresh; a never-refreshed list is + * always considered stale. + */ +/** + * Process-wide timestamp of the last successful refresh. The registry is + * shared runtime data, so the staleness window must span session windows + * rather than reset for every new PiService instance. + */ +let sharedModelListRefreshedAt: number | null = null; + +export function getSharedModelListRefreshedAt(): number | null { + return sharedModelListRefreshedAt; +} + +export function markModelListRefreshed(now: number): void { + sharedModelListRefreshedAt = now; +} + +/** Test hook: forget the shared refresh timestamp. */ +export function resetModelListRefreshClock(): void { + sharedModelListRefreshedAt = null; +} + +export function shouldRefreshModelList( + lastRefreshedAt: number | null, + now: number, + maxAgeMs: number, +): boolean { + if (maxAgeMs <= 0) { return false; } + if (lastRefreshedAt === null) { return true; } + return now - lastRefreshedAt >= maxAgeMs; +} diff --git a/src/pi-service.ts b/src/pi-service.ts index 0ed4b9e..ecfba46 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -14,6 +14,13 @@ import { buildEffortOptions, buildThinkingOptions, } from "./webview/render/option-picker-helpers.js"; +import { + DEFAULT_MODEL_LIST_REFRESH_MINUTES, + getSharedModelListRefreshedAt, + markModelListRefreshed, + modelListRefreshMaxAgeMs, + shouldRefreshModelList, +} from "./model-refresh.js"; import { buildScopedModels, completeWithModelRuntime, getRuntimeModel, selectInitialModel } from "./pi-model-runtime.js"; import { buildConversationTurnPreviews } from "./conversation-turns.js"; import { type ImageContent, type PiServiceEvent, validateExtensionToWebview } from "./types.js"; @@ -403,6 +410,7 @@ export class PiService { private SDK: PiSdk | null = null; private modelRuntime: any = null; private modelRegistry: any = null; + private modelListRefreshPromise: Promise | null = null; private settingsManager: any = null; private sessionManager: any = null; private resourceLoader: any = null; @@ -2639,6 +2647,7 @@ export class PiService { /** Open a QuickPick to choose a model, set it on this session, and optionally save as default. */ async pickModel(): Promise { + await this.ensureModelListFresh(); interface ModelItem { label: string; provider: string; modelId: string; cost?: { input: number; output: number }; contextWindow?: number } let models: ModelItem[] = []; @@ -2756,6 +2765,46 @@ export class PiService { this.emit({ type: "scoped-models-update", data: { models: this.getScopedModels() } }); } + /** + * Refresh the model registry when the cached list is older than the + * configured staleness window (pi-on-code.modelListRefreshMinutes, default + * 30; 0 disables). Concurrent callers share one in-flight refresh. + */ + private async ensureModelListFresh(): Promise { + const minutes = vscode.workspace + .getConfiguration("pi-on-code") + .get("modelListRefreshMinutes", DEFAULT_MODEL_LIST_REFRESH_MINUTES); + const maxAgeMs = modelListRefreshMaxAgeMs(minutes); + if (!this.modelRegistry) { return; } + if (!shouldRefreshModelList(getSharedModelListRefreshedAt(), Date.now(), maxAgeMs)) { return; } + if (this.modelListRefreshPromise) { return this.modelListRefreshPromise; } + this.modelListRefreshPromise = (async () => { + try { + await this.modelRegistry.refresh(); + markModelListRefreshed(Date.now()); + // Cycling reads cycleModels, so rebuild it from the fresh registry. + await this.rebuildCycleModels(); + } catch (error: unknown) { + piWarn(`model list refresh failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.modelListRefreshPromise = null; + } + })(); + return this.modelListRefreshPromise; + } + + /** Rebuild the model-cycling order from the current registry contents. */ + private async rebuildCycleModels(): Promise { + try { + const available = await this.getAvailableModels(); + if (available.length > 0) { + this.cycleModels = available.map((m) => ({ provider: m.provider, id: m.id })); + } + } catch (error: unknown) { + piWarn(`cycle model list refresh failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + /** * JSON payload for the reusable webview status picker. Labels are plain text * (no VS Code codicon markup); selection and default markers are conveyed @@ -2780,6 +2829,7 @@ export class PiService { interface ModelChoice { label: string; provider: string; modelId: string; cost?: { input: number; output: number }; contextWindow?: number } let models: ModelChoice[] = []; + await this.ensureModelListFresh(); try { const available = await this.getAvailableModels(); if (available.length > 0) { diff --git a/src/test/model-refresh.test.ts b/src/test/model-refresh.test.ts new file mode 100644 index 0000000..4042577 --- /dev/null +++ b/src/test/model-refresh.test.ts @@ -0,0 +1,94 @@ +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { + DEFAULT_MODEL_LIST_REFRESH_MINUTES, + getSharedModelListRefreshedAt, + markModelListRefreshed, + modelListRefreshMaxAgeMs, + resetModelListRefreshClock, + shouldRefreshModelList, +} from "../model-refresh.js"; + +suite("Model list auto refresh", () => { + test("uses 30 minutes as the default staleness window", () => { + assert.strictEqual(DEFAULT_MODEL_LIST_REFRESH_MINUTES, 30); + assert.strictEqual(modelListRefreshMaxAgeMs(undefined), 30 * 60_000); + assert.strictEqual(modelListRefreshMaxAgeMs(15), 15 * 60_000); + }); + + test("disables auto refresh for zero, negative, or invalid values", () => { + assert.strictEqual(modelListRefreshMaxAgeMs(0), 0); + assert.strictEqual(modelListRefreshMaxAgeMs(-5), 0); + assert.strictEqual(modelListRefreshMaxAgeMs("30"), 30 * 60_000); // non-number falls back to default + assert.strictEqual(shouldRefreshModelList(0, 10_000_000, 0), false); + }); + + test("refreshes a never-populated list and stale lists only", () => { + const maxAge = 30 * 60_000; + assert.strictEqual(shouldRefreshModelList(null, 0, maxAge), true); + assert.strictEqual(shouldRefreshModelList(1_000, 1_000 + maxAge - 1, maxAge), false); + assert.strictEqual(shouldRefreshModelList(1_000, 1_000 + maxAge, maxAge), true); + assert.strictEqual(shouldRefreshModelList(1_000, 1_000 + maxAge * 3, maxAge), true); + }); + + test("shares the refresh clock across sessions", () => { + resetModelListRefreshClock(); + const maxAge = 30 * 60_000; + assert.strictEqual(getSharedModelListRefreshedAt(), null); + assert.strictEqual(shouldRefreshModelList(getSharedModelListRefreshedAt(), 5_000, maxAge), true); + + markModelListRefreshed(1_000); + assert.strictEqual(getSharedModelListRefreshedAt(), 1_000); + // A sibling session created afterwards sees the fresh shared timestamp. + assert.strictEqual(shouldRefreshModelList(getSharedModelListRefreshedAt(), 1_000 + maxAge - 1, maxAge), false); + assert.strictEqual(shouldRefreshModelList(getSharedModelListRefreshedAt(), 1_000 + maxAge, maxAge), true); + + resetModelListRefreshClock(); + assert.strictEqual(getSharedModelListRefreshedAt(), null); + }); + + test("covers every model-list surface and rebuilds the cycle order", () => { + const service = readFileSync( + new URL("../../src/pi-service.ts", import.meta.url), + "utf8", + ); + const pickModelIndex = service.indexOf("async pickModel(): Promise {"); + const pickModelGuard = service.indexOf("await this.ensureModelListFresh();", pickModelIndex); + assert.ok(pickModelIndex >= 0, "pickModel must exist"); + assert.ok(pickModelGuard > pickModelIndex, "pickModel must refresh a stale list first"); + + const builderIndex = service.indexOf("async buildStatusPickerOptions("); + const builderGuard = service.indexOf("await this.ensureModelListFresh();", builderIndex); + const readIndex = service.indexOf("const available = await this.getAvailableModels();", builderIndex); + assert.ok(builderGuard > builderIndex && builderGuard < readIndex, "the picker builder refreshes before reading models"); + + assert.match(service, /await this\.rebuildCycleModels\(\);/); + assert.match(service, /this\.cycleModels = available\.map\(\(m\) => \(\{ provider: m\.provider, id: m\.id \}\)\);/); + assert.match(service, /shouldRefreshModelList\(getSharedModelListRefreshedAt\(\), Date\.now\(\), maxAgeMs\)/); + }); + + test("wires the setting through the model picker path", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { + contributes: { + configuration: { + properties: Record; + }; + }; + }; + const property = manifest.contributes.configuration.properties["pi-on-code.modelListRefreshMinutes"]; + assert.ok(property, "pi-on-code.modelListRefreshMinutes property is missing"); + assert.strictEqual(property.type, "number"); + assert.strictEqual(property.default, 30); + assert.strictEqual(property.minimum, 0); + + const service = readFileSync( + new URL("../../src/pi-service.ts", import.meta.url), + "utf8", + ); + assert.match(service, /get\("modelListRefreshMinutes", DEFAULT_MODEL_LIST_REFRESH_MINUTES\)/); + assert.match(service, /await this\.modelRegistry\.refresh\(\);/); + assert.match(service, /await this\.ensureModelListFresh\(\);/); + }); +});