Merge remote-tracking branch 'origin/main' into fix/issue-6647-retry-summary-requests-2

This commit is contained in:
David Brailovsky
2026-07-21 17:15:54 +02:00
102 changed files with 3220 additions and 504 deletions
+27
View File
@@ -2,14 +2,41 @@
## [Unreleased]
## [0.81.0] - 2026-07-21
### New Features
- **Local llama.cpp model management** — Connect to a llama.cpp router, search and download Hugging Face models, and explicitly load or unload models with live progress. See [llama.cpp](docs/llama-cpp.md).
- **Full provider extensions** — Extensions can register complete pi-ai providers with authentication, model refresh, filtering, and custom streaming. See [Register New Provider](docs/custom-provider.md#register-new-provider).
- **Qwen Token Plan providers** — Use the built-in international and China subscription providers with regional endpoints and API-key authentication. See [API Keys](docs/providers.md#api-keys).
- **Expanded usage accounting** — Tool, compaction, and branch-summary usage is persisted and included in session totals. See [Compaction & Branch Summarization](docs/compaction.md).
### Added
- Added Qwen Token Plan and Qwen Token Plan China to built-in provider setup, default model resolution, and provider documentation ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)).
- Added the `get_available_thinking_levels` RPC command and `RpcClient.getAvailableThinkingLevels()` method ([#6865](https://github.com/earendil-works/pi/pull/6865) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Exported message and tool execution lifecycle event types from the package root ([#6772](https://github.com/earendil-works/pi/pull/6772) by [@davidbrai](https://github.com/davidbrai)).
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
- Added usage accounting for tools, compaction, and branch summaries in persisted sessions, footer totals, and session statistics ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Updated the packaged `brace-expansion` dependency to 5.0.7 ([#6896](https://github.com/earendil-works/pi/pull/6896) by [@davidbrai](https://github.com/davidbrai)).
- Fixed persisted remote model catalogs from overriding newer bundled catalogs after an upgrade.
- Fixed inherited stored API-key credentials to apply their provider-scoped `env` values, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed inherited OpenAI-compatible cross-provider replay to keep tool call IDs unique when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Fixed inherited Kimi K3 thinking levels to expose low, high, and max, and normalized the `k2p7` alias to `kimi-for-coding`.
- Fixed inherited OpenCode Go models routed through the OpenAI Responses API.
- Fixed inherited `pi-ai` package metadata to avoid repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)).
- Fixed inherited terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)).
- Fixed inherited ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)).
- Fixed inherited editor paste registry corruption after deleting and undoing paste markers, preventing literal or mismatched paste markers in submitted prompts ([#6844](https://github.com/earendil-works/pi/issues/6844)).
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Fixed inherited GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)).
- Fixed messages queued during compaction to preserve steering and follow-up delivery behavior ([#6730](https://github.com/earendil-works/pi/pull/6730) by [@dannote](https://github.com/dannote)).
- Fixed read tool errors being syntax-highlighted as if they were file contents ([#6731](https://github.com/earendil-works/pi/pull/6731) by [@dannote](https://github.com/dannote)).
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
+14 -3
View File
@@ -232,10 +232,18 @@ Created when context is compacted. Stores a summary of earlier messages.
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
```
Newer harness-generated compactions embed the retained post-compaction context directly on the entry, instead of `firstKeptEntryId`:
```json
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","tokensBefore":50000,"retainedTail":[{"role":"user","content":"latest request"},{"role":"assistant","content":[{"type":"text","text":"latest reply"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}]}
```
Optional fields:
- `usage`: LLM usage from generating the summary; included in session token and cost totals
- `retainedTail`: Materialized `AgentMessage[]` kept after compaction. This is optional only for backward compatibility with older sessions. Newer harness-generated compactions include it so we can rebuild context from this checkpoint without walking older entries before the compaction entry.
- `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions)
- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)
- `firstKeptEntryId`: for compatibility with old entry format.
### BranchSummaryEntry
@@ -314,8 +322,9 @@ Entries form a tree:
1. Collects all entries on the path
2. If a `CompactionEntry` is on the path:
- Includes the compaction entry first
- Then entries from `firstKeptEntryId` to compaction
- Then entries after compaction
- If `retainedTail` is present, it acts as a self-contained checkpoint and entries after the compaction are included
- Otherwise entries from `firstKeptEntryId` to the compaction are included
- Then entries after compaction are included
3. Preserves non-message entries in the selected range so interactive mode can render them
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
@@ -323,11 +332,13 @@ Entries form a tree:
1. Extracts current model and thinking level settings from the full path
2. Converts selected entries to messages:
- `message` -> stored `AgentMessage`
- `compaction` -> `compactionSummary`
- `compaction` -> `compactionSummary` plus `retainedTail` when present
- `branch_summary` -> `branchSummary`
- `custom_message` -> `CustomMessage`
- `custom` -> no context message
This makes newer compactions act like self-contained checkpoints. `retainedTail` is optional only so older sessions that only store `firstKeptEntryId` continue to load correctly.
## Parsing Example
```typescript
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.10.10",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.10.10",
"version": "1.11.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.10.10",
"version": "1.11.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.80.10",
"version": "0.81.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+15 -30
View File
@@ -1,14 +1,14 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.10"
"@earendil-works/pi-coding-agent": "0.81.0"
},
"engines": {
"node": ">=22.19.0"
@@ -450,11 +450,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.81.0",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -464,8 +464,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -488,13 +488,13 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -522,8 +522,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -638,9 +638,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
@@ -657,9 +654,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
@@ -676,9 +670,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
@@ -695,9 +686,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
@@ -714,9 +702,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
@@ -1,10 +1,10 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.80.10",
"version": "0.81.0",
"private": true,
"description": "Lockfile root used by the Pi installer and updater.",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.10"
"@earendil-works/pi-coding-agent": "0.81.0"
},
"overrides": {
"rimraf": "6.1.2",
+12 -27
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -474,11 +474,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.81.0",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -488,8 +488,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -512,8 +512,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -628,9 +628,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
@@ -647,9 +644,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
@@ -666,9 +660,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
@@ -685,9 +676,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
@@ -704,9 +692,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.81.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -143,7 +143,15 @@ export class ModelRuntime implements Models {
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
provider.id === "radius"
? provider
: withRemoteCatalog(
provider,
options.catalogBaseUrl,
builtinProviderCatalog.getBuiltinModelDataUrl(
provider.id as builtinProviderCatalog.BuiltinProvider,
),
),
);
const runtime = new ModelRuntime(
credentials,
@@ -1,4 +1,5 @@
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
import { stat } from "node:fs/promises";
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
@@ -29,8 +30,26 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
.map((model) => ({ ...model, provider: providerId }));
}
function remoteModels(
entry: ModelsStoreEntry | undefined,
localLastModified: number | undefined,
): readonly Model<Api>[] {
if (!entry) return [];
if (
localLastModified !== undefined &&
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
) {
return [];
}
return entry.models;
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
export function withRemoteCatalog(
provider: Provider,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localCatalogUrl?: URL,
): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
@@ -40,12 +59,21 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const localLastModified = localCatalogUrl
? await stat(localCatalogUrl).then(
(value) => value.mtimeMs,
() => undefined,
)
: undefined;
const stored = await context.store.read();
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
dynamicModels = remoteModels(stored, localLastModified).filter(
(model) => model.provider === provider.id,
);
if (!context.allowNetwork || context.signal?.aborted) return;
if (
!context.force &&
stored?.checkedAt !== undefined &&
stored.lastModified !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
) {
return;
@@ -62,17 +90,23 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
if (context.signal?.aborted) return;
const checkedAt = Date.now();
if (response.status === 404 || response.status === 501) {
await context.store.write({ models: dynamicModels, checkedAt });
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
return;
}
if (!response.ok) {
await context.store.write({ models: dynamicModels, checkedAt });
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const refreshed = parseCatalog(provider.id, await response.json());
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write({ models: refreshed, checkedAt });
const entry = {
models: refreshed,
checkedAt,
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
};
dynamicModels = remoteModels(entry, localLastModified);
await context.store.write(entry);
} finally {
inflightRefresh = undefined;
}
@@ -107,9 +107,11 @@ function parseLoadProgress(data: unknown): LlamaProgress | undefined {
function parseDownloadProgress(data: unknown): LlamaProgress | undefined {
if (typeof data !== "object" || data === null) return undefined;
const nested = (data as { progress?: unknown }).progress;
const files = typeof nested === "object" && nested !== null ? nested : data;
let done = 0;
let total = 0;
for (const value of Object.values(data as Record<string, unknown>)) {
for (const value of Object.values(files as Record<string, unknown>)) {
if (typeof value !== "object" || value === null) continue;
const entry = value as { done?: unknown; total?: unknown };
if (typeof entry.done !== "number" || typeof entry.total !== "number") continue;
@@ -88,7 +88,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
model: target.id,
initialMessage: "Starting…",
cancelTitle: "Stop loading?",
cancelMessage: `Stop loading ${target.id}?`,
cancelMessage: target.id,
run: (signal, update) => client.loadAndWait(target.id, update, signal),
cancel: () => client.unload(target.id),
});
@@ -119,7 +119,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
client: LlamaClient,
model: LlamaModelInfo,
): Promise<void> => {
if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`))) return;
if (!(await ui.confirm("Unload model?", model.id))) return;
await client.unloadAndWait(model.id);
await syncCatalog(ctx, client);
ctx.ui.notify(`Unloaded ${model.id}`);
@@ -162,7 +162,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
model,
initialMessage: "Starting…",
cancelTitle: "Stop download?",
cancelMessage: `Stop downloading ${model}?`,
cancelMessage: model,
run: (signal, update) => client.downloadAndWait(model, update, signal),
cancel: () => client.unload(model),
});
@@ -220,7 +220,7 @@ describe("llama.cpp extension", () => {
send({
model: "owner/repo:Q4_K_M",
event: "download_progress",
data: { "https://example/model.gguf": { done: 512, total: 1024 } },
data: { progress: { "https://example/model.gguf": { done: 512, total: 1024 } } },
});
status = "unloaded";
send({ model: "owner/repo:Q4_K_M", event: "download_finished", data: {} });
@@ -652,7 +652,7 @@ describe("ModelRegistry", () => {
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
});
}, 60_000);
test("removing custom models from models.json keeps built-in provider models", async () => {
writeModelsJson({
@@ -1,4 +1,11 @@
import { createProvider, InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
import { statSync } from "node:fs";
import {
createProvider,
InMemoryModelsStore,
type Model,
type ModelsStoreEntry,
type ProviderModelsStore,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VERSION } from "../src/config.ts";
import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts";
@@ -18,6 +25,34 @@ function model(id: string): Model<"openai-completions"> {
};
}
function testProvider(localCatalogUrl?: URL) {
return withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
"https://pi.dev",
localCatalogUrl,
);
}
function scopedStore(store: InMemoryModelsStore): ProviderModelsStore {
return {
read: () => store.read("test-provider"),
write: (entry: ModelsStoreEntry) => store.write("test-provider", entry),
delete: () => store.delete("test-provider"),
};
}
afterEach(() => vi.restoreAllMocks());
describe("remote catalog provider", () => {
@@ -29,50 +64,12 @@ describe("remote catalog provider", () => {
headers: { "content-type": "application/json" },
}),
);
const provider = withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
);
const provider = testProvider();
const store = new InMemoryModelsStore();
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,
});
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,
});
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,
});
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await provider.refreshModels?.(refresh);
await provider.refreshModels?.({ ...refresh, 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"]);
@@ -82,33 +79,40 @@ describe("remote catalog provider", () => {
});
});
it("prefers the newer of the generated and remote catalogs", async () => {
const localCatalogUrl = new URL(import.meta.url);
const localMtime = statSync(localCatalogUrl).mtimeMs;
const newerHeader = new Date(localMtime + 60_000).toUTCString();
const responses = [
new Response(JSON.stringify({ old: model("old") }), {
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
}),
new Response(JSON.stringify({ newer: model("newer") }), {
headers: { "last-modified": newerHeader },
}),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider(localCatalogUrl);
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]);
await provider.refreshModels?.({ ...refresh, force: true });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "newer"]);
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
});
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
const provider = withRemoteCatalog(
createProvider({
id: "test-provider",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [model("static")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
}),
);
const provider = testProvider();
const store = new InMemoryModelsStore();
await expect(
provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
store: scopedStore(store),
allowNetwork: true,
}),
).resolves.toBeUndefined();