// SPDX-License-Identifier: GPL-3.0-or-later /** * pi-live-pricing — correct LLM prices in Pi, with zero configuration. * * Pi's built-in catalogs are regenerated at release time and then frozen. For * direct providers that is fine, because their prices barely move. For * OpenRouter — a marketplace of 400+ models repriced continuously — it is not: * a catalog days old already misprices dozens of models, and every one of them * corrupts cost tracking on every turn. * * This extension keeps prices live: * * - **`openrouter`** is overridden in *merge* mode. The catalog Pi composed * is kept intact — `compat`, `thinkingLevelMap`, hand-checked `maxTokens`, * modalities, plus anything the user declared in `models.json` — and only * `cost` is replaced from the live catalog. Models Pi does not know are * appended. Nothing is ever removed, and a fetch that fails contributes * nothing rather than a stale list, so your model list cannot shrink. * * - **`deepinfra-live`** is registered in *standalone* mode when * `DEEPINFRA_API_KEY` is set: Pi ships no DeepInfra provider, so the live * feed is the whole catalog. * * Both go through the same TTL / ETag / persistence / best-effort machinery in * sync.ts; the per-provider code is only fetching and mapping. * * Commands: * /pricing-refresh force an immediate re-sync, bypassing the TTL * /pricing-status show catalog sizes and cache age */ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { deepInfraAdapter } from "./adapters/deepinfra.ts"; import { openRouterAdapter } from "./adapters/openrouter.ts"; import { captureCuratedBase, forceNextSync, getStatus, syncLiveModels } from "./sync.ts"; import type { CatalogAdapter, LiveModel } from "./types.ts"; /** Adapters whose provider was actually registered this session. */ const ADAPTERS: CatalogAdapter[] = [ openRouterAdapter, ...(process.env.DEEPINFRA_API_KEY ? [deepInfraAdapter] : []), ]; function formatAge(ageMs: number | undefined): string { if (ageMs === undefined) return "never synced"; const minutes = Math.floor(ageMs / 60_000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`; } /** * Provider ids whose refresh failed, from whatever `ModelRegistry.refresh()` * resolved to. * * The result is deliberately untyped: `refresh()` only began resolving to a * `ModelsRefreshResult` in pi 0.84.0. Older builds discard both the options and * the result and resolve to `undefined`, so reading `.errors` off it threw and * took down a command whose actual work had already succeeded (issue #1). Those * builds cannot report which provider failed, so we report none — the catalog * summary printed alongside is the part that carries the real signal. */ export function refreshFailures(result: unknown): string[] { const errors = (result as { errors?: unknown } | undefined)?.errors; return errors instanceof Map ? [...errors.keys()].map(String) : []; } function summarize(adapter: CatalogAdapter): string { const status = getStatus(adapter); const parts = [`${status.models} models`, formatAge(status.ageMs)]; if (status.stats) { parts.push(`${status.stats.repriced} repriced`, `${status.stats.added} new`); } // Worth showing: a merge-mode provider with no captured base contributes // nothing at all, which would otherwise look like a silently dead extension. if (status.baseCaptured === false) parts.push("base not captured"); return `${status.providerId}: ${parts.join(", ")}`; } /** * Snapshots the catalog Pi composed for each merge-mode provider, so live * prices land on Pi's real list — remote catalog and the user's `models.json` * included — rather than on the frozen copy that shipped with the release. * * Must run before the refresh it precedes: `syncLiveModels` stays silent until * this has happened, and refuses a capture taken after we have contributed. */ function captureBases(models: readonly { provider: string }[]): void { for (const adapter of ADAPTERS) { if (!adapter.baseModels) continue; // standalone: the feed is the catalog captureCuratedBase( adapter.providerId, models.filter((model) => model.provider === adapter.providerId) as unknown as LiveModel[], ); } } export default function (pi: ExtensionAPI) { // --- openrouter: override the built-in provider, contributing prices only. // No apiKey or baseUrl is passed: the built-in provider already owns auth // (env key plus OAuth) and endpoint, and overriding either would break users // who signed in through /login. pi.registerProvider(openRouterAdapter.providerId, { api: "openai-completions", // `undefined` is Pi's "no change" signal: its composer only replaces the // provider catalog when refreshModels returns an array. The published type // cannot express that, hence the cast. See rule 1 in sync.ts. async refreshModels(context) { return (await syncLiveModels(openRouterAdapter, context)) as never; }, }); // --- deepinfra: a provider Pi does not ship. // // Registered only when a key is present. Pi skips the networked refresh phase // for any provider whose credential does not resolve, so without one this // would sit in the model picker permanently empty — and its models would be // unusable anyway. if (process.env.DEEPINFRA_API_KEY) { pi.registerProvider(deepInfraAdapter.providerId, { name: deepInfraAdapter.providerName, baseUrl: deepInfraAdapter.baseUrl, apiKey: "$DEEPINFRA_API_KEY", api: "openai-completions", async refreshModels(context) { return (await syncLiveModels(deepInfraAdapter, context)) as never; }, }); } // Pi does not hit the network during normal startup, so without this the // catalog would stay at its shipped prices until the user happened to open // /model. The refresh is fire-and-forget (never awaited, so startup is not // delayed) and honours the TTL, which caps it at one request per provider // per TTL window regardless of how many sessions are opened. pi.on("session_start", (_event, ctx) => { captureBases(ctx.modelRegistry.getAll()); void ctx.modelRegistry .refresh({ providers: ADAPTERS.map((adapter) => adapter.providerId), allowNetwork: true, }) .catch(() => { // Best-effort: a failed background sync leaves the previous catalog in // place and must never surface as a startup error. }); }); pi.registerCommand("pricing-refresh", { description: "Re-sync live provider pricing now (bypasses the cache)", async handler(_args: string, ctx: ExtensionCommandContext) { // Going through the registry is what actually updates the catalog Pi // holds in memory. Warming a local cache would leave the displayed // prices untouched until Pi happened to refresh on its own. // // Captured again in case no session_start reached us (SDK embedders may // never fire one). A no-op once a base is recorded. captureBases(ctx.modelRegistry.getAll()); // Belt and braces: `force` below is the real mechanism, but pi builds // older than 0.84.0 drop the options entirely, so the bypass is also // flagged locally where nothing can strip it. for (const adapter of ADAPTERS) forceNextSync(adapter.providerId); const result = await ctx.modelRegistry.refresh({ providers: ADAPTERS.map((adapter) => adapter.providerId), force: true, // An explicit user command is always allowed to hit the network, even // when background catalog refreshes are turned off. allowNetwork: true, }); const failures = refreshFailures(result); const summary = ADAPTERS.map(summarize).join(" | "); ctx.ui.notify( failures.length ? `${summary} (failed: ${failures.join(", ")})` : summary, failures.length ? "warning" : "info", ); }, }); pi.registerCommand("pricing-status", { description: "Show live pricing catalog sizes and cache age", async handler(_args: string, ctx: ExtensionCommandContext) { ctx.ui.notify(ADAPTERS.map(summarize).join("\n"), "info"); }, }); }