@lincoln504/pi-research 1.0.12 → 1.1.2

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.
@@ -13,8 +13,44 @@
13
13
 
14
14
  import type { Model } from '@earendil-works/pi-ai';
15
15
  import { extractUsage } from '../types/llm.ts';
16
+ import { logger } from '../logger.ts';
16
17
  import { metrics } from './metrics.ts';
17
18
 
19
+ /** Models already warned about, so the notice fires at most once per model per process. */
20
+ const unpricedModelsWarned = new Set<string>();
21
+
22
+ /**
23
+ * Warn once when a model bills tokens but carries an all-zero price table.
24
+ *
25
+ * Cost is never read off the wire — pi computes it as tokens × the model's local
26
+ * price table — so an unpriced table yields $0.00 silently and indefinitely. That
27
+ * is legitimate for flat-rate plans and local servers, but it is also exactly what
28
+ * a misconfiguration looks like: a hand-written `models[]` entry in models.json
29
+ * REPLACES pi's catalog entry for the same id, discarding its pricing. This
30
+ * happened in practice and went unnoticed for weeks because every display site
31
+ * suppressed a zero cost. The message deliberately does not assert a fault — it
32
+ * states the condition and names the usual cause.
33
+ */
34
+ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
35
+ if (tokens <= 0 || cost > 0) return;
36
+ const m = model as unknown as { provider?: string; id?: string; cost?: Record<string, number> };
37
+ const key = `${m.provider ?? 'unknown'}/${m.id ?? 'unknown'}`;
38
+ if (unpricedModelsWarned.has(key)) return;
39
+
40
+ const table = m.cost;
41
+ const allZero =
42
+ !table || Object.values(table).every((v) => typeof v !== 'number' || v === 0);
43
+ if (!allZero) return;
44
+
45
+ unpricedModelsWarned.add(key);
46
+ logger.warn(
47
+ `[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 — its price table is all zeros. ` +
48
+ `Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written ` +
49
+ `"models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same ` +
50
+ `model id — remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
51
+ );
52
+ }
53
+
18
54
  /** Structural subset of the run observer — just the token sink. Kept structural (rather
19
55
  * than importing ResearchObserver from core/) so this foundation-layer helper does not
20
56
  * depend on an upper layer; the full ResearchObserver satisfies it. */
@@ -43,6 +79,7 @@ export function recordLlmUsage(
43
79
  ): { tokens: number; cost: number } {
44
80
  if (!rawUsage) return { tokens: 0, cost: 0 };
45
81
  const { tokens, cost } = extractUsage(model, rawUsage);
82
+ warnIfUnpriced(model, tokens, cost);
46
83
  if (tokens > 0 || cost > 0) {
47
84
  const labels: Record<string, string> = { component: opts.component };
48
85
  if (opts.complexity !== undefined) labels['complexity'] = String(opts.complexity);
@@ -283,8 +283,15 @@ export function buildResearchSummary(stats: ResearchStats): string {
283
283
  if (stats.tokens > 0) {
284
284
  resourceParts.push(`**${formatTokens(stats.tokens)}** tokens`);
285
285
  }
286
- if (stats.cost > 0) {
287
- const costStr = stats.cost < 0.01 ? '<$0.01' : `$${stats.cost.toFixed(2)}`;
286
+ // Render the cost whenever tokens were actually billed-through, INCLUDING when it
287
+ // is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose
288
+ // price table is all zeros (an unpriced or catalog-overwritten entry) produced no
289
+ // cost line at all, so a misconfiguration was indistinguishable from a run that
290
+ // simply had not reported yet. "$0.00" beside a large token count is conspicuous;
291
+ // silence is not. Zero is also the honest figure for flat-rate and local providers.
292
+ if (stats.tokens > 0 || stats.cost > 0) {
293
+ const costStr =
294
+ stats.cost === 0 ? '$0.00' : stats.cost < 0.01 ? '<$0.01' : `$${stats.cost.toFixed(2)}`;
288
295
  resourceParts.push(`**${costStr}**`);
289
296
  }
290
297
  if (stats.durationMs > 0) {
@@ -317,6 +324,9 @@ export interface SessionStats {
317
324
  totalUrlsDiscovered: number;
318
325
  totalSearchQueries: number;
319
326
  totalTokens: number;
327
+ /** Summed LLM cost across the session's runs. The Session Metrics UI advertises
328
+ * "API cost estimates"; without this field it had no cost to show. */
329
+ totalCost: number;
320
330
  totalToolUsage: {
321
331
  searches: number;
322
332
  scrapes: number;
@@ -339,6 +349,7 @@ export function aggregateSessionStats(
339
349
  let totalUrlsDiscovered = 0;
340
350
  let totalSearchQueries = 0;
341
351
  let totalTokens = 0;
352
+ let totalCost = 0;
342
353
  const totalToolUsage = {
343
354
  searches: 0,
344
355
  scrapes: 0,
@@ -359,6 +370,7 @@ export function aggregateSessionStats(
359
370
  totalUrlsDiscovered += stats.urlsDiscovered;
360
371
  totalSearchQueries += stats.searchQueries;
361
372
  totalTokens += stats.tokens;
373
+ totalCost += stats.cost;
362
374
  totalToolUsage.searches += stats.toolUsage.searches;
363
375
  totalToolUsage.scrapes += stats.toolUsage.scrapes;
364
376
  totalToolUsage.securitySearches += stats.toolUsage.securitySearches;
@@ -377,6 +389,7 @@ export function aggregateSessionStats(
377
389
  totalUrlsDiscovered,
378
390
  totalSearchQueries,
379
391
  totalTokens,
392
+ totalCost,
380
393
  totalToolUsage,
381
394
  };
382
395
  }
@@ -403,6 +416,16 @@ export function buildSessionOverview(stats: SessionStats): string {
403
416
  }
404
417
  if (stats.totalTokens > 0) {
405
418
  overviewParts.push(`**${formatTokens(stats.totalTokens)}** tokens`);
419
+ // Shown alongside tokens even at exactly $0.00 — the Session Metrics view
420
+ // advertises cost estimates, and a silent omission is what let an unpriced
421
+ // model table go unnoticed. Zero is correct for flat-rate and local providers.
422
+ const costStr =
423
+ stats.totalCost === 0
424
+ ? '$0.00'
425
+ : stats.totalCost < 0.01
426
+ ? '<$0.01'
427
+ : `$${stats.totalCost.toFixed(2)}`;
428
+ overviewParts.push(`**${costStr}**`);
406
429
  }
407
430
  if (stats.totalDurationMs > 0) {
408
431
  overviewParts.push(`**${formatDuration(stats.totalDurationMs)}** total research time`);
@@ -207,8 +207,15 @@ export async function exportResearchReport(
207
207
  */
208
208
  export function appendExportMessage(result: string, filepath: string, totalCost?: number): string {
209
209
  let footer = `\n\n---\n\nResearch report saved to: ${filepath}`;
210
- if (totalCost !== undefined && totalCost > 0) {
211
- const costStr = totalCost < 0.01 ? '<$0.01' : `$${totalCost.toFixed(4)}`.replace(/0+$/, '').replace(/\.$/, '');
210
+ // Report a defined cost even when it is exactly zero — suppressing it made an
211
+ // unpriced/misconfigured model table look identical to "no cost reported yet".
212
+ if (totalCost !== undefined) {
213
+ const costStr =
214
+ totalCost === 0
215
+ ? '$0.00'
216
+ : totalCost < 0.01
217
+ ? '<$0.01'
218
+ : `$${totalCost.toFixed(4)}`.replace(/0+$/, '').replace(/\.$/, '');
212
219
  footer += `\nTotal cost: ${costStr}`;
213
220
  }
214
221
  return result + footer;
@@ -141,6 +141,11 @@ export function withTimeout<T>(
141
141
  // aborted before we start means an external (user) cancellation, not a timeout —
142
142
  // a clean stop, so log at debug, not error.
143
143
  if (combinedSignal.aborted) {
144
+ // `promise` is a parameter, so the caller already started it. Returning here
145
+ // skips the promise.then(...) attachment below, leaving its eventual rejection
146
+ // with no handler — and Node 22 defaults to --unhandled-rejections=throw, so
147
+ // that kills the process. Adopt the rejection before bailing out.
148
+ void promise.catch(() => undefined);
144
149
  logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
145
150
  return reject(new Error(`${label} cancelled or timed out`));
146
151
  }
@@ -319,7 +324,14 @@ export async function retryWithBackoff<T>(
319
324
  // the full backoff. If the wait is aborted, surface the original transient
320
325
  // error (not the synthetic AbortError) so callers see the real cause.
321
326
  try {
322
- await abortableDelay(delay, opts.signal);
327
+ // keepAlive=true: this sleep IS the operation. The retry has already been
328
+ // decided and the next attempt must run, so the timer must hold the event
329
+ // loop open. Without it, a transient network failure — which by its nature
330
+ // leaves no referenced socket handle — can make the backoff the sole pending
331
+ // handle, and Node drains the loop and exits mid-retry. The attempt never
332
+ // runs, and no error surfaces: in the CLI neither branch of the top-level
333
+ // handler fires, so the user gets a bare exit with no report.
334
+ await abortableDelay(delay, opts.signal, true);
323
335
  } catch {
324
336
  metrics.increment('retry_aborted_total', 1, { label: opts.label });
325
337
  throw lastError;