@danypops/jittor 0.5.1 → 0.7.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 +62 -7
- package/docs/BENCHMARK_SOURCES.md +32 -0
- package/docs/OUTPUT_CHANNELS.md +31 -0
- package/docs/PROVIDER_RESEARCH.md +57 -0
- package/extension/src/benchmark-tui.ts +105 -0
- package/extension/src/footer.ts +68 -12
- package/extension/src/index.ts +263 -36
- package/extension/src/tui.ts +61 -9
- package/extension/src/usage.ts +165 -47
- package/package.json +5 -1
- package/src/adapters/metric-benchmark-store.ts +99 -0
- package/src/adapters/openrouter-benchmark-index-source.ts +94 -0
- package/src/adapters/openrouter-benchmark-source.ts +109 -0
- package/src/adapters/sqlite-metric-store.ts +43 -2
- package/src/cli.ts +660 -9
- package/src/client.ts +12 -44
- package/src/constants.ts +69 -5
- package/src/daemon.ts +65 -43
- package/src/db.ts +13 -30
- package/src/domain/benchmark.ts +264 -0
- package/src/domain/context-telemetry.ts +31 -0
- package/src/domain/metric.ts +46 -5
- package/src/domain/model-observation.ts +203 -0
- package/src/domain/model-ranking-service.ts +41 -0
- package/src/domain/model-ranking.ts +232 -0
- package/src/domain/task-cost.ts +70 -0
- package/src/domain/task-focus.ts +65 -0
- package/src/domain/usage.ts +134 -33
- package/src/log.ts +28 -0
- package/src/ports/benchmark-controller.ts +11 -0
- package/src/ports/benchmark-source.ts +7 -0
- package/src/ports/benchmark-store.ts +7 -0
- package/src/ports/metric-store.ts +30 -0
- package/src/ports/router-controller.ts +1 -0
- package/src/providers/anthropic-contracts.ts +127 -0
- package/src/providers/google-adc-auth.ts +63 -0
- package/src/providers/google-vertex-budget-contracts.ts +181 -0
- package/src/providers/google-vertex-budget.ts +127 -0
- package/src/providers/google-vertex-contracts.ts +116 -0
- package/src/providers/telemetry-sources.ts +35 -0
- package/src/router.ts +12 -0
- package/src/service.ts +132 -17
- package/src/state.ts +31 -57
- package/src/version.ts +2 -14
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { StoredMetricObservation } from "./metric.ts";
|
|
2
|
+
|
|
3
|
+
export interface TaskCostEntry {
|
|
4
|
+
taskId: string;
|
|
5
|
+
costUsd: number;
|
|
6
|
+
inputTokens: number;
|
|
7
|
+
outputTokens: number;
|
|
8
|
+
cacheReadTokens: number;
|
|
9
|
+
cacheWriteTokens: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskCostSummary {
|
|
13
|
+
since: number;
|
|
14
|
+
until: number;
|
|
15
|
+
entries: TaskCostEntry[];
|
|
16
|
+
unattributedCostUsd: number;
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskCostSummaryOptions {
|
|
21
|
+
since: number;
|
|
22
|
+
until: number;
|
|
23
|
+
truncated?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TOKEN_METRICS = new Set(["input-tokens", "output-tokens", "cache-read-tokens", "cache-write-tokens"]);
|
|
27
|
+
|
|
28
|
+
function entryFor(byTask: Map<string, TaskCostEntry>, taskId: string): TaskCostEntry {
|
|
29
|
+
const existing = byTask.get(taskId);
|
|
30
|
+
if (existing) return existing;
|
|
31
|
+
const created: TaskCostEntry = { taskId, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
32
|
+
byTask.set(taskId, created);
|
|
33
|
+
return created;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 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.
|
|
42
|
+
*
|
|
43
|
+
* This queries a single bounded time window without per-task fairness partitioning (unlike
|
|
44
|
+
* buildUsageGraph/buildCostGraph's per-scope fetch): a task's own working period is typically a far
|
|
45
|
+
* narrower, more targeted window than "the whole usage dashboard, any period", so one task's volume
|
|
46
|
+
* crowding another out of the same query is a much smaller risk here. If this does become a problem
|
|
47
|
+
* in practice, the same per-distinct-value fetch strategy applies, keyed by attributes.taskId.
|
|
48
|
+
*/
|
|
49
|
+
export function buildTaskCostSummary(rows: StoredMetricObservation[], options: TaskCostSummaryOptions): TaskCostSummary {
|
|
50
|
+
const byTask = new Map<string, TaskCostEntry>();
|
|
51
|
+
let unattributedCostUsd = 0;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
if (row.source !== "pi" || typeof row.value !== "number" || !Number.isFinite(row.value) || row.value < 0) continue;
|
|
54
|
+
if (row.observedAt < options.since || row.observedAt > options.until) continue;
|
|
55
|
+
const taskId = typeof row.attributes["taskId"] === "string" ? row.attributes["taskId"] : undefined;
|
|
56
|
+
if (row.metric === "cost" && row.unit === "usd") {
|
|
57
|
+
if (taskId === undefined) { unattributedCostUsd += row.value; continue; }
|
|
58
|
+
entryFor(byTask, taskId).costUsd += row.value;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (taskId === undefined || row.unit !== "tokens" || !TOKEN_METRICS.has(row.metric)) continue;
|
|
62
|
+
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;
|
|
67
|
+
}
|
|
68
|
+
const entries = [...byTask.values()].sort((left, right) => right.costUsd - left.costUsd || left.taskId.localeCompare(right.taskId));
|
|
69
|
+
return { since: options.since, until: options.until, entries, unattributedCostUsd, truncated: options.truncated === true };
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { PAPYRUS_TASK_FOCUS_SCHEMA, TASK_FOCUS_EVENT_MAX_AGE_MS, TASK_FOCUS_ID_MAX_LENGTH } from "../constants.ts";
|
|
2
|
+
|
|
3
|
+
export type TaskFocusStatus = "focused" | "paused" | "unpaused" | "cleared";
|
|
4
|
+
|
|
5
|
+
export interface TaskFocusEvent {
|
|
6
|
+
schema: typeof PAPYRUS_TASK_FOCUS_SCHEMA;
|
|
7
|
+
taskId: string | null;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
status: TaskFocusStatus;
|
|
10
|
+
observedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TOP_LEVEL_FIELDS = new Set(["schema", "taskId", "sessionId", "status", "observedAt"]);
|
|
14
|
+
const STATUSES = new Set<string>(["focused", "paused", "unpaused", "cleared"]);
|
|
15
|
+
|
|
16
|
+
function record(value: unknown): Record<string, unknown> {
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("task-focus event must be an object");
|
|
18
|
+
const result = value as Record<string, unknown>;
|
|
19
|
+
for (const key of Object.keys(result)) if (!TOP_LEVEL_FIELDS.has(key)) throw new Error(`task-focus event contains unexpected field: ${key}`);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function boundedId(value: unknown, name: string): string {
|
|
24
|
+
if (typeof value !== "string" || value.length === 0 || value.length > TASK_FOCUS_ID_MAX_LENGTH) throw new Error(`${name} must be a non-empty bounded string`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates the papyrus.task-focus.v1 shared-bus payload independently of whatever Papyrus itself
|
|
30
|
+
* guarantees -- this is a cross-extension trust boundary, so Jittor never trusts an unvalidated
|
|
31
|
+
* shape. Fails closed (throws) on schema drift, an unrecognized status, or a stale observation,
|
|
32
|
+
* mirroring validatePapyrusContextInjection's pattern for the same reason: a malformed or
|
|
33
|
+
* out-of-order cross-extension event must never silently corrupt Jittor's own state.
|
|
34
|
+
*/
|
|
35
|
+
export function validateTaskFocusEvent(value: unknown, now = Date.now()): TaskFocusEvent {
|
|
36
|
+
const input = record(value);
|
|
37
|
+
if (input["schema"] !== PAPYRUS_TASK_FOCUS_SCHEMA) throw new Error("task-focus event schema is not supported");
|
|
38
|
+
const status = input["status"];
|
|
39
|
+
if (typeof status !== "string" || !STATUSES.has(status)) throw new Error("task-focus event status is not supported");
|
|
40
|
+
const observedAt = input["observedAt"];
|
|
41
|
+
if (typeof observedAt !== "number" || !Number.isSafeInteger(observedAt) || observedAt < 0) throw new Error("task-focus event observedAt must be a non-negative integer");
|
|
42
|
+
if (Math.abs(now - observedAt) > TASK_FOCUS_EVENT_MAX_AGE_MS) throw new Error("task-focus event is stale");
|
|
43
|
+
const rawTaskId = input["taskId"];
|
|
44
|
+
const taskId = rawTaskId === null ? null : boundedId(rawTaskId, "taskId");
|
|
45
|
+
if (taskId === null && status !== "cleared") throw new Error(`task-focus event of status "${status}" requires a taskId`);
|
|
46
|
+
const rawSessionId = input["sessionId"];
|
|
47
|
+
const sessionId = rawSessionId === undefined ? undefined : boundedId(rawSessionId, "sessionId");
|
|
48
|
+
return {
|
|
49
|
+
schema: PAPYRUS_TASK_FOCUS_SCHEMA,
|
|
50
|
+
taskId,
|
|
51
|
+
status: status as TaskFocusStatus,
|
|
52
|
+
observedAt,
|
|
53
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Applies a validated event to the currently tracked focused task id. "paused" and "cleared" both
|
|
59
|
+
* mean "no task is actively being worked on right now" for cost-attribution purposes, even though
|
|
60
|
+
* Papyrus itself keeps a paused task's focus state around for later resumption -- Jittor only
|
|
61
|
+
* cares about whether to keep tagging new metrics, not about Papyrus's own pause bookkeeping.
|
|
62
|
+
*/
|
|
63
|
+
export function applyTaskFocusEvent(event: TaskFocusEvent): string | null {
|
|
64
|
+
return event.status === "focused" || event.status === "unpaused" ? event.taskId : null;
|
|
65
|
+
}
|
package/src/domain/usage.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { MAX_USAGE_BUCKETS, MILLISECONDS_PER_DAY, MILLISECONDS_PER_HOUR } from "../constants.ts";
|
|
2
|
-
import type { StoredMetricObservation } from "./metric.ts";
|
|
3
2
|
|
|
4
3
|
export const USAGE_PERIODS = [
|
|
5
4
|
{ id: "hourly", label: "Hourly", windowMs: MILLISECONDS_PER_HOUR, bucketCount: 12 },
|
|
6
5
|
{ id: "daily", label: "Daily", windowMs: MILLISECONDS_PER_DAY, bucketCount: 24 },
|
|
7
6
|
{ id: "weekly", label: "Weekly", windowMs: 7 * MILLISECONDS_PER_DAY, bucketCount: 28 },
|
|
8
7
|
{ id: "monthly", label: "Monthly", windowMs: 30 * MILLISECONDS_PER_DAY, bucketCount: 30 },
|
|
8
|
+
{ id: "quarterly", label: "Quarterly", windowMs: 90 * MILLISECONDS_PER_DAY, bucketCount: 90 },
|
|
9
9
|
] as const;
|
|
10
10
|
export type UsagePeriod = typeof USAGE_PERIODS[number]["id"];
|
|
11
11
|
|
|
@@ -13,6 +13,52 @@ export function usagePeriod(period: UsagePeriod): typeof USAGE_PERIODS[number] {
|
|
|
13
13
|
return USAGE_PERIODS.find((candidate) => candidate.id === period)!;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function usagePeriodStart(period: UsagePeriod, now: number): number {
|
|
17
|
+
return Math.max(0, now - usagePeriod(period).windowMs);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The bucket boundaries a chart period actually renders. Shared, byte-identical, by both sides of
|
|
22
|
+
* the wire: the daemon computes the SAME window from the same (period, now[, bucketCount]) before
|
|
23
|
+
* running its SQL-side GROUP BY, so a pre-aggregated bucket sum always lands in exactly the bucket
|
|
24
|
+
* the chart expects -- there is no second, independent bucketing pass left to disagree with it.
|
|
25
|
+
*/
|
|
26
|
+
export interface UsageBucketWindow {
|
|
27
|
+
start: number;
|
|
28
|
+
end: number;
|
|
29
|
+
bucketCount: number;
|
|
30
|
+
bucketSizeMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveUsageWindow(period: UsagePeriod, now: number, bucketCount?: number): UsageBucketWindow {
|
|
34
|
+
const end = now;
|
|
35
|
+
const start = usagePeriodStart(period, end);
|
|
36
|
+
const requested = bucketCount ?? usagePeriod(period).bucketCount;
|
|
37
|
+
const count = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requested)));
|
|
38
|
+
const bucketSizeMs = Math.max(1, (end - start) / count);
|
|
39
|
+
return { start, end, bucketCount: count, bucketSizeMs };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mirrors the SQL-side `MIN(CAST((observed_at - start) / bucketSizeMs AS INTEGER), bucketCount - 1)` grouping exactly. */
|
|
43
|
+
export function usageBucketIndex(observedAt: number, window: UsageBucketWindow): number {
|
|
44
|
+
return Math.min(window.bucketCount - 1, Math.max(0, Math.floor((observedAt - window.start) / window.bucketSizeMs)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One already-summed (scope, metric, bucket) cell -- what the daemon's SQL-side GROUP BY returns,
|
|
49
|
+
* replacing a bounded-but-still-truncatable fetch of raw per-observation rows. Result size scales
|
|
50
|
+
* with (distinct scopes x distinct metrics x bucket count), never with raw event count, so a
|
|
51
|
+
* heavy scope's full history is represented exactly regardless of how many observations it made
|
|
52
|
+
* (a real incident: a scope with 49,270 rows in a week had its "weekly" chart built from the 250
|
|
53
|
+
* most recent rows alone -- 3.3 minutes of real activity mislabeled as a full week).
|
|
54
|
+
*/
|
|
55
|
+
export interface UsageAggregateRow {
|
|
56
|
+
scope: string;
|
|
57
|
+
metric: string;
|
|
58
|
+
bucketIndex: number;
|
|
59
|
+
sum: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
export interface UsageSeries {
|
|
17
63
|
key: string;
|
|
18
64
|
provider: string;
|
|
@@ -47,8 +93,6 @@ export interface UsageGraph {
|
|
|
47
93
|
|
|
48
94
|
export interface UsageGraphOptions {
|
|
49
95
|
period: UsagePeriod;
|
|
50
|
-
now: number;
|
|
51
|
-
bucketCount?: number;
|
|
52
96
|
truncated?: boolean;
|
|
53
97
|
}
|
|
54
98
|
|
|
@@ -59,54 +103,52 @@ const BREAKDOWN_KEYS = {
|
|
|
59
103
|
"cache-write-tokens": "cacheWrite",
|
|
60
104
|
} as const satisfies Record<string, keyof UsageBreakdown>;
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const provider =
|
|
71
|
-
const model =
|
|
106
|
+
/**
|
|
107
|
+
* Derives provider/model from `scope` alone (`"${provider}:${model}"`, see assistantUsageMetrics),
|
|
108
|
+
* rather than needing a row's `attributes.provider`/`attributes.model` -- source "pi" always
|
|
109
|
+
* constructs `scope` from those exact same two values, so the two are equivalent for this source,
|
|
110
|
+
* and an aggregated bucket sum has no per-row attributes left to read anyway.
|
|
111
|
+
*/
|
|
112
|
+
export function identity(scope: string): { key: string; provider: string; model: string } {
|
|
113
|
+
const separator = scope.indexOf(":");
|
|
114
|
+
const provider = separator >= 0 ? scope.slice(0, separator) : scope;
|
|
115
|
+
const model = separator >= 0 ? scope.slice(separator + 1) : "unknown";
|
|
72
116
|
return { key: `${provider}/${model}`, provider, model };
|
|
73
117
|
}
|
|
74
118
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const bucketCount = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requestedBuckets)));
|
|
80
|
-
const bucketSize = Math.max(1, (end - start) / bucketCount);
|
|
81
|
-
const buckets: UsageBucket[] = Array.from({ length: bucketCount }, (_, index) => ({
|
|
82
|
-
start: start + index * bucketSize,
|
|
83
|
-
end: index === bucketCount - 1 ? end : start + (index + 1) * bucketSize,
|
|
119
|
+
function emptyBuckets(window: UsageBucketWindow): { start: number; end: number; total: number; series: Record<string, number> }[] {
|
|
120
|
+
return Array.from({ length: window.bucketCount }, (_, index) => ({
|
|
121
|
+
start: window.start + index * window.bucketSizeMs,
|
|
122
|
+
end: index === window.bucketCount - 1 ? window.end : window.start + (index + 1) * window.bucketSizeMs,
|
|
84
123
|
total: 0,
|
|
85
124
|
series: {},
|
|
86
125
|
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildUsageGraph(rows: UsageAggregateRow[], window: UsageBucketWindow, options: UsageGraphOptions): UsageGraph {
|
|
129
|
+
const buckets: UsageBucket[] = emptyBuckets(window);
|
|
87
130
|
const breakdown: UsageBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
88
131
|
const identities = new Map<string, UsageSeries>();
|
|
89
132
|
|
|
90
133
|
for (const row of rows) {
|
|
91
134
|
const breakdownKey = BREAKDOWN_KEYS[row.metric as keyof typeof BREAKDOWN_KEYS];
|
|
92
|
-
if (
|
|
93
|
-
if (row.
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
bucket.
|
|
98
|
-
|
|
99
|
-
breakdown[breakdownKey] += row.value;
|
|
135
|
+
if (!breakdownKey || !Number.isFinite(row.sum) || row.sum < 0) continue;
|
|
136
|
+
if (!Number.isInteger(row.bucketIndex) || row.bucketIndex < 0 || row.bucketIndex >= buckets.length) continue;
|
|
137
|
+
const bucket = buckets[row.bucketIndex]!;
|
|
138
|
+
const series = identity(row.scope);
|
|
139
|
+
bucket.total += row.sum;
|
|
140
|
+
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.sum;
|
|
141
|
+
breakdown[breakdownKey] += row.sum;
|
|
100
142
|
const current = identities.get(series.key) ?? { ...series, total: 0 };
|
|
101
|
-
current.total += row.
|
|
143
|
+
current.total += row.sum;
|
|
102
144
|
identities.set(series.key, current);
|
|
103
145
|
}
|
|
104
146
|
|
|
105
147
|
const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
106
148
|
return {
|
|
107
149
|
period: options.period,
|
|
108
|
-
start,
|
|
109
|
-
end,
|
|
150
|
+
start: window.start,
|
|
151
|
+
end: window.end,
|
|
110
152
|
buckets,
|
|
111
153
|
series,
|
|
112
154
|
totalTokens: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
|
|
@@ -114,3 +156,62 @@ export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageG
|
|
|
114
156
|
truncated: options.truncated === true,
|
|
115
157
|
};
|
|
116
158
|
}
|
|
159
|
+
|
|
160
|
+
export interface CostSeries {
|
|
161
|
+
key: string;
|
|
162
|
+
provider: string;
|
|
163
|
+
model: string;
|
|
164
|
+
total: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface CostBucket {
|
|
168
|
+
start: number;
|
|
169
|
+
end: number;
|
|
170
|
+
total: number;
|
|
171
|
+
series: Record<string, number>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface CostGraph {
|
|
175
|
+
period: UsagePeriod;
|
|
176
|
+
start: number;
|
|
177
|
+
end: number;
|
|
178
|
+
buckets: CostBucket[];
|
|
179
|
+
series: CostSeries[];
|
|
180
|
+
totalUsd: number;
|
|
181
|
+
truncated: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Mirrors buildUsageGraph but for the "cost" (unit "usd") metric already recorded content-free on
|
|
186
|
+
* every finalized Pi assistant message (see assistantUsageMetrics), so this needs no new
|
|
187
|
+
* instrumentation. Aggregated spend by model/time is one of the metrics LLM usage dashboards
|
|
188
|
+
* surface from day one (alongside raw token counts), since token counts alone do not reflect that
|
|
189
|
+
* output tokens and premium models cost disproportionately more per token.
|
|
190
|
+
*/
|
|
191
|
+
export function buildCostGraph(rows: UsageAggregateRow[], window: UsageBucketWindow, options: UsageGraphOptions): CostGraph {
|
|
192
|
+
const buckets: CostBucket[] = emptyBuckets(window);
|
|
193
|
+
const identities = new Map<string, CostSeries>();
|
|
194
|
+
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
if (row.metric !== "cost" || !Number.isFinite(row.sum) || row.sum < 0) continue;
|
|
197
|
+
if (!Number.isInteger(row.bucketIndex) || row.bucketIndex < 0 || row.bucketIndex >= buckets.length) continue;
|
|
198
|
+
const bucket = buckets[row.bucketIndex]!;
|
|
199
|
+
const series = identity(row.scope);
|
|
200
|
+
bucket.total += row.sum;
|
|
201
|
+
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.sum;
|
|
202
|
+
const current = identities.get(series.key) ?? { ...series, total: 0 };
|
|
203
|
+
current.total += row.sum;
|
|
204
|
+
identities.set(series.key, current);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
208
|
+
return {
|
|
209
|
+
period: options.period,
|
|
210
|
+
start: window.start,
|
|
211
|
+
end: window.end,
|
|
212
|
+
buckets,
|
|
213
|
+
series,
|
|
214
|
+
totalUsd: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
|
|
215
|
+
truncated: options.truncated === true,
|
|
216
|
+
};
|
|
217
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured daemon logging, now backed by `@danypops/daemon-kit/logging` (pino) instead of a
|
|
3
|
+
* hand-rolled `console.error(JSON.stringify(...))` -- daemon-kit's own module doc explains why:
|
|
4
|
+
* level ordering/filtering/child-scoping is exactly the kind of thing worth one shared,
|
|
5
|
+
* dependency-backed implementation instead of four independent hand-rolled ones. One deliberate,
|
|
6
|
+
* disclosed shape change from jittor's old bespoke format: the event name is now pino's `msg`
|
|
7
|
+
* field rather than a separate `event` field, matching daemon-kit's shared convention across all
|
|
8
|
+
* four daemons. `component`/`level`/`timestamp` and credential-safety (callers still must pass
|
|
9
|
+
* only bounded, non-sensitive fields) are unchanged.
|
|
10
|
+
*/
|
|
11
|
+
import { createLogger, type LogLevel as DaemonKitLogLevel, type Logger } from "@danypops/daemon-kit/logging";
|
|
12
|
+
|
|
13
|
+
export type LogLevel = Extract<DaemonKitLogLevel, "info" | "warn" | "error">;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Also passed directly as `StartDaemonOptions.logger` so daemon-kit's own maintenance-task
|
|
17
|
+
* failure logging shares this same sink/shape. `destination` is pinned to `console.error` rather
|
|
18
|
+
* than daemon-kit's own default (a raw fd 2 write via `pino.destination(2)`, which bypasses
|
|
19
|
+
* `console.error` entirely) so existing tooling/tests that intercept `console.error` keep working.
|
|
20
|
+
*/
|
|
21
|
+
export const logger: Logger = createLogger("jittor-daemon", {
|
|
22
|
+
destination: { write: (chunk: string) => { console.error(chunk.replace(/\n$/, "")); return true; } },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
|
|
26
|
+
export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
27
|
+
logger[level](event, fields);
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BenchmarkQuery,
|
|
3
|
+
BenchmarkQueryResult,
|
|
4
|
+
BenchmarkRefreshResult,
|
|
5
|
+
} from "../domain/benchmark.ts";
|
|
6
|
+
|
|
7
|
+
export interface BenchmarkController {
|
|
8
|
+
refresh(force?: boolean): Promise<BenchmarkRefreshResult>;
|
|
9
|
+
status(): BenchmarkRefreshResult;
|
|
10
|
+
query(input: BenchmarkQuery): BenchmarkQueryResult;
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BenchmarkSourceSnapshot } from "../domain/benchmark.ts";
|
|
2
|
+
|
|
3
|
+
/** External evidence boundary. Implementations validate one source-specific schema. */
|
|
4
|
+
export interface BenchmarkSource {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
fetch(): Promise<BenchmarkSourceSnapshot>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BenchmarkObservation, BenchmarkSnapshot } from "../domain/benchmark.ts";
|
|
2
|
+
|
|
3
|
+
/** Immutable evidence snapshot boundary. Only complete snapshots become visible. */
|
|
4
|
+
export interface BenchmarkStore {
|
|
5
|
+
publish(sourceId: string, snapshotId: string, observations: BenchmarkObservation[]): BenchmarkSnapshot;
|
|
6
|
+
latest(sourceId: string): BenchmarkSnapshot | null;
|
|
7
|
+
}
|
|
@@ -1,8 +1,38 @@
|
|
|
1
|
+
import type { UsageAggregateRow } from "../domain/usage.ts";
|
|
1
2
|
import type { MetricObservation, MetricQuery, StoredMetricObservation } from "../domain/metric.ts";
|
|
2
3
|
|
|
4
|
+
export interface DistinctScopesFilter {
|
|
5
|
+
source: string;
|
|
6
|
+
since: number;
|
|
7
|
+
until: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UsageAggregateFilter {
|
|
12
|
+
source: string;
|
|
13
|
+
/** Only these scopes are summed -- callers pass the bounded result of distinctScopes, so a real explosion of distinct scopes truncates honestly instead of this method silently discovering and aggregating an unbounded set on its own. */
|
|
14
|
+
scopes: string[];
|
|
15
|
+
since: number;
|
|
16
|
+
until: number;
|
|
17
|
+
bucketSizeMs: number;
|
|
18
|
+
bucketCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
export interface MetricStore {
|
|
4
22
|
record(observation: MetricObservation): StoredMetricObservation;
|
|
5
23
|
query(filter?: MetricQuery): StoredMetricObservation[];
|
|
24
|
+
/** Bounded distinct scope values for a source within a time window, so callers can fetch a fair share per scope instead of one flat query a single heavy scope could monopolize. */
|
|
25
|
+
distinctScopes(filter: DistinctScopesFilter): string[];
|
|
26
|
+
/**
|
|
27
|
+
* SQL-side (scope, metric, bucket) sums for a bounded scope list -- result size scales with
|
|
28
|
+
* (scopes x distinct metrics x buckets), never with raw event count, so a heavy scope's full
|
|
29
|
+
* history is represented exactly regardless of how many observations fed it. Replaces fetching
|
|
30
|
+
* up to a fixed number of raw rows per scope, which could silently truncate a single heavy
|
|
31
|
+
* scope's own older history within the requested window (a real incident: a scope with 49,270
|
|
32
|
+
* rows in a week had its "weekly" chart built from the 250 most recent rows alone -- 3.3
|
|
33
|
+
* minutes of real activity mislabeled as a full week).
|
|
34
|
+
*/
|
|
35
|
+
aggregateUsage(filter: UsageAggregateFilter): UsageAggregateRow[];
|
|
6
36
|
pruneBefore(cutoff: number): number;
|
|
7
37
|
checkpoint(): void;
|
|
8
38
|
close(): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Official Anthropic Messages API rate-limit response headers, verified against
|
|
5
|
+
* https://platform.claude.com/docs/en/api/rate-limits (fetched 2026-07-21). These headers are
|
|
6
|
+
* returned on every Messages API response (per-request, not from a standalone polling endpoint —
|
|
7
|
+
* Anthropic's Admin/Rate Limits API is documented as "unavailable for individual accounts"), so
|
|
8
|
+
* Jittor observes them from Pi's own `after_provider_response` event rather than daemon-side
|
|
9
|
+
* polling. Priority Tier buckets are optional and only present for organizations enrolled in it.
|
|
10
|
+
*/
|
|
11
|
+
export interface AnthropicRateLimitWindow {
|
|
12
|
+
limit: number | null;
|
|
13
|
+
remaining: number | null;
|
|
14
|
+
resetsAt: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AnthropicRateLimitSnapshot {
|
|
18
|
+
requests: AnthropicRateLimitWindow | null;
|
|
19
|
+
tokens: AnthropicRateLimitWindow | null;
|
|
20
|
+
inputTokens: AnthropicRateLimitWindow | null;
|
|
21
|
+
outputTokens: AnthropicRateLimitWindow | null;
|
|
22
|
+
priorityInputTokens: AnthropicRateLimitWindow | null;
|
|
23
|
+
priorityOutputTokens: AnthropicRateLimitWindow | null;
|
|
24
|
+
retryAfterMs: number | null;
|
|
25
|
+
observedAt: number;
|
|
26
|
+
metrics: MetricObservation[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function metric(
|
|
30
|
+
source: AnthropicMetricSource,
|
|
31
|
+
scope: string,
|
|
32
|
+
name: string,
|
|
33
|
+
value: number,
|
|
34
|
+
unit: MetricObservation["unit"],
|
|
35
|
+
observedAt: number,
|
|
36
|
+
attributes: Record<string, unknown> = {},
|
|
37
|
+
): MetricObservation {
|
|
38
|
+
return { source, scope, metric: name, value, unit, observedAt, attributes };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function headerInteger(headers: Headers, name: string): number | null {
|
|
42
|
+
const raw = headers.get(name);
|
|
43
|
+
if (raw === null) return null;
|
|
44
|
+
const value = Number(raw);
|
|
45
|
+
if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value < 0) throw new Error(`Anthropic rate-limit header schema changed: ${name}`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function headerResetTime(headers: Headers, name: string): number | null {
|
|
50
|
+
const raw = headers.get(name);
|
|
51
|
+
if (raw === null) return null;
|
|
52
|
+
const parsed = Date.parse(raw);
|
|
53
|
+
if (!Number.isFinite(parsed)) throw new Error(`Anthropic rate-limit header schema changed: ${name} is not RFC 3339`);
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseWindow(headers: Headers, prefix: string): AnthropicRateLimitWindow | null {
|
|
58
|
+
const limit = headerInteger(headers, `${prefix}-limit`);
|
|
59
|
+
const remaining = headerInteger(headers, `${prefix}-remaining`);
|
|
60
|
+
const resetsAt = headerResetTime(headers, `${prefix}-reset`);
|
|
61
|
+
if (limit === null && remaining === null && resetsAt === null) return null;
|
|
62
|
+
return { limit, remaining, resetsAt };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function windowMetrics(source: AnthropicMetricSource, scope: string, window: AnthropicRateLimitWindow | null, observedAt: number): MetricObservation[] {
|
|
66
|
+
if (!window) return [];
|
|
67
|
+
const metrics: MetricObservation[] = [];
|
|
68
|
+
const attributes = { limit: window.limit, remaining: window.remaining, resetsAt: window.resetsAt };
|
|
69
|
+
if (window.limit !== null && window.limit > 0 && window.remaining !== null) {
|
|
70
|
+
const remainingFraction = window.remaining / window.limit;
|
|
71
|
+
if (remainingFraction < 0 || remainingFraction > 1) throw new Error(`Anthropic ${scope} remaining exceeds its configured limit`);
|
|
72
|
+
metrics.push(metric(source, scope, "remaining-fraction", remainingFraction, "ratio", observedAt, attributes));
|
|
73
|
+
metrics.push(metric(source, scope, "used-fraction", 1 - remainingFraction, "ratio", observedAt, attributes));
|
|
74
|
+
}
|
|
75
|
+
return metrics;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Direct Anthropic API calls and Anthropic-on-Vertex passthroughs (e.g. the third-party
|
|
80
|
+
* `@twogiants/pi-anthropic-vertex` extension, which reuses Pi's built-in Anthropic Messages stream
|
|
81
|
+
* with Anthropic's own `@anthropic-ai/vertex-sdk` client) may both emit this same header shape, but
|
|
82
|
+
* they are different accounts against different quota pools -- Anthropic's own org-scoped buckets
|
|
83
|
+
* vs whatever a Vertex project's passthrough exposes, if anything. Tagging the metric source keeps
|
|
84
|
+
* them from ever being blended into one budget reading.
|
|
85
|
+
*/
|
|
86
|
+
export type AnthropicMetricSource = "anthropic" | "anthropic-vertex";
|
|
87
|
+
|
|
88
|
+
export function parseAnthropicRateLimitHeaders(headers: Headers, observedAt = Date.now(), source: AnthropicMetricSource = "anthropic"): AnthropicRateLimitSnapshot {
|
|
89
|
+
const requests = parseWindow(headers, "anthropic-ratelimit-requests");
|
|
90
|
+
const tokens = parseWindow(headers, "anthropic-ratelimit-tokens");
|
|
91
|
+
const inputTokens = parseWindow(headers, "anthropic-ratelimit-input-tokens");
|
|
92
|
+
const outputTokens = parseWindow(headers, "anthropic-ratelimit-output-tokens");
|
|
93
|
+
const priorityInputTokens = parseWindow(headers, "anthropic-priority-input-tokens");
|
|
94
|
+
const priorityOutputTokens = parseWindow(headers, "anthropic-priority-output-tokens");
|
|
95
|
+
const retryAfterRaw = headers.get("retry-after");
|
|
96
|
+
const retryAfterSeconds = retryAfterRaw === null ? null : Number(retryAfterRaw);
|
|
97
|
+
if (retryAfterRaw !== null && (!Number.isFinite(retryAfterSeconds) || (retryAfterSeconds as number) < 0)) {
|
|
98
|
+
throw new Error("Anthropic rate-limit header schema changed: retry-after");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
requests,
|
|
102
|
+
tokens,
|
|
103
|
+
inputTokens,
|
|
104
|
+
outputTokens,
|
|
105
|
+
priorityInputTokens,
|
|
106
|
+
priorityOutputTokens,
|
|
107
|
+
retryAfterMs: retryAfterSeconds === null ? null : Math.round(retryAfterSeconds * 1_000),
|
|
108
|
+
observedAt,
|
|
109
|
+
metrics: [
|
|
110
|
+
...windowMetrics(source, "requests", requests, observedAt),
|
|
111
|
+
...windowMetrics(source, "tokens", tokens, observedAt),
|
|
112
|
+
...windowMetrics(source, "input-tokens", inputTokens, observedAt),
|
|
113
|
+
...windowMetrics(source, "output-tokens", outputTokens, observedAt),
|
|
114
|
+
...windowMetrics(source, "priority-input-tokens", priorityInputTokens, observedAt),
|
|
115
|
+
...windowMetrics(source, "priority-output-tokens", priorityOutputTokens, observedAt),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when the response carries at least one recognized Anthropic rate-limit header. */
|
|
121
|
+
export function hasAnthropicRateLimitHeaders(headers: Headers): boolean {
|
|
122
|
+
for (const name of headers.keys()) {
|
|
123
|
+
const lower = name.toLowerCase();
|
|
124
|
+
if (lower.startsWith("anthropic-ratelimit-") || lower.startsWith("anthropic-priority-")) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { GoogleAuth } from "google-auth-library";
|
|
2
|
+
import { GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The only file in Jittor that imports `google-auth-library` (Jittor's first runtime dependency;
|
|
6
|
+
* see docs/PROVIDER_RESEARCH.md for why ADC token acquisition -- not the Vertex/Budget API
|
|
7
|
+
* surface itself -- is the one piece worth a mature dependency instead of hand-rolling: it is
|
|
8
|
+
* security-sensitive OAuth/JWT/metadata-server protocol code, the exact class of thing the
|
|
9
|
+
* project's own off-the-shelf-modules guidance calls out as worth not reimplementing).
|
|
10
|
+
*
|
|
11
|
+
* This matches the individual-GCP-project migration's "passwordless/keyless" model: Application
|
|
12
|
+
* Default Credentials (a user's own `gcloud auth application-default login` session, a Compute/
|
|
13
|
+
* Cloud Run metadata identity, or Workload Identity Federation), never a static service-account
|
|
14
|
+
* key file. `google-auth-library` auto-detects which of those applies to the environment it runs
|
|
15
|
+
* in; Jittor does not choose or configure a credential type itself.
|
|
16
|
+
*/
|
|
17
|
+
export type GoogleAdcTokenProvider = () => Promise<string>;
|
|
18
|
+
|
|
19
|
+
/** The minimal shape this module actually calls on an ADC client; matches google-auth-library's real `AuthClient`/`Credentials` fields (verified against its published `.d.ts`), kept narrow so tests can inject a fake without constructing a real `GoogleAuth`. */
|
|
20
|
+
export interface GoogleAdcClient {
|
|
21
|
+
getAccessToken(): Promise<{ token?: string | null }>;
|
|
22
|
+
credentials?: { expiry_date?: number | null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type GoogleAdcClientFactory = (scopes: readonly string[]) => Promise<GoogleAdcClient>;
|
|
26
|
+
|
|
27
|
+
async function defaultAdcClientFactory(scopes: readonly string[]): Promise<GoogleAdcClient> {
|
|
28
|
+
return new GoogleAuth({ scopes: [...scopes] }).getClient();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wraps ADC token acquisition with the caching every other Jittor provider adapter skips
|
|
33
|
+
* only because their upstreams don't require an OAuth exchange per call: a fresh Google access
|
|
34
|
+
* token is valid for roughly an hour, and re-deriving ADC (which may itself make a metadata-server
|
|
35
|
+
* or token-exchange network call) on every poll would be wasteful and, for some ADC sources,
|
|
36
|
+
* rate-limited. `GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS` triggers a refresh slightly before Google
|
|
37
|
+
* itself would consider the cached token invalid.
|
|
38
|
+
*/
|
|
39
|
+
export function createGoogleAdcTokenProvider(
|
|
40
|
+
scopes: readonly string[],
|
|
41
|
+
clock: () => number = Date.now,
|
|
42
|
+
clientFactory: GoogleAdcClientFactory = defaultAdcClientFactory,
|
|
43
|
+
): GoogleAdcTokenProvider {
|
|
44
|
+
let cachedClient: GoogleAdcClient | undefined;
|
|
45
|
+
let cachedToken: string | undefined;
|
|
46
|
+
let cachedExpiryEpochMs = 0;
|
|
47
|
+
|
|
48
|
+
return async function getAccessToken(): Promise<string> {
|
|
49
|
+
const now = clock();
|
|
50
|
+
if (cachedToken && now < cachedExpiryEpochMs - GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS) return cachedToken;
|
|
51
|
+
cachedClient ??= await clientFactory(scopes);
|
|
52
|
+
const { token } = await cachedClient.getAccessToken();
|
|
53
|
+
if (!token) throw new Error("Google Application Default Credentials did not return an access token");
|
|
54
|
+
// `credentials.expiry_date` is the verified google-auth-library field (epoch ms). When a
|
|
55
|
+
// credential type doesn't report one, this deliberately does not fabricate an assumed TTL --
|
|
56
|
+
// it treats the token as already due for refresh, so the next call re-fetches rather than
|
|
57
|
+
// caching for a guessed duration.
|
|
58
|
+
const expiryDate = cachedClient.credentials?.expiry_date;
|
|
59
|
+
cachedToken = token;
|
|
60
|
+
cachedExpiryEpochMs = typeof expiryDate === "number" ? expiryDate : now;
|
|
61
|
+
return token;
|
|
62
|
+
};
|
|
63
|
+
}
|