Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
50 changes: 50 additions & 0 deletions src/model-refresh.ts
Original file line number Diff line number Diff line change
@@ -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;
}
50 changes: 50 additions & 0 deletions src/pi-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -403,6 +410,7 @@ export class PiService {
private SDK: PiSdk | null = null;
private modelRuntime: any = null;
private modelRegistry: any = null;
private modelListRefreshPromise: Promise<void> | null = null;
private settingsManager: any = null;
private sessionManager: any = null;
private resourceLoader: any = null;
Expand Down Expand Up @@ -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<boolean> {
await this.ensureModelListFresh();
interface ModelItem { label: string; provider: string; modelId: string; cost?: { input: number; output: number }; contextWindow?: number }
let models: ModelItem[] = [];

Expand Down Expand Up @@ -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<void> {
const minutes = vscode.workspace
.getConfiguration("pi-on-code")
.get<number>("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<void> {
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
Expand All @@ -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) {
Expand Down
94 changes: 94 additions & 0 deletions src/test/model-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {");
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<string, { type?: string; default?: unknown; minimum?: number }>;
};
};
};
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<number>\("modelListRefreshMinutes", DEFAULT_MODEL_LIST_REFRESH_MINUTES\)/);
assert.match(service, /await this\.modelRegistry\.refresh\(\);/);
assert.match(service, /await this\.ensureModelListFresh\(\);/);
});
});
Loading