@bismawy/pi-arnative 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,6 +87,8 @@ interface UsageModelStats {
87
87
  cacheRead: number;
88
88
  totalTokens: number;
89
89
  cost: number;
90
+ /** Cost reconstructed from the price table, only when `cost` is 0. Never folded into `cost`. */
91
+ estimatedCost: number;
90
92
  }
91
93
 
92
94
  interface UsageProviderStats {
@@ -98,6 +100,8 @@ interface UsageProviderStats {
98
100
  cacheRead: number;
99
101
  totalTokens: number;
100
102
  cost: number;
103
+ /** Sum of the models' `estimatedCost`, for the same rows this provider could not price. */
104
+ estimatedCost: number;
101
105
  models: UsageModelStats[];
102
106
  }
103
107
 
@@ -111,9 +115,82 @@ export interface UsageSummary {
111
115
  cacheRead: number;
112
116
  totalTokens: number;
113
117
  cost: number;
118
+ /** Provider-reported cost only (never includes the estimate). */
119
+ reportedCost: number;
120
+ /** Cost reconstructed from `prices.json`. Shown with a `~`, never folded into `cost`. */
121
+ estimatedCost: number;
114
122
  };
115
123
  }
116
124
 
125
+ export type ModelPrices = Record<string, { input?: number; output?: number; cacheRead?: number }>;
126
+ export type PriceTable = Record<string, ModelPrices>;
127
+
128
+ const modelsFilePath = (): string => join(getAgentDir(), "models.json");
129
+
130
+ type ModelsJsonCost = { input?: number; output?: number; cacheRead?: number };
131
+
132
+ /**
133
+ * Rates declared automatically in pi's `models.json` (`providers.<name>.models[].cost`).
134
+ * Keyed by provider to reconstruct estimates for historical or 0-cost rows.
135
+ */
136
+ export function loadPriceTable(): PriceTable {
137
+ const table: PriceTable = {};
138
+ try {
139
+ const raw = JSON.parse(readFileSync(modelsFilePath(), "utf8")) as {
140
+ providers?: Record<string, { models?: { id?: string; cost?: ModelsJsonCost }[] }>;
141
+ };
142
+ for (const [provider, cfg] of Object.entries(raw.providers ?? {})) {
143
+ const models: ModelPrices = {};
144
+ for (const m of cfg?.models ?? []) {
145
+ const c = m?.cost;
146
+ if (!m?.id || !c || typeof c.input !== "number" || typeof c.output !== "number") continue;
147
+ models[m.id] = { input: c.input, output: c.output, cacheRead: typeof c.cacheRead === "number" ? c.cacheRead : 0 };
148
+ }
149
+ if (Object.keys(models).length > 0) table[provider] = models;
150
+ }
151
+ } catch {
152
+ // Malformed or absent models.json means no declared rates.
153
+ }
154
+ return table;
155
+ }
156
+
157
+ /** True when this provider+model has a usable input+output price (cacheRead is optional). */
158
+ export function hasPrice(provider: string, modelId: string, table: PriceTable = loadPriceTable()): boolean {
159
+ return findPrice(provider, modelId, table) !== null;
160
+ }
161
+
162
+ /** Rates for one provider+model. The provider's own entry wins over the `*` fallback. */
163
+ function findPrice(provider: string, modelId: string, table: PriceTable): { input: number; output: number; cacheRead: number } | null {
164
+ return rateFor(table[provider], modelId) ?? rateFor(table["*"], modelId);
165
+ }
166
+
167
+ function rateFor(entries: ModelPrices | undefined, modelId: string): { input: number; output: number; cacheRead: number } | null {
168
+ if (!entries || typeof entries !== "object") return null;
169
+ const lower = modelId.toLowerCase();
170
+ const keys = Object.keys(entries).filter((k) => !k.startsWith("_"));
171
+ const key =
172
+ keys.find((k) => k.toLowerCase() === lower) ??
173
+ keys.find((k) => lower.endsWith("/" + k.toLowerCase()) || k.toLowerCase().endsWith("/" + lower));
174
+ if (!key) return null;
175
+ const p = entries[key]!;
176
+ if (typeof p.input !== "number" || typeof p.output !== "number") return null;
177
+ return { input: p.input, output: p.output, cacheRead: typeof p.cacheRead === "number" ? p.cacheRead : 0 };
178
+ }
179
+
180
+ /** Cost of one provider+model's token counts from the price table, or null when unpriced. */
181
+ export function estimateCost(
182
+ input: number,
183
+ output: number,
184
+ cacheRead: number,
185
+ modelId: string,
186
+ provider: string,
187
+ table: PriceTable = loadPriceTable(),
188
+ ): number | null {
189
+ const p = findPrice(provider, modelId, table);
190
+ if (!p) return null;
191
+ return (input / 1e6) * p.input + (output / 1e6) * p.output + (cacheRead / 1e6) * p.cacheRead;
192
+ }
193
+
117
194
  /** Scaled value with 1 decimal ("2.5k"); a value that rounds up to the next unit is promoted (999,999 -> "1M", not "1000k"). */
