imprint-mcp 0.6.2 → 0.6.4

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.
@@ -1,4 +1,3 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  import {
3
2
  MimeType,
4
3
  type NodeTracerProvider,
@@ -15,26 +14,6 @@ type TraceKind = OpenInferenceSpanKind | `${OpenInferenceSpanKind}`;
15
14
  type TraceAttributes = Record<string, unknown>;
16
15
  type TraceLlmMessage = { role?: string; content?: string };
17
16
 
18
- // ---------------------------------------------------------------------------
19
- // Cost accumulator — rolls up LLM costs from child spans to a parent span.
20
- // ---------------------------------------------------------------------------
21
- interface CostAccumulator {
22
- inputTokens: number;
23
- outputTokens: number;
24
- cacheReadTokens: number;
25
- cacheWriteTokens: number;
26
- uncachedInputCost: number;
27
- cacheReadCost: number;
28
- cacheWriteCost: number;
29
- completionCost: number;
30
- }
31
-
32
- const costAccumulatorStorage = new AsyncLocalStorage<CostAccumulator>();
33
-
34
- function getActiveCostAccumulator(): CostAccumulator | undefined {
35
- return costAccumulatorStorage.getStore();
36
- }
37
-
38
17
  let provider: NodeTracerProvider | null = null;
39
18
  let attemptedInit = false;
40
19
  let suppressInit = false;
@@ -49,8 +28,6 @@ export function suppressTracingInit(): void {
49
28
  }
50
29
  const DEFAULT_TRACE_IO_MAX_CHARS = 50_000;
51
30
  const DEFAULT_TRACE_ERROR_MAX_CHARS = 2000;
52
- const CACHE_READ_MULTIPLIER = 0.1;
53
- const CACHE_WRITE_MULTIPLIER = 1.25;
54
31
 
55
32
  function isTracingEnabled(): boolean {
56
33
  return (
@@ -82,6 +59,12 @@ function validateTracingUrl(raw: string | undefined): string | undefined {
82
59
  function ensureTracingInitialized(): void {
83
60
  if (attemptedInit || suppressInit || !isTracingEnabled()) return;
84
61
  attemptedInit = true;
62
+ const legacyCostEnv = legacyTraceCostEnvNames();
63
+ if (legacyCostEnv.length > 0) {
64
+ process.stderr.write(
65
+ `[imprint] warning: ${legacyCostEnv.join(', ')} ${legacyCostEnv.length === 1 ? 'is' : 'are'} no longer used; configure model pricing in Phoenix under Settings → Models\n`,
66
+ );
67
+ }
85
68
  // The OTEL SDK default is 128 attributes per span. getLLMAttributes() flattens
86
69
  // each input message into ~2+ attributes (role, content, tool_calls…), so a
87
70
  // 60-message conversation exceeds the cap and silently drops later attributes
@@ -104,6 +87,20 @@ export function traceBatchEnabled(value: string | undefined): boolean {
104
87
  return value === undefined ? true : isTruthy(value);
105
88
  }
106
89
 
90
+ /** Legacy local-pricing variables retained only to produce an upgrade warning. */
91
+ export function legacyTraceCostEnvNames(
92
+ env: Record<string, string | undefined> = process.env,
93
+ ): string[] {
94
+ return Object.keys(env)
95
+ .filter(
96
+ (name) =>
97
+ /^IMPRINT_TRACE_(?:INPUT|PROMPT|OUTPUT|COMPLETION)_USD_PER_1M$/.test(name) ||
98
+ /^IMPRINT_TRACE_COST_.+_(?:INPUT|PROMPT|OUTPUT|COMPLETION)_USD_PER_1M$/.test(name),
99
+ )
100
+ .filter((name) => env[name] !== undefined && env[name] !== '')
101
+ .sort();
102
+ }
103
+
107
104
  export function traceLlmIoEnabled(): boolean {
108
105
  if (process.env.IMPRINT_TRACE_LLM_IO !== undefined)
109
106
  return isTruthy(process.env.IMPRINT_TRACE_LLM_IO);
@@ -163,12 +160,9 @@ export function resolveTraceTokenCount(
163
160
  *
164
161
  * Providers (Anthropic API and the claude CLI alike) report `usage.input_tokens`
165
162
  * as the *uncached* portion only — the cached bulk lives in the separate cache
166
- * counts. `llmCostAttributes` expects `inputTokens` to be the TOTAL (it
167
- * re-derives uncached by subtracting the cache split), and `llm.token_count.prompt`
168
- * should likewise reflect the whole prompt. So every capture boundary normalizes
169
- * here instead of feeding the bare uncached count (which billed the cached bulk
170
- * at the full input rate, or mislabeled the token count). Returns null when the
171
- * uncached count itself is unknown.
163
+ * counts. `llm.token_count.prompt` must reflect the whole prompt so Phoenix can
164
+ * combine it with the detailed cache token counts and calculate cost server-side.
165
+ * Returns null when the uncached count itself is unknown.
172
166
  */
173
167
  export function totalPromptTokens(
174
168
  uncachedInputTokens: number | null | undefined,
@@ -179,33 +173,6 @@ export function totalPromptTokens(
179
173
  return uncachedInputTokens + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0);
180
174
  }
181
175
 
182
- const DEFAULT_MODEL_RATES: Record<string, { inputUsdPer1M: number; outputUsdPer1M: number }> = {
183
- 'claude-opus-4-8': { inputUsdPer1M: 5, outputUsdPer1M: 25 },
184
- 'claude-opus-4-7': { inputUsdPer1M: 5, outputUsdPer1M: 25 },
185
- 'claude-opus-4-6': { inputUsdPer1M: 5, outputUsdPer1M: 25 },
186
- 'claude-opus-4-5': { inputUsdPer1M: 5, outputUsdPer1M: 25 },
187
- 'claude-opus-4-1': { inputUsdPer1M: 15, outputUsdPer1M: 75 },
188
- 'claude-sonnet-4-6': { inputUsdPer1M: 3, outputUsdPer1M: 15 },
189
- 'claude-sonnet-4-5': { inputUsdPer1M: 3, outputUsdPer1M: 15 },
190
- 'claude-haiku-4-5': { inputUsdPer1M: 1, outputUsdPer1M: 5 },
191
- };
192
-
193
- export function traceLlmCostRates(
194
- providerName: string,
195
- modelName?: string,
196
- ): { inputUsdPer1M: number; outputUsdPer1M: number } | null {
197
- const inputUsdPer1M = envNumber(rateEnvNames(providerName, modelName, 'INPUT'));
198
- const outputUsdPer1M = envNumber(rateEnvNames(providerName, modelName, 'OUTPUT'));
199
- if (inputUsdPer1M !== null && outputUsdPer1M !== null) {
200
- return { inputUsdPer1M, outputUsdPer1M };
201
- }
202
- if (modelName) {
203
- const defaultRate = DEFAULT_MODEL_RATES[modelName];
204
- if (defaultRate) return defaultRate;
205
- }
206
- return null;
207
- }
208
-
209
176
  export function traceInputOutputAttributes(
210
177
  direction: 'input' | 'output',
211
178
  value: string,
@@ -274,59 +241,6 @@ export async function traced<T>(
274
241
  );
275
242
  }
276
243
 
277
- /**
278
- * Like `traced`, but accumulates `llm.cost.*` from all descendant LLM spans
279
- * and sets the rolled-up totals on the parent span when `fn` completes.
280
- * Use on root spans (`cli.teach`, `cli.audit`) so Phoenix shows the full cost.
281
- */
282
- export async function tracedWithCostRollup<T>(
283
- name: string,
284
- kind: TraceKind,
285
- attributes: TraceAttributes | undefined,
286
- fn: (span: Span) => Promise<T> | T,
287
- ): Promise<T> {
288
- const acc: CostAccumulator = {
289
- inputTokens: 0,
290
- outputTokens: 0,
291
- cacheReadTokens: 0,
292
- cacheWriteTokens: 0,
293
- uncachedInputCost: 0,
294
- cacheReadCost: 0,
295
- cacheWriteCost: 0,
296
- completionCost: 0,
297
- };
298
-
299
- const applyCostRollup = (span: Span): void => {
300
- const promptCost = acc.uncachedInputCost + acc.cacheReadCost + acc.cacheWriteCost;
301
- const totalCost = promptCost + acc.completionCost;
302
- if (totalCost === 0 && acc.inputTokens === 0 && acc.outputTokens === 0) return;
303
- setSpanAttributes(span, {
304
- [SemanticConventions.LLM_TOKEN_COUNT_PROMPT]: acc.inputTokens,
305
- [SemanticConventions.LLM_TOKEN_COUNT_COMPLETION]: acc.outputTokens,
306
- [SemanticConventions.LLM_TOKEN_COUNT_TOTAL]: acc.inputTokens + acc.outputTokens,
307
- [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: acc.cacheReadTokens,
308
- [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: acc.cacheWriteTokens,
309
- [SemanticConventions.LLM_COST_PROMPT]: promptCost,
310
- [SemanticConventions.LLM_COST_COMPLETION]: acc.completionCost,
311
- [SemanticConventions.LLM_COST_TOTAL]: totalCost,
312
- [SemanticConventions.LLM_COST_PROMPT_DETAILS_CACHE_READ]: acc.cacheReadCost,
313
- [SemanticConventions.LLM_COST_PROMPT_DETAILS_CACHE_WRITE]: acc.cacheWriteCost,
314
- [SemanticConventions.LLM_COST_INPUT]: acc.uncachedInputCost,
315
- 'imprint.llm.cost_estimated': true,
316
- });
317
- };
318
-
319
- return costAccumulatorStorage.run(acc, () =>
320
- traced(name, kind, attributes, async (span) => {
321
- try {
322
- return await fn(span);
323
- } finally {
324
- applyCostRollup(span);
325
- }
326
- }),
327
- );
328
- }
329
-
330
244
  export function startTraceSpan(
331
245
  name: string,
332
246
  kind: TraceKind,
@@ -357,7 +271,7 @@ export function endTraceSpan(span: Span | null | undefined, err?: unknown): void
357
271
  span.end();
358
272
  }
359
273
 
360
- export function llmSpanAttributes(opts: {
274
+ interface LlmSpanAttributeOptions {
361
275
  provider: string;
362
276
  model?: string;
363
277
  inputTokens?: number | null;
@@ -375,21 +289,13 @@ export function llmSpanAttributes(opts: {
375
289
  inputMimeType?: string;
376
290
  outputMimeType?: string;
377
291
  invocationParameters?: Record<string, unknown>;
378
- }): Attributes {
292
+ }
293
+
294
+ export function llmSpanAttributes(opts: LlmSpanAttributeOptions): Attributes {
379
295
  const prompt = opts.inputTokens ?? undefined;
380
296
  const completion = opts.outputTokens ?? undefined;
381
- const costRates = traceLlmCostRates(opts.provider, opts.model);
382
- const cost =
383
- costRates && (prompt !== undefined || completion !== undefined)
384
- ? llmCostAttributes({
385
- inputTokens: prompt,
386
- outputTokens: completion,
387
- cacheReadTokens: opts.cacheReadTokens ?? undefined,
388
- cacheWriteTokens: opts.cacheWriteTokens ?? undefined,
389
- inputUsdPer1M: costRates.inputUsdPer1M,
390
- outputUsdPer1M: costRates.outputUsdPer1M,
391
- })
392
- : {};
297
+ const cacheRead = opts.cacheReadTokens ?? undefined;
298
+ const cacheWrite = opts.cacheWriteTokens ?? undefined;
393
299
  return {
394
300
  ...getLLMAttributes({
395
301
  provider: openInferenceProvider(opts.provider),
@@ -416,7 +322,12 @@ export function llmSpanAttributes(opts: {
416
322
  ...(opts.outputValue
417
323
  ? traceInputOutputAttributes('output', opts.outputValue, opts.outputMimeType ?? MimeType.TEXT)
418
324
  : {}),
419
- ...cost,
325
+ ...(cacheRead !== undefined
326
+ ? { [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: cacheRead }
327
+ : {}),
328
+ ...(cacheWrite !== undefined
329
+ ? { [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: cacheWrite }
330
+ : {}),
420
331
  ...(opts.stopReason ? { [SemanticConventions.LLM_FINISH_REASON]: opts.stopReason } : {}),
421
332
  'imprint.llm.provider': opts.provider,
422
333
  ...(opts.tokenCountsEstimated !== undefined
@@ -426,15 +337,34 @@ export function llmSpanAttributes(opts: {
426
337
  ...(opts.outputTokenSource
427
338
  ? { 'imprint.llm.output_tokens_source': opts.outputTokenSource }
428
339
  : {}),
429
- ...(costRates
430
- ? {
431
- 'imprint.llm.cost.input_usd_per_1m': costRates.inputUsdPer1M,
432
- 'imprint.llm.cost.output_usd_per_1m': costRates.outputUsdPer1M,
433
- }
434
- : {}),
435
340
  };
436
341
  }
437
342
 
343
+ /**
344
+ * Record aggregate token usage reported by an external agent CLI without
345
+ * misclassifying the surrounding tool-driving workflow as one LLM invocation.
346
+ * The zero-duration child is deliberately marked as an aggregate usage carrier:
347
+ * Phoenix can price it, while workflow latency remains on the parent AGENT span.
348
+ */
349
+ export function recordLlmUsageSpan(
350
+ name: string,
351
+ opts: LlmSpanAttributeOptions,
352
+ attributes: TraceAttributes = {},
353
+ ): void {
354
+ const hasUsage =
355
+ (opts.inputTokens ?? 0) > 0 ||
356
+ (opts.outputTokens ?? 0) > 0 ||
357
+ (opts.cacheReadTokens ?? 0) > 0 ||
358
+ (opts.cacheWriteTokens ?? 0) > 0;
359
+ if (!hasUsage) return;
360
+ const span = startTraceSpan(name, 'LLM', {
361
+ ...attributes,
362
+ 'imprint.llm.usage_aggregate': true,
363
+ ...llmSpanAttributes(opts),
364
+ });
365
+ endTraceSpan(span);
366
+ }
367
+
438
368
  export function traceLlmMessages(messages: TraceLlmMessage[]): TraceLlmMessage[] {
439
369
  return messages.map((message) => ({
440
370
  ...message,
@@ -539,114 +469,6 @@ function stringifyTraceValue(value: unknown): string {
539
469
  }
540
470
  }
541
471
 
542
- function llmCostAttributes(opts: {
543
- inputTokens?: number;
544
- outputTokens?: number;
545
- cacheReadTokens?: number;
546
- cacheWriteTokens?: number;
547
- inputUsdPer1M: number;
548
- outputUsdPer1M: number;
549
- }): Attributes {
550
- const cacheRead = opts.cacheReadTokens ?? 0;
551
- const cacheWrite = opts.cacheWriteTokens ?? 0;
552
- const hasCacheBreakdown = cacheRead > 0 || cacheWrite > 0;
553
- const uncachedInput =
554
- opts.inputTokens === undefined
555
- ? undefined
556
- : hasCacheBreakdown
557
- ? Math.max(0, opts.inputTokens - cacheRead - cacheWrite)
558
- : opts.inputTokens;
559
-
560
- let uncachedInputCost: number | undefined;
561
- let cacheReadCost = 0;
562
- let cacheWriteCost = 0;
563
- if (uncachedInput !== undefined) {
564
- if (hasCacheBreakdown) {
565
- uncachedInputCost = (uncachedInput / 1_000_000) * opts.inputUsdPer1M;
566
- cacheReadCost = (cacheRead / 1_000_000) * opts.inputUsdPer1M * CACHE_READ_MULTIPLIER;
567
- cacheWriteCost = (cacheWrite / 1_000_000) * opts.inputUsdPer1M * CACHE_WRITE_MULTIPLIER;
568
- } else {
569
- uncachedInputCost = (uncachedInput / 1_000_000) * opts.inputUsdPer1M;
570
- }
571
- }
572
-
573
- const prompt =
574
- uncachedInputCost === undefined
575
- ? undefined
576
- : uncachedInputCost + cacheReadCost + cacheWriteCost;
577
- const completion =
578
- opts.outputTokens === undefined
579
- ? undefined
580
- : (opts.outputTokens / 1_000_000) * opts.outputUsdPer1M;
581
- const total = (prompt ?? 0) + (completion ?? 0);
582
-
583
- // Roll up into the nearest ancestor tracedWithCostRollup, if any.
584
- const acc = getActiveCostAccumulator();
585
- if (acc) {
586
- acc.inputTokens += opts.inputTokens ?? 0;
587
- acc.outputTokens += opts.outputTokens ?? 0;
588
- acc.cacheReadTokens += cacheRead;
589
- acc.cacheWriteTokens += cacheWrite;
590
- acc.uncachedInputCost += uncachedInputCost ?? 0;
591
- acc.cacheReadCost += cacheReadCost;
592
- acc.cacheWriteCost += cacheWriteCost;
593
- acc.completionCost += completion ?? 0;
594
- }
595
-
596
- return {
597
- ...(prompt !== undefined ? { [SemanticConventions.LLM_COST_PROMPT]: prompt } : {}),
598
- ...(completion !== undefined ? { [SemanticConventions.LLM_COST_COMPLETION]: completion } : {}),
599
- [SemanticConventions.LLM_COST_TOTAL]: total,
600
- ...(hasCacheBreakdown
601
- ? {
602
- [SemanticConventions.LLM_COST_PROMPT_DETAILS_CACHE_READ]: cacheReadCost,
603
- [SemanticConventions.LLM_COST_PROMPT_DETAILS_CACHE_WRITE]: cacheWriteCost,
604
- [SemanticConventions.LLM_COST_INPUT]: uncachedInputCost,
605
- [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: cacheRead,
606
- [SemanticConventions.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: cacheWrite,
607
- }
608
- : {}),
609
- 'imprint.llm.cost_estimated': true,
610
- };
611
- }
612
-
613
- function rateEnvNames(
614
- providerName: string,
615
- modelName: string | undefined,
616
- side: 'INPUT' | 'OUTPUT',
617
- ): string[] {
618
- const providerKey = envKey(providerName);
619
- const modelKey = modelName ? envKey(modelName) : undefined;
620
- const aliases = side === 'INPUT' ? ['INPUT', 'PROMPT'] : ['OUTPUT', 'COMPLETION'];
621
- const names: string[] = [];
622
- for (const alias of aliases) {
623
- if (providerKey && modelKey) {
624
- names.push(`IMPRINT_TRACE_COST_${providerKey}_${modelKey}_${alias}_USD_PER_1M`);
625
- }
626
- if (modelKey) names.push(`IMPRINT_TRACE_COST_${modelKey}_${alias}_USD_PER_1M`);
627
- if (providerKey) names.push(`IMPRINT_TRACE_COST_${providerKey}_${alias}_USD_PER_1M`);
628
- names.push(`IMPRINT_TRACE_${alias}_USD_PER_1M`);
629
- }
630
- return names;
631
- }
632
-
633
- function envKey(value: string): string {
634
- return value
635
- .toUpperCase()
636
- .replace(/[^A-Z0-9]+/g, '_')
637
- .replace(/^_+|_+$/g, '');
638
- }
639
-
640
- function envNumber(names: string[]): number | null {
641
- for (const name of names) {
642
- const raw = process.env[name];
643
- if (raw === undefined || raw === '') continue;
644
- const parsed = Number(raw);
645
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
646
- }
647
- return null;
648
- }
649
-
650
472
  function isTruthy(value: string | undefined): boolean {
651
473
  if (!value) return false;
652
474
  return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());