@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
192 lines • 9.42 kB
JavaScript
import { getModelType, isModelType, } from "@earendil-works/pi-ai";
import { VERSION } from "../config.js";
import { fetchWithRetry } from "../utils/management-http.js";
import { getPiUserAgent } from "../utils/pi-user-agent.js";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000;
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
/**
* Model types this client can consume. Sent as `?types=` so the catalog server
* returns the full-type shard instead of the chat-only one served to clients
* that predate model types. A server that ignores the parameter still returns
* the chat-only shard, which this client handles unchanged.
*/
export const REMOTE_CATALOG_MODEL_TYPES = ["chat", "image", "classifier"];
function isSupportedModelType(model) {
return (model.type === undefined ||
(typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type)));
}
function mergeModels(baseline, dynamic) {
const merged = new Map();
for (const model of [...baseline, ...dynamic])
merged.set(`${getModelType(model)}\0${model.id}`, model);
return [...merged.values()];
}
function parseCatalog(providerId, value) {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
? value.models
: typeof value === "object" && value !== null
? Object.values(value)
: undefined;
if (!entries)
throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry) => typeof entry === "object" && entry !== null && "id" in entry)
.filter(isSupportedModelType)
.map((model) => ({ ...model, provider: providerId }));
}
/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */
async function readCatalog(providerId, response) {
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
return {
models: parseCatalog(providerId, await response.json()),
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
etag: response.headers.get("etag") ?? undefined,
};
}
/** Whether a pi.dev catalog is newer than the model data shipped with pi. */
function isNewer(lastModified, localGeneratedAt) {
return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt);
}
function remoteModels(entry, localGeneratedAt) {
return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : [];
}
function requestCatalog(providerId, catalogBaseUrl, signal, validator) {
const url = new URL(`/api/models/providers/${encodeURIComponent(providerId)}`, catalogBaseUrl);
url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(","));
return fetchWithRetry(url, {
headers: {
accept: "application/json",
"User-Agent": getPiUserAgent(VERSION),
...(validator ? { "if-none-match": validator } : {}),
},
signal,
}, { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS });
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BASE_URL, localGeneratedAt) {
let dynamicModels = [];
return {
...provider,
getModels: () => mergeModels(provider.getModels(), dynamicModels.filter((model) => isModelType(model, "chat"))),
getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels),
// pi.dev serves the catalog, so a stale provider login must not block or fail its refresh.
refreshModelsAuth: "configured",
refreshModels: async (context) => {
const stored = context.stored;
const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
if (!(await context.publish({
update: () => {
dynamicModels = restored;
},
}))) {
return;
}
if (!context.allowNetwork || context.signal.aborted)
return;
// Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago.
// An entry without lastModified (saved by an older pi, or after a failed first request) is
// always refetched: remoteModels() ignores it, so waiting would leave the overlay empty.
if (!context.force &&
stored?.checkedAt !== undefined &&
stored.lastModified !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS) {
return;
}
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored && stored.models.length > 0 ? stored.etag : undefined;
const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator);
if (context.signal.aborted)
return;
const checkedAt = Date.now();
// Unchanged: dynamicModels already holds the stored overlay, so only the
// freshness window moves.
if (response.status === 304 && stored) {
await context.publish({ persist: { ...stored, checkedAt } });
return;
}
if (response.status === 404 || response.status === 501) {
await context.publish({
persist: {
...(stored ?? { models: [] }),
checkedAt,
lastModified: 0,
etag: undefined,
},
});
return;
}
if (!response.ok) {
// Transient failure: the cached body and its validator stay valid, so keep the
// etag and let the next refresh revalidate instead of downloading the catalog.
await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const catalog = await readCatalog(provider.id, response);
if (context.signal.aborted)
return;
const entry = { ...catalog, checkedAt };
const published = remoteModels(entry, localGeneratedAt);
await context.publish({
persist: entry,
update: () => {
dynamicModels = published;
},
});
},
};
}
/**
* Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer
* pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds
* the personalized list, not the pi.dev catalog, and replaces the catalog, including offline.
* The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an
* empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the
* built-in models.
*/
export function withPersonalizedCatalog(provider, personalize, catalogBaseUrl = DEFAULT_CATALOG_BASE_URL, localGeneratedAt) {
let personalModels = [];
const builtinModels = () => provider.getAllModels?.() ?? provider.getModels();
const getAllModels = () => (personalModels.length > 0 ? personalModels : builtinModels());
return {
...provider,
getModels: () => getAllModels().filter((model) => isModelType(model, "chat")),
getAllModels,
refreshModels: async (context) => {
const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id);
if (!(await context.publish({
update: () => {
personalModels = restored;
},
}))) {
return;
}
if (!context.allowNetwork || context.signal.aborted)
return;
const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal);
if (context.signal.aborted)
return;
let remote = [];
if (response.status !== 404 && response.status !== 501) {
if (!response.ok)
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
const catalog = await readCatalog(provider.id, response);
if (isNewer(catalog.lastModified, localGeneratedAt))
remote = catalog.models;
}
const personalized = await personalize(mergeModels(builtinModels(), remote), context);
if (context.signal.aborted)
return;
await context.publish({
persist: { models: personalized, checkedAt: Date.now() },
update: () => {
personalModels = personalized;
},
});
},
};
}
//# sourceMappingURL=remote-catalog-provider.js.map