@danypops/jittor 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ The graph fetches metrics per distinct provider/model scope (`jittor metrics dis
72
72
 
73
73
  ### Cost per Papyrus task
74
74
 
75
- Jittor observes Papyrus's task-focus lifecycle in real time over a shared Pi extension event bus (`papyrus.task-focus.v1`) -- Papyrus never depends on Jittor, it only broadcasts which task is currently focused. Every token/cost metric Jittor already records on a finalized Pi assistant message is tagged with the currently focused task's id the moment it is recorded (no time-window estimation, no new instrumentation). A paused or cleared focus stops tagging; spend recorded with nothing focused is reported separately as unattributed, never dropped or folded into an invented task. Run `jittor metrics cost-by-task --since <ms> --until <ms> [--json]` for a bounded per-task breakdown of cost and input/output/cache tokens.
75
+ Jittor observes Papyrus's task-focus lifecycle in real time over a shared Pi extension event bus (`papyrus.task-focus.v1`) -- Papyrus never depends on Jittor, it only broadcasts which task is currently focused. Every token/cost metric Jittor already records on a finalized Pi assistant message is tagged with the currently focused task's id, and the provider/model/thinking level active at that moment, the instant it is recorded (no time-window estimation, no new instrumentation). A paused or cleared focus stops tagging; spend recorded with nothing focused is reported separately as unattributed, never dropped or folded into an invented task. Run `jittor metrics cost-by-task --since <ms> --until <ms> [--json]` for a bounded per-task breakdown of cost and input/output/cache tokens, broken down further by which provider/model/thinking combination each task actually spent on.
76
76
 
77
77
  Series are colored with a categorical palette chosen to avoid this UI's own status colors ("success"/"warning"/"error" already mean something specific elsewhere in this panel, so reusing them for arbitrary model identity would make a model's bar segment look like a warning or a failure) and instead reuses the theme's syntax-highlighting roles, which are already tuned by theme authors to stay mutually distinguishable on screen — the same design problem as a categorical data palette. Once more series are active than there are hues, a series reuses a hue in bold rather than repeating an indistinguishable color. Multiple models active within the same cumulative time frame are rendered as one bar stacked by color, not separate bars.
78
78
 
@@ -230,8 +230,12 @@ async function applyDecision(
230
230
  return halt(ctx, `Jittor could not apply any authenticated Pi route after ${decision.route.provider}/${decision.route.model} became unavailable`);
231
231
  }
232
232
 
233
- /** taskId, when a Papyrus task is currently focused in this session, tags the metric for real-time cost-per-task correlation without any new instrumentation surface. */
234
- function assistantUsageMetrics(message: unknown, observedAt: number, taskId: string | null = null): MetricObservation[] {
233
+ /**
234
+ * taskId, when a Papyrus task is focused, tags the metric for cost-per-task correlation. thinking
235
+ * comes from pi.getThinkingLevel() at message_end time, not from the message itself -- AssistantMessage
236
+ * has no thinking field of its own, and the level can't have changed mid-message.
237
+ */
238
+ function assistantUsageMetrics(message: unknown, observedAt: number, taskId: string | null = null, thinking: string | null = null): MetricObservation[] {
235
239
  if (typeof message !== "object" || message === null || Array.isArray(message)) return [];
236
240
  const value = message as Record<string, unknown>;
237
241
  if (value["role"] !== "assistant" || typeof value["usage"] !== "object" || value["usage"] === null) return [];
@@ -239,7 +243,7 @@ function assistantUsageMetrics(message: unknown, observedAt: number, taskId: str
239
243
  const provider = typeof value["provider"] === "string" ? value["provider"] : "unknown";
240
244
  const model = typeof value["model"] === "string" ? value["model"] : "unknown";
241
245
  const scope = `${provider}:${model}`;
242
- const attributes = { provider, model, ...(taskId === null ? {} : { taskId }) };
246
+ const attributes = { provider, model, ...(taskId === null ? {} : { taskId }), ...(thinking === null || thinking.length === 0 ? {} : { thinking }) };
243
247
  const metrics: MetricObservation[] = [];
244
248
  for (const [field, metric] of [["input", "input-tokens"], ["output", "output-tokens"], ["cacheRead", "cache-read-tokens"], ["cacheWrite", "cache-write-tokens"]] as const) {
245
249
  const amount = usage[field];
@@ -796,7 +800,7 @@ export function registerJittorExtension(
796
800
  }
797
801
  lastAnthropicVertexResponse = {};
798
802
  }
799
- const metrics = assistantUsageMetrics(event.message, Date.now(), focusedTaskId);
803
+ const metrics = assistantUsageMetrics(event.message, Date.now(), focusedTaskId, pi.getThinkingLevel());
800
804
  if (metrics.length > 0) {
801
805
  const amount = (name: string): number => metrics.filter((metric) => metric.metric === name && typeof metric.value === "number").reduce((sum, metric) => sum + (metric.value ?? 0), 0);
802
806
  compactionTelemetry.observeProviderUsage({ input: amount("input-tokens"), output: amount("output-tokens"), cacheRead: amount("cache-read-tokens"), cacheWrite: amount("cache-write-tokens") });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/jittor",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Just-in-Time Token Optimizing Router for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
package/src/cli.ts CHANGED
@@ -573,7 +573,10 @@ function formatUsdAmount(amount: number): string {
573
573
  export function formatCostByTask(summary: TaskCostSummary): string {
574
574
  const lines = [
575
575
  `Cost by task: ${summary.entries.length.toLocaleString()} task(s)${summary.truncated ? " (query limit reached; totals are a lower bound)" : ""}`,
576
- ...summary.entries.map((entry) => `- ${humanField(entry.taskId)}: ${formatUsdAmount(entry.costUsd)} · ↑${entry.inputTokens.toLocaleString()} ↓${entry.outputTokens.toLocaleString()} R${entry.cacheReadTokens.toLocaleString()} W${entry.cacheWriteTokens.toLocaleString()}`),
576
+ ...summary.entries.flatMap((entry) => [
577
+ `- ${humanField(entry.taskId)}: ${formatUsdAmount(entry.costUsd)} · ↑${entry.inputTokens.toLocaleString()} ↓${entry.outputTokens.toLocaleString()} R${entry.cacheReadTokens.toLocaleString()} W${entry.cacheWriteTokens.toLocaleString()}`,
578
+ ...entry.byModel.map((model) => ` · ${humanField(model.provider)}/${humanField(model.model)} (${humanField(model.thinking)}): ${formatUsdAmount(model.costUsd)} · ↑${model.inputTokens.toLocaleString()} ↓${model.outputTokens.toLocaleString()} R${model.cacheReadTokens.toLocaleString()} W${model.cacheWriteTokens.toLocaleString()}`),
579
+ ]),
577
580
  `Unattributed spend (no task was focused): ${formatUsdAmount(summary.unattributedCostUsd)}`,
578
581
  ];
579
582
  return lines.join("\n");
@@ -1,5 +1,16 @@
1
1
  import type { StoredMetricObservation } from "./metric.ts";
2
2
 
3
+ export interface TaskCostBreakdown {
4
+ provider: string;
5
+ model: string;
6
+ thinking: string;
7
+ costUsd: number;
8
+ inputTokens: number;
9
+ outputTokens: number;
10
+ cacheReadTokens: number;
11
+ cacheWriteTokens: number;
12
+ }
13
+
3
14
  export interface TaskCostEntry {
4
15
  taskId: string;
5
16
  costUsd: number;
@@ -7,6 +18,7 @@ export interface TaskCostEntry {
7
18
  outputTokens: number;
8
19
  cacheReadTokens: number;
9
20
  cacheWriteTokens: number;
21
+ byModel: TaskCostBreakdown[];
10
22
  }
11
23
 
12
24
  export interface TaskCostSummary {
@@ -25,20 +37,35 @@ export interface TaskCostSummaryOptions {
25
37
 
26
38
  const TOKEN_METRICS = new Set(["input-tokens", "output-tokens", "cache-read-tokens", "cache-write-tokens"]);
27
39
 
40
+ function attributeText(attributes: Record<string, unknown>, key: string): string {
41
+ return typeof attributes[key] === "string" && attributes[key].length > 0 ? attributes[key] : "unknown";
42
+ }
43
+
28
44
  function entryFor(byTask: Map<string, TaskCostEntry>, taskId: string): TaskCostEntry {
29
45
  const existing = byTask.get(taskId);
30
46
  if (existing) return existing;
31
- const created: TaskCostEntry = { taskId, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
47
+ const created: TaskCostEntry = { taskId, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, byModel: [] };
32
48
  byTask.set(taskId, created);
33
49
  return created;
34
50
  }
35
51
 
52
+ function breakdownFor(byModel: Map<string, TaskCostBreakdown>, provider: string, model: string, thinking: string): TaskCostBreakdown {
53
+ const key = `${provider}\u0000${model}\u0000${thinking}`;
54
+ const existing = byModel.get(key);
55
+ if (existing) return existing;
56
+ const created: TaskCostBreakdown = { provider, model, thinking, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
57
+ byModel.set(key, created);
58
+ return created;
59
+ }
60
+
36
61
  /**
37
62
  * Groups already-recorded "pi" source cost/token metrics by the Papyrus task focused when each was
38
- * recorded (see the papyrus.task-focus.v1 real-time tagging in the extension), rather than by
39
- * provider/model identity. Rows recorded with nothing focused have no attributes.taskId and are
40
- * reported separately as unattributedCostUsd -- they are real spend, just not attributable to any
41
- * task, and must never be silently dropped or folded into an invented "unknown" task bucket.
63
+ * recorded (see the papyrus.task-focus.v1 real-time tagging in the extension), with a secondary
64
+ * breakdown per provider/model/thinking within each task. Rows recorded with nothing focused have
65
+ * no attributes.taskId and are reported separately as unattributedCostUsd -- they are real spend,
66
+ * just not attributable to any task, and must never be silently dropped or folded into an invented
67
+ * "unknown" task bucket. A row missing provider/model/thinking (recorded before that attribution
68
+ * existed) falls into an "unknown" breakdown bucket instead, since the task-level total is real.
42
69
  *
43
70
  * This queries a single bounded time window without per-task fairness partitioning (unlike
44
71
  * buildUsageGraph/buildCostGraph's per-scope fetch): a task's own working period is typically a far
@@ -48,6 +75,7 @@ function entryFor(byTask: Map<string, TaskCostEntry>, taskId: string): TaskCostE
48
75
  */
49
76
  export function buildTaskCostSummary(rows: StoredMetricObservation[], options: TaskCostSummaryOptions): TaskCostSummary {
50
77
  const byTask = new Map<string, TaskCostEntry>();
78
+ const byTaskModel = new Map<string, Map<string, TaskCostBreakdown>>();
51
79
  let unattributedCostUsd = 0;
52
80
  for (const row of rows) {
53
81
  if (row.source !== "pi" || typeof row.value !== "number" || !Number.isFinite(row.value) || row.value < 0) continue;
@@ -56,14 +84,21 @@ export function buildTaskCostSummary(rows: StoredMetricObservation[], options: T
56
84
  if (row.metric === "cost" && row.unit === "usd") {
57
85
  if (taskId === undefined) { unattributedCostUsd += row.value; continue; }
58
86
  entryFor(byTask, taskId).costUsd += row.value;
87
+ if (!byTaskModel.has(taskId)) byTaskModel.set(taskId, new Map());
88
+ breakdownFor(byTaskModel.get(taskId)!, attributeText(row.attributes, "provider"), attributeText(row.attributes, "model"), attributeText(row.attributes, "thinking")).costUsd += row.value;
59
89
  continue;
60
90
  }
61
91
  if (taskId === undefined || row.unit !== "tokens" || !TOKEN_METRICS.has(row.metric)) continue;
62
92
  const entry = entryFor(byTask, taskId);
63
- if (row.metric === "input-tokens") entry.inputTokens += row.value;
64
- else if (row.metric === "output-tokens") entry.outputTokens += row.value;
65
- else if (row.metric === "cache-read-tokens") entry.cacheReadTokens += row.value;
66
- else entry.cacheWriteTokens += row.value;
93
+ if (!byTaskModel.has(taskId)) byTaskModel.set(taskId, new Map());
94
+ const breakdown = breakdownFor(byTaskModel.get(taskId)!, attributeText(row.attributes, "provider"), attributeText(row.attributes, "model"), attributeText(row.attributes, "thinking"));
95
+ if (row.metric === "input-tokens") { entry.inputTokens += row.value; breakdown.inputTokens += row.value; }
96
+ else if (row.metric === "output-tokens") { entry.outputTokens += row.value; breakdown.outputTokens += row.value; }
97
+ else if (row.metric === "cache-read-tokens") { entry.cacheReadTokens += row.value; breakdown.cacheReadTokens += row.value; }
98
+ else { entry.cacheWriteTokens += row.value; breakdown.cacheWriteTokens += row.value; }
99
+ }
100
+ for (const [taskId, entry] of byTask) {
101
+ entry.byModel = [...(byTaskModel.get(taskId)?.values() ?? [])].sort((left, right) => right.costUsd - left.costUsd || left.model.localeCompare(right.model));
67
102
  }
68
103
  const entries = [...byTask.values()].sort((left, right) => right.costUsd - left.costUsd || left.taskId.localeCompare(right.taskId));
69
104
  return { since: options.since, until: options.until, entries, unattributedCostUsd, truncated: options.truncated === true };