@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 +1 -1
- package/extension/src/index.ts +8 -4
- package/package.json +1 -1
- package/src/cli.ts +4 -1
- package/src/domain/task-cost.ts +44 -9
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
|
|
package/extension/src/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
234
|
-
|
|
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
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.
|
|
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");
|
package/src/domain/task-cost.ts
CHANGED
|
@@ -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),
|
|
39
|
-
* provider/model
|
|
40
|
-
* reported separately as unattributedCostUsd -- they are real spend,
|
|
41
|
-
* task, and must never be silently dropped or folded into an invented
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
else entry.
|
|
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 };
|