@danypops/pi-jittor 0.3.1 → 0.4.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/extension/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
loadOpenAiTextTokenCounter,
|
|
13
13
|
MAX_DYNAMIC_ROUTES,
|
|
14
14
|
type MetricObservation,
|
|
15
|
+
MILLISECONDS_PER_DAY,
|
|
15
16
|
type ModelCandidate,
|
|
16
17
|
type ModelTaskDomain,
|
|
17
18
|
type ModelTaskType,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
validateTaskFocusEvent,
|
|
34
35
|
} from "@danypops/jittor";
|
|
35
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { showCacheEconomicsPanel } from "./observability/cache-economics-view.ts";
|
|
36
38
|
import {
|
|
37
39
|
basePromptSegment,
|
|
38
40
|
buildBasePromptItems,
|
|
@@ -161,6 +163,23 @@ interface PiRouteModel {
|
|
|
161
163
|
cost?: { input?: number; output?: number };
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the `/scoped-models`
|
|
168
|
+
* command shows) is the authority for which models a session may actually use -- e.g. a "work"
|
|
169
|
+
* profile scoped to one provider/model set versus a "personal" profile scoped to a different one.
|
|
170
|
+
* `ctx.modelRegistry.getAvailable()` enumerates every authenticated model on the host regardless of
|
|
171
|
+
* that restriction (Pi's own docs warn against using it for a model picker for exactly this reason:
|
|
172
|
+
* "instead of enumerating the whole catalogue via ctx.modelRegistry.getAvailable()"). Every Jittor
|
|
173
|
+
* call site that builds a candidate/route set for routing or ranking must prefer the scoped set
|
|
174
|
+
* when one is configured, or a scoped session keeps seeing -- and can be automatically routed onto
|
|
175
|
+
* -- another profile's models. An empty scopedModels list means "no scoping configured" (matching
|
|
176
|
+
* Pi's own semantics), not "scoped to nothing", so it still falls back to the full catalog.
|
|
177
|
+
*/
|
|
178
|
+
function scopedOrAvailableModels(ctx: ExtensionContext): PiRouteModel[] {
|
|
179
|
+
if (ctx.scopedModels.length > 0) return ctx.scopedModels.map((entry) => entry.model as PiRouteModel);
|
|
180
|
+
return ctx.modelRegistry.getAvailable() as PiRouteModel[];
|
|
181
|
+
}
|
|
182
|
+
|
|
164
183
|
const THINKING_DESCENDING = ["max", "xhigh", "high", "medium", "low", "minimal", "off"] as const;
|
|
165
184
|
|
|
166
185
|
function supportsThinking(model: PiRouteModel, level: string): boolean {
|
|
@@ -237,7 +256,7 @@ async function syncAvailableRoutes(pi: ExtensionAPI, client: JittorExtensionClie
|
|
|
237
256
|
await client.call("router.available_routes", { routes: [], session_id, ...secret });
|
|
238
257
|
return;
|
|
239
258
|
}
|
|
240
|
-
const models = ctx
|
|
259
|
+
const models = scopedOrAvailableModels(ctx);
|
|
241
260
|
const routes = routesFromPi(models, ctx.model as PiRouteModel, pi.getThinkingLevel());
|
|
242
261
|
await client.call("router.available_routes", { routes, session_id, ...secret });
|
|
243
262
|
}
|
|
@@ -292,16 +311,24 @@ async function applyDecision(
|
|
|
292
311
|
);
|
|
293
312
|
}
|
|
294
313
|
|
|
314
|
+
let assistantUsageRunSequence = 0;
|
|
315
|
+
|
|
295
316
|
/**
|
|
296
317
|
* taskId, when a Papyrus task is focused, tags the metric for cost-per-task correlation. thinking
|
|
297
318
|
* comes from pi.getThinkingLevel() at message_end time, not from the message itself -- AssistantMessage
|
|
298
|
-
* has no thinking field of its own, and the level can't have changed mid-message.
|
|
319
|
+
* has no thinking field of its own, and the level can't have changed mid-message. sessionId tags every
|
|
320
|
+
* row so cache economics (see @danypops/jittor's cache-economics.ts) can correlate a cache write back
|
|
321
|
+
* to this same session's own context-prefix reset evidence, without ever widening scope by provider/model.
|
|
322
|
+
* runId ties every metric emitted for this one turn together (a counter, not just observedAt, since
|
|
323
|
+
* two turns can share a millisecond) so cache economics can resolve per-turn (e.g. tiered) catalog
|
|
324
|
+
* pricing against this turn's own real size instead of a blended sum across a whole query window.
|
|
299
325
|
*/
|
|
300
326
|
function assistantUsageMetrics(
|
|
301
327
|
message: unknown,
|
|
302
328
|
observedAt: number,
|
|
303
329
|
taskId: string | null = null,
|
|
304
330
|
thinking: string | null = null,
|
|
331
|
+
sessionId: string | null = null,
|
|
305
332
|
): MetricObservation[] {
|
|
306
333
|
if (typeof message !== "object" || message === null || Array.isArray(message)) return [];
|
|
307
334
|
const value = message as Record<string, unknown>;
|
|
@@ -313,11 +340,14 @@ function assistantUsageMetrics(
|
|
|
313
340
|
const provider = typeof value.provider === "string" ? value.provider : "unknown";
|
|
314
341
|
const model = typeof value.model === "string" ? value.model : "unknown";
|
|
315
342
|
const scope = `${provider}:${model}`;
|
|
343
|
+
const runId = `pi-usage-${metricObservedAt}-${++assistantUsageRunSequence}`;
|
|
316
344
|
const attributes = {
|
|
317
345
|
provider,
|
|
318
346
|
model,
|
|
347
|
+
runId,
|
|
319
348
|
...(taskId === null ? {} : { taskId }),
|
|
320
349
|
...(thinking === null || thinking.length === 0 ? {} : { thinking }),
|
|
350
|
+
...(sessionId === null || sessionId.length === 0 ? {} : { sessionId }),
|
|
321
351
|
};
|
|
322
352
|
const metrics: MetricObservation[] = [];
|
|
323
353
|
for (const [field, metric, tokenScope] of [
|
|
@@ -348,9 +378,23 @@ function assistantUsageMetrics(
|
|
|
348
378
|
},
|
|
349
379
|
});
|
|
350
380
|
}
|
|
351
|
-
const
|
|
381
|
+
const costBreakdown = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>) : undefined;
|
|
382
|
+
const cost = costBreakdown?.total;
|
|
352
383
|
if (typeof cost === "number" && Number.isFinite(cost))
|
|
353
384
|
metrics.push({ source: "pi", scope, metric: "cost", value: cost, unit: "usd", observedAt: metricObservedAt, attributes });
|
|
385
|
+
// Itemized provider-reported cost, when the provider breaks it out -- the real dollar figures cache
|
|
386
|
+
// economics needs (see cache-economics.ts) instead of ever re-deriving them from catalog prices when
|
|
387
|
+
// the provider already told us. Never fabricated: omitted entirely when a field is absent.
|
|
388
|
+
for (const [field, metric] of [
|
|
389
|
+
["input", "input-cost"],
|
|
390
|
+
["output", "output-cost"],
|
|
391
|
+
["cacheRead", "cache-read-cost"],
|
|
392
|
+
["cacheWrite", "cache-write-cost"],
|
|
393
|
+
] as const satisfies ReadonlyArray<readonly [string, string]>) {
|
|
394
|
+
const amount = costBreakdown?.[field];
|
|
395
|
+
if (typeof amount === "number" && Number.isFinite(amount))
|
|
396
|
+
metrics.push({ source: "pi", scope, metric, value: amount, unit: "usd", observedAt: metricObservedAt, attributes });
|
|
397
|
+
}
|
|
354
398
|
return metrics;
|
|
355
399
|
}
|
|
356
400
|
|
|
@@ -529,7 +573,7 @@ export function registerJittorExtension(
|
|
|
529
573
|
};
|
|
530
574
|
|
|
531
575
|
pi.registerCommand("jittor", {
|
|
532
|
-
description: "Jittor settings, routing status, benchmarks, and Codex recovery controls",
|
|
576
|
+
description: "Jittor settings, routing status, benchmarks, cache economics, and Codex recovery controls",
|
|
533
577
|
handler: async (args, ctx) => {
|
|
534
578
|
const action = args.trim().toLowerCase();
|
|
535
579
|
if (action === "" || action === "settings") {
|
|
@@ -569,7 +613,7 @@ export function registerJittorExtension(
|
|
|
569
613
|
ctx.ui.notify("Usage: /jittor benchmarks [coding|general] [research|planning|general]", "warning");
|
|
570
614
|
return;
|
|
571
615
|
}
|
|
572
|
-
const candidates = benchmarkCandidatesFromPi(ctx
|
|
616
|
+
const candidates = benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel());
|
|
573
617
|
await showBenchmarkPanel(
|
|
574
618
|
ctx,
|
|
575
619
|
client,
|
|
@@ -580,6 +624,10 @@ export function registerJittorExtension(
|
|
|
580
624
|
);
|
|
581
625
|
return;
|
|
582
626
|
}
|
|
627
|
+
if (action === "cache") {
|
|
628
|
+
await showCacheEconomicsPanel(ctx, client, 7 * MILLISECONDS_PER_DAY);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
583
631
|
if (action === "outcome accepted" || action === "outcome rejected") {
|
|
584
632
|
const explicitOutcome = action.endsWith("accepted") ? ("accepted" as const) : ("rejected" as const);
|
|
585
633
|
const outcomeMetric = localRunTelemetry.explicitOutcomeMetric(explicitOutcome);
|
|
@@ -929,7 +977,13 @@ export function registerJittorExtension(
|
|
|
929
977
|
event.message.errorMessage,
|
|
930
978
|
);
|
|
931
979
|
}
|
|
932
|
-
const metrics = assistantUsageMetrics(
|
|
980
|
+
const metrics = assistantUsageMetrics(
|
|
981
|
+
event.message,
|
|
982
|
+
Date.now(),
|
|
983
|
+
focusedTaskId,
|
|
984
|
+
pi.getThinkingLevel(),
|
|
985
|
+
ctx.sessionManager.getSessionId(),
|
|
986
|
+
);
|
|
933
987
|
if (metrics.length > 0) {
|
|
934
988
|
const amount = (name: string): number =>
|
|
935
989
|
metrics
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CacheEconomicsAggregateTotals,
|
|
3
|
+
CacheEconomicsModelSummary,
|
|
4
|
+
CacheEconomicsSummary,
|
|
5
|
+
CacheEconomicsTaskSummary,
|
|
6
|
+
} from "@danypops/jittor";
|
|
7
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { BorderedSelectPanel, type TextMeasure } from "malevich-tui-components";
|
|
10
|
+
|
|
11
|
+
export interface CacheEconomicsPanelTheme {
|
|
12
|
+
fg(color: string, text: string): string;
|
|
13
|
+
bold(text: string): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type CacheEconomicsPanelAction = "refresh" | "close";
|
|
17
|
+
|
|
18
|
+
const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
|
|
19
|
+
|
|
20
|
+
export interface CacheEconomicsPanelClient {
|
|
21
|
+
call(operation: string, input: unknown): Promise<any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatUsd(amount: number): string {
|
|
25
|
+
return `$${amount.toFixed(Math.abs(amount) < 0.01 && amount !== 0 ? 4 : 2)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function costField(amountUsd: number | null, basis: "provider-reported" | "catalog-estimate" | "unknown"): string {
|
|
29
|
+
if (amountUsd === null) return "unknown";
|
|
30
|
+
return basis === "catalog-estimate" ? `${formatUsd(amountUsd)} (est.)` : formatUsd(amountUsd);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function aggregateFields(totals: CacheEconomicsAggregateTotals): string {
|
|
34
|
+
const payback = totals.paybackAchieved === null ? "n/a" : totals.paybackAchieved ? "yes" : "not yet";
|
|
35
|
+
return [
|
|
36
|
+
`read ${totals.cacheReadTokens.toLocaleString()} tok (${costField(totals.cacheReadCostUsd, totals.cacheReadCostBasis)})`,
|
|
37
|
+
`write ${totals.cacheWriteTokens.toLocaleString()} tok (${costField(totals.cacheWriteCostUsd, totals.cacheWriteCostBasis)})`,
|
|
38
|
+
`savings ${totals.savingsUsd === null ? "unknown" : formatUsd(totals.savingsUsd)}`,
|
|
39
|
+
`payback ${payback}`,
|
|
40
|
+
].join(" · ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function freshnessSuffix(catalogFreshness: "fresh" | "stale" | null): string {
|
|
44
|
+
return catalogFreshness === "stale" ? " -- stale catalog snapshot used for the estimate(s) above" : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function modelLine(model: CacheEconomicsModelSummary): string {
|
|
48
|
+
return `${model.provider}/${model.model}: ${aggregateFields(model)}${freshnessSuffix(model.catalogFreshness)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function taskLine(task: CacheEconomicsTaskSummary): string {
|
|
52
|
+
return `${task.taskId}: ${aggregateFields(task)}${freshnessSuffix(task.catalogFreshness)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Plain multi-line text, shared by TUI notify and non-TUI notify -- a full interactive panel is deferred; this already satisfies "bounded query plus Pi presentation" without a new widget. */
|
|
56
|
+
export function renderCacheEconomicsView(summary: CacheEconomicsSummary): string[] {
|
|
57
|
+
const lines = [
|
|
58
|
+
`Cache economics (${new Date(summary.since).toISOString().slice(0, 10)} .. ${new Date(summary.until).toISOString().slice(0, 10)})${summary.truncated ? " -- query limit reached, totals are a lower bound" : ""}`,
|
|
59
|
+
];
|
|
60
|
+
if (summary.models.length === 0) lines.push("No cache activity recorded in this window.");
|
|
61
|
+
else lines.push(...summary.models.map((model) => `- ${modelLine(model)}`));
|
|
62
|
+
if (summary.tasks.length > 0) {
|
|
63
|
+
lines.push(`By task: ${summary.tasks.length}`);
|
|
64
|
+
lines.push(...summary.tasks.map((task) => `- ${taskLine(task)}`));
|
|
65
|
+
}
|
|
66
|
+
const unattributed = summary.unattributedCacheActivity;
|
|
67
|
+
if (unattributed.cacheReadTokens > 0 || unattributed.cacheWriteTokens > 0) {
|
|
68
|
+
lines.push(
|
|
69
|
+
`Unattributed (no task focused): read ${unattributed.cacheReadTokens.toLocaleString()} tok (${costField(unattributed.cacheReadCostUsd, unattributed.cacheReadCostBasis)}) · write ${unattributed.cacheWriteTokens.toLocaleString()} tok (${costField(unattributed.cacheWriteCostUsd, unattributed.cacheWriteCostBasis)})`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (summary.stablePrefixChurn.length > 0) {
|
|
73
|
+
lines.push(`Stable-prefix churn (${summary.stablePrefixChurn.length} snapshot(s), oldest first):`);
|
|
74
|
+
for (const point of summary.stablePrefixChurn) {
|
|
75
|
+
lines.push(
|
|
76
|
+
`- ${new Date(point.observedAt).toISOString()} session ${point.sessionId}: ${point.stablePrefixTokens.toLocaleString()} tok${point.resetReason === null ? "" : ` (${point.resetReason} reset)`}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (summary.missedOpportunities.length > 0) {
|
|
81
|
+
lines.push(`Candidate missed-cache opportunities: ${summary.missedOpportunities.length}`);
|
|
82
|
+
for (const candidate of summary.missedOpportunities.slice(0, 10)) {
|
|
83
|
+
lines.push(
|
|
84
|
+
`- session ${candidate.sessionId}: ${candidate.resetReason} reset, then ${candidate.cacheWriteTokens.toLocaleString()} cache-write tok${candidate.cacheWriteCostUsd === null ? "" : ` (${formatUsd(candidate.cacheWriteCostUsd)})`}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function showCacheEconomicsView(
|
|
92
|
+
ctx: ExtensionCommandContext,
|
|
93
|
+
client: CacheEconomicsPanelClient,
|
|
94
|
+
windowMs: number,
|
|
95
|
+
now: () => number = Date.now,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const until = now();
|
|
98
|
+
const since = Math.max(0, until - windowMs);
|
|
99
|
+
const summary = (await client.call("cache.economics", { since, until })) as CacheEconomicsSummary;
|
|
100
|
+
ctx.ui.notify(renderCacheEconomicsView(summary).join("\n"), "info");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The same content as renderCacheEconomicsView, wrapped in a titled, bordered, scrollable frame -- mirrors optimization/model-selection-panel.ts's renderBenchmarkView/BorderedSelectPanel pattern. */
|
|
104
|
+
export function renderCacheEconomicsPanel(summary: CacheEconomicsSummary, width: number, theme: CacheEconomicsPanelTheme): string[] {
|
|
105
|
+
const safeWidth = Math.max(1, width);
|
|
106
|
+
const lines = renderCacheEconomicsView(summary);
|
|
107
|
+
const content = {
|
|
108
|
+
invalidate: () => {},
|
|
109
|
+
render: (availableWidth: number): string[] => lines.map((line) => truncateToWidth(line, availableWidth, "…")),
|
|
110
|
+
};
|
|
111
|
+
return new BorderedSelectPanel({
|
|
112
|
+
title: "Jittor Cache Economics",
|
|
113
|
+
list: content,
|
|
114
|
+
helpText: "r refresh · Esc close",
|
|
115
|
+
theme: {
|
|
116
|
+
border: (text) => theme.fg("borderMuted", text),
|
|
117
|
+
title: theme.bold,
|
|
118
|
+
help: (text) => theme.fg("dim", text),
|
|
119
|
+
},
|
|
120
|
+
measure: hostTextMeasure,
|
|
121
|
+
}).render(safeWidth);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Interactive scrollable panel for /jittor cache, mirroring showBenchmarkPanel: a plain notify
|
|
126
|
+
* outside TUI mode (unchanged from showCacheEconomicsView's own behavior), an interactive
|
|
127
|
+
* BorderedSelectPanel with an 'r' refresh keybinding inside it.
|
|
128
|
+
*/
|
|
129
|
+
export async function showCacheEconomicsPanel(
|
|
130
|
+
ctx: ExtensionCommandContext,
|
|
131
|
+
client: CacheEconomicsPanelClient,
|
|
132
|
+
windowMs: number,
|
|
133
|
+
now: () => number = Date.now,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
for (;;) {
|
|
136
|
+
const until = now();
|
|
137
|
+
const since = Math.max(0, until - windowMs);
|
|
138
|
+
const summary = (await client.call("cache.economics", { since, until })) as CacheEconomicsSummary;
|
|
139
|
+
if (ctx.mode !== "tui") {
|
|
140
|
+
ctx.ui.notify(renderCacheEconomicsView(summary).join("\n"), "info");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const action = await ctx.ui.custom<CacheEconomicsPanelAction>((_tui, theme, _keybindings, done) => ({
|
|
144
|
+
invalidate() {},
|
|
145
|
+
render(width: number): string[] {
|
|
146
|
+
return renderCacheEconomicsPanel(summary, width, theme);
|
|
147
|
+
},
|
|
148
|
+
handleInput(data: string): void {
|
|
149
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
|
|
150
|
+
else if (data === "r") done("refresh");
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
if (!action || action === "close") return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -50,6 +50,7 @@ const OPERATION_RETRY_MODE = {
|
|
|
50
50
|
"router.clear_override": "once",
|
|
51
51
|
"router.current_route": "once",
|
|
52
52
|
"router.available_routes": "once",
|
|
53
|
+
"cache.economics": "retry",
|
|
53
54
|
} as const satisfies Record<OperationName, "retry" | "once">;
|
|
54
55
|
|
|
55
56
|
export function operationRetryMode(operation: OperationName): "retry" | "once" {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-jittor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension for Jittor token and context observability with optimization controls backed by the @danypops/jittor daemon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package", "llm-router", "token-budget"],
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"@danypops/vehicle-client": "^0.5.1",
|
|
16
16
|
"@danypops/vehicle-core": "^0.12.1",
|
|
17
17
|
"@danypops/vehicle-server": "^0.17.0",
|
|
18
|
-
"@danypops/jittor": "^0.
|
|
18
|
+
"@danypops/jittor": "^0.18.0",
|
|
19
19
|
"malevich-tui-components": "^0.16.1"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|