118
195
  const fmtUnit = (v: number, unit: string, next: string): string => {
119
196
  const s = v.toFixed(1).replace(/\.0$/, "");
@@ -208,10 +285,9 @@ function entryOfLine(line: string): UsageEntry | null {
208
285
  }
209
286
 
210
287
  /**
211
- * Parse newline-terminated lines from a buffer. Byte granularity, so the returned offset can be
212
- * fed back to `readSync`. A trailing segment with no newline is parsed (the last record of a file
213
- * not ending in `\n`) but only consumed when it is valid JSON — a write caught mid-flight is left
214
- * for the next scan instead of being dropped or truncated into a bad entry.
288
+ * Parse newline-terminated lines from a buffer. The returned offset is byte-granular, so it can
289
+ * be fed back to `readSync`; a trailing segment without a newline counts only when it parses, so
290
+ * a write caught mid-flight is left for the next scan rather than dropped.
215
291
  */
216
292
  function parseBuffer(buf: Buffer): { entries: UsageEntry[]; consumed: number } {
217
293
  const entries: UsageEntry[] = [];
@@ -428,6 +504,20 @@ export function collectUsageSummary(forceScan = false): UsageSummary {
428
504
 
429
505
  providers.sort((a, b) => b.totalTokens - a.totalTokens);
430
506
 
507
+ // Estimate unpriced pairs from price table. Kept separate in estimatedCost.
508
+ const priceTable = loadPriceTable();
509
+ let estimatedCost = 0;
510
+ for (const p of providers) {
511
+ let providerEstimate = 0;
512
+ for (const m of p.models) {
513
+ const est = m.cost > 0 ? null : estimateCost(m.input, m.output, m.cacheRead, m.name, p.name, priceTable);
514
+ m.estimatedCost = est ?? 0;
515
+ providerEstimate += m.estimatedCost;
516
+ }
517
+ p.estimatedCost = providerEstimate;
518
+ estimatedCost += providerEstimate;
519
+ }
520
+
431
521
  cachedSummary = {
432
522
  providers,
433
523
  totals: {
@@ -438,6 +528,8 @@ export function collectUsageSummary(forceScan = false): UsageSummary {
438
528
  cacheRead: totalCache,
439
529
  totalTokens: totalInp + totalOut,
440
530
  cost: totalCost,
531
+ reportedCost: totalCost,
532
+ estimatedCost,
441
533
  },
442
534
  };
443
535
  lastScanMs = now;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -40,8 +40,9 @@
40
40
  "@earendil-works/pi-tui": "*"
41
41
  },
42
42
  "scripts": {
43
- "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts --extension ./extensions/arnative.ts",
44
- "test": "node lib/box.ts && node lib/ansi.ts && node lib/color.ts && node lib/shortcuts.ts && node lib/preset-store.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts && node --test",
43
+ "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts --extension ./extensions/animations.ts --extension ./extensions/arnative.ts",
44
+ "test": "node lib/box.ts && node lib/ansi.ts && node lib/color.ts && node lib/shortcuts.ts && node lib/preset-store.ts && node lib/spinner.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/animations.ts && node extensions/arnative.ts && node --test",
45
+ "preview": "node scripts/preview.mjs",
45
46
  "themes": "node themes/gen-themes.mjs",
46
47
  "release:check": "node scripts/release-check.mjs pre-tag",
47
48
  "prepublishOnly": "npm test && node scripts/release-check.mjs pre-publish"