/** * Show per-model prices in CNY inside pi's built-in `/model` selector. * * The built-in selector renders only `model.id` per row, so this extension * replaces `ModelSelectorComponent.prototype.updateList` with an equivalent * implementation that appends the model's cost to every row and expands the * "Model Name:" detail line with the full breakdown. * * Currency * -------- * pi's model catalog stores `model.cost` in USD per 1M tokens. This extension * never hardcodes prices; it converts the live catalog value at render time * using a USD→CNY rate resolved in this order: * * 1. `usdToCny` in `~/.pi/agent/model-selector-prices.json` (fixed override) * 2. a live rate cached in `~/.pi/agent/model-selector-prices-cache.json` * 3. a live fetch (open.er-api.com, then jsdelivr/fawazahmed0) * 4. `DEFAULT_USD_CNY`, only when nothing else is available * * Set `"liveRate": false` to never hit the network, or pin `"usdToCny": 7.2` * to use a fixed rate. The rate actually used is always printed in the * selector's detail block, so a stale or fallback rate is never silent. */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { modelsAreEqual } from "@earendil-works/pi-ai"; import type { Api, Model } from "@earendil-works/pi-ai"; import { getAgentDir, ModelSelectorComponent } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { Spacer, Text } from "@earendil-works/pi-tui"; const DEFAULT_USD_CNY = 6.72; const CONFIG_FILE = "model-selector-prices.json"; const CACHE_FILE = "model-selector-prices-cache.json"; const FETCH_TIMEOUT_MS = 8000; const DEFAULT_REFRESH_HOURS = 12; interface RateConfig { /** Fixed rate. When set to a positive number it wins and disables fetching. */ usdToCny?: number | null; /** Fetch a live rate. Default true. */ liveRate?: boolean; /** Hours before a cached live rate is considered stale. Default 12. */ refreshHours?: number; } interface RateCache { usdToCny: number; origin: string; fetchedAt: number; } const RATE_SOURCES: Array<{ url: string; pick: (json: any) => unknown }> = [ { url: "https://open.er-api.com/v6/latest/USD", pick: (j) => j?.rates?.CNY }, { url: "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json", pick: (j) => j?.usd?.cny, }, ]; let usdToCny = DEFAULT_USD_CNY; let rateOrigin = "内置默认值(未联网)"; let rateFetchedAt: number | undefined; function resetToDefaultRate(): void { usdToCny = DEFAULT_USD_CNY; rateOrigin = "内置默认值(未联网)"; rateFetchedAt = undefined; } function agentFile(name: string): string { return join(getAgentDir(), name); } function readJson(path: string): T | undefined { try { return JSON.parse(readFileSync(path, "utf-8")) as T; } catch { return undefined; } } function writeJson(path: string, value: unknown): void { try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); } catch { // A read-only agent dir must not break the selector. } } function isUsableRate(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } /** Trim float noise: 6.725314 -> 6.7253 */ function fmtRate(value: number): string { return String(Number(value.toFixed(4))); } /** CNY amount, 2 decimals at ¥1+ and 4 decimals below, trailing zeros trimmed. */ export function fmtCny(value: number): string { if (!Number.isFinite(value)) return "?"; if (value === 0) return "0"; return String(Number(value.toFixed(Math.abs(value) >= 1 ? 2 : 4))); } /** Convert a USD-per-1M-token catalog value into a CNY label. */ export function cny(usd: number): string { return `\u00a5${fmtCny(usd * usdToCny)}`; } function shortTime(ms: number): string { const d = new Date(ms); const p = (n: number) => String(n).padStart(2, "0"); return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } /** One-line note describing the rate in force, always shown in the detail block. */ export function rateNote(): string { const stamp = rateFetchedAt ? `\uff0c${shortTime(rateFetchedAt)}` : ""; return `\u4eba\u6c11\u5e01/\u767e\u4e07 token \u00b7 \u6c47\u7387 1 USD = ${fmtRate(usdToCny)} CNY\uff08${rateOrigin}${stamp}\uff09`; } export function setUsdToCny(rate: number, origin = "手动设置", fetchedAt: number = Date.now()): void { if (!isUsableRate(rate)) return; usdToCny = rate; rateOrigin = origin; rateFetchedAt = fetchedAt; } export function getRateInfo(): { usdToCny: number; origin: string; fetchedAt: number | undefined } { return { usdToCny, origin: rateOrigin, fetchedAt: rateFetchedAt }; } async function fetchLiveRate(): Promise<{ rate: number; origin: string } | undefined> { for (const source of RATE_SOURCES) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(source.url, { signal: controller.signal, headers: { accept: "application/json" }, }); if (!response.ok) continue; const picked = source.pick(await response.json()); if (isUsableRate(picked)) return { rate: picked, origin: new URL(source.url).host }; } catch { // Try the next source. } finally { clearTimeout(timer); } } return undefined; } let refreshing: Promise | undefined; /** * Resolve the rate: fixed override > cached live > live fetch > built-in default. * Never throws; the selector always has a usable rate. */ export async function initRates(force = false): Promise { if (refreshing) return refreshing; refreshing = (async () => { const config = readJson(agentFile(CONFIG_FILE)) ?? {}; if (isUsableRate(config.usdToCny)) { setUsdToCny(config.usdToCny, "配置文件固定值"); return; } const cache = readJson(agentFile(CACHE_FILE)); if (cache && isUsableRate(cache.usdToCny)) { setUsdToCny(cache.usdToCny, cache.origin ?? "缓存", cache.fetchedAt ?? Date.now()); } else { // No override and no cache: a stale in-process value must not leak through. resetToDefaultRate(); } if (config.liveRate === false) return; const hours = typeof config.refreshHours === "number" && config.refreshHours >= 0 ? config.refreshHours : DEFAULT_REFRESH_HOURS; const ageMs = rateFetchedAt === undefined ? Number.POSITIVE_INFINITY : Date.now() - rateFetchedAt; if (!force && hours > 0 && ageMs < hours * 3_600_000) return; const live = await fetchLiveRate(); if (!live) return; setUsdToCny(live.rate, live.origin); writeJson(agentFile(CACHE_FILE), { usdToCny: live.rate, origin: live.origin, fetchedAt: Date.now() } satisfies RateCache); })().finally(() => { refreshing = undefined; }); return refreshing; } /** 272000 -> "272K", 1050000 -> "1.05M" */ export function tokens(n: number): string { if (!Number.isFinite(n)) return "?"; if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(2))}M`; if (n >= 1_000) return `${Number((n / 1_000).toFixed(1))}K`; return String(n); } const isTiered = (model: Model): boolean => Boolean(model.cost.tiers?.length); /** Compact one-line tag appended to each selector row. */ export function priceTag(model: Model): string { const c = model.cost; const tag = `${cny(c.input)}/${cny(c.output)} \u00b7 cr ${cny(c.cacheRead)} \u00b7 cw ${cny(c.cacheWrite)}`; return isTiered(model) ? `${tag} \u26a0` : tag; } /** Multi-line breakdown shown under the highlighted model. */ export function detailBreakdown(model: Model): string[] { const c = model.cost; const lines = [ `in ${cny(c.input)} \u00b7 out ${cny(c.output)} \u00b7 cr ${cny(c.cacheRead)} \u00b7 cw ${cny(c.cacheWrite)} \u00b7 ctx ${tokens(model.contextWindow)}`, ]; const tiers = [...(c.tiers ?? [])].sort((a, b) => a.inputTokensAbove - b.inputTokensAbove); for (const tier of tiers) { lines.push( `\u26a0 input > ${tokens(tier.inputTokensAbove)} \u2192 in ${cny(tier.input)} \u00b7 out ${cny(tier.output)} \u00b7 cr ${cny(tier.cacheRead)} \u00b7 cw ${cny(tier.cacheWrite)}`, ); } lines.push(rateNote()); return lines; } let activeTheme: Theme | undefined; let patched = false; /** * Replace the built-in updateList with an equivalent that appends CNY prices. * Kept faithful to the current implementation so the patch survives as long as * that method's shape does; `pi update` may require re-checking this wrapper. */ export function patchModelSelector(theme: Theme | undefined): void { if (patched) return; const proto = ModelSelectorComponent.prototype as Record & { updateList?: (...args: unknown[]) => void; }; if (typeof proto.updateList !== "function") return; patched = true; proto.updateList = function (this: any): void { const t = theme ?? activeTheme; const fg = (color: string, text: string) => (t ? t.fg(color as never, text) : text); this.listContainer.clear(); const maxVisible = 10; const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible)); const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length); for (let i = startIndex; i < endIndex; i++) { const item = this.filteredModels[i]; if (!item) continue; const isSelected = i === this.selectedIndex; const isCurrent = modelsAreEqual(this.currentModel, item.model); const isDefault = this.isDefaultModel(item.model); const defaultBadge = isDefault ? fg("muted", " \u00b7 default") : ""; const cursor = isSelected ? fg("accent", "\u2192 ") : " "; const currentMarker = isCurrent ? fg("accent", "\u2713 ") : " "; const modelText = isSelected ? fg("accent", item.id) : item.id; const providerBadge = fg("muted", `[${item.provider}]`); const price = item.model?.cost ? fg("dim", ` ${priceTag(item.model)}`) : ""; const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}${price}`; this.listContainer.addChild(new Text(line, 0, 0)); } if (startIndex > 0 || endIndex < this.filteredModels.length) { this.listContainer.addChild(new Text(fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`), 0, 0)); } if (this.errorMessage) { for (const line of this.errorMessage.split("\n")) { this.listContainer.addChild(new Text(fg("error", line), 0, 0)); } } else if (this.filteredModels.length === 0) { this.listContainer.addChild(new Text(fg("muted", " No matching models"), 0, 0)); } else { const selected = this.filteredModels[this.selectedIndex]; this.listContainer.addChild(new Spacer(1)); const detail = [ fg("muted", ` Model Name: ${selected.model.name}`), ...detailBreakdown(selected.model).map((line) => fg("dim", ` ${line}`)), ].join("\n"); this.listContainer.addChild(new Text(detail, 0, 0)); } if (this.refreshStatusMessage) { this.listContainer.addChild(new Spacer(1)); this.listContainer.addChild(new Text(fg(this.refreshStatusSuccess ? "success" : "muted", ` ${this.refreshStatusMessage}`), 0, 0)); } }; } export default function modelSelectorPricesExtension(pi: ExtensionAPI) { // Resolve a rate at load time so the first `/model` open is already in CNY. void initRates(false); pi.on("session_start", (_event, ctx) => { activeTheme = ctx.ui.theme; patchModelSelector(activeTheme); void initRates(false); }); }