@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.
- package/README.md +20 -8
- package/dist/cli.mjs +210 -67
- package/dist/thread-worker.mjs +11 -1
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +40 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/KNOWLEDGE-STORE.md +1 -1
- package/docs/SDK.md +17 -5
- package/package.json +1 -1
- package/scripts/build.cjs +23 -5
- package/skills/pi-research/SKILL.md +13 -4
- package/skills/pi-research/references/configuration.md +1 -1
- package/src/cli.ts +54 -4
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/infrastructure/browser/browser-error-utils.ts +19 -4
- package/src/infrastructure/browser/task-execution-service.ts +86 -9
- package/src/infrastructure/browser/thread-worker.mjs +11 -1
- package/src/infrastructure/research-run-semaphore.ts +43 -10
- package/src/orchestration/headless-observer.ts +4 -0
- package/src/orchestration/research-orchestration-service.ts +42 -7
- package/src/orchestration/research-synthesis-service.ts +1 -81
- package/src/tools/research-knowledge-search.ts +8 -2
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +103 -7
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/retry-utils.ts +13 -1
package/src/utils/llm-usage.ts
CHANGED
|
@@ -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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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;
|