@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.
Files changed (44) hide show
  1. package/README.md +62 -7
  2. package/docs/BENCHMARK_SOURCES.md +32 -0
  3. package/docs/OUTPUT_CHANNELS.md +31 -0
  4. package/docs/PROVIDER_RESEARCH.md +57 -0
  5. package/extension/src/benchmark-tui.ts +105 -0
  6. package/extension/src/footer.ts +68 -12
  7. package/extension/src/index.ts +263 -36
  8. package/extension/src/tui.ts +61 -9
  9. package/extension/src/usage.ts +165 -47
  10. package/package.json +5 -1
  11. package/src/adapters/metric-benchmark-store.ts +99 -0
  12. package/src/adapters/openrouter-benchmark-index-source.ts +94 -0
  13. package/src/adapters/openrouter-benchmark-source.ts +109 -0
  14. package/src/adapters/sqlite-metric-store.ts +43 -2
  15. package/src/cli.ts +660 -9
  16. package/src/client.ts +12 -44
  17. package/src/constants.ts +69 -5
  18. package/src/daemon.ts +65 -43
  19. package/src/db.ts +13 -30
  20. package/src/domain/benchmark.ts +264 -0
  21. package/src/domain/context-telemetry.ts +31 -0
  22. package/src/domain/metric.ts +46 -5
  23. package/src/domain/model-observation.ts +203 -0
  24. package/src/domain/model-ranking-service.ts +41 -0
  25. package/src/domain/model-ranking.ts +232 -0
  26. package/src/domain/task-cost.ts +70 -0
  27. package/src/domain/task-focus.ts +65 -0
  28. package/src/domain/usage.ts +134 -33
  29. package/src/log.ts +28 -0
  30. package/src/ports/benchmark-controller.ts +11 -0
  31. package/src/ports/benchmark-source.ts +7 -0
  32. package/src/ports/benchmark-store.ts +7 -0
  33. package/src/ports/metric-store.ts +30 -0
  34. package/src/ports/router-controller.ts +1 -0
  35. package/src/providers/anthropic-contracts.ts +127 -0
  36. package/src/providers/google-adc-auth.ts +63 -0
  37. package/src/providers/google-vertex-budget-contracts.ts +181 -0
  38. package/src/providers/google-vertex-budget.ts +127 -0
  39. package/src/providers/google-vertex-contracts.ts +116 -0
  40. package/src/providers/telemetry-sources.ts +35 -0
  41. package/src/router.ts +12 -0
  42. package/src/service.ts +132 -17
  43. package/src/state.ts +31 -57
  44. 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
+ }
@@ -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
- export function usagePeriodStart(period: UsagePeriod, now: number): number {
63
- return Math.max(0, now - usagePeriod(period).windowMs);
64
- }
65
-
66
- function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
67
- const separator = row.scope.indexOf(":");
68
- const fallbackProvider = separator >= 0 ? row.scope.slice(0, separator) : row.scope;
69
- const fallbackModel = separator >= 0 ? row.scope.slice(separator + 1) : "unknown";
70
- const provider = typeof row.attributes["provider"] === "string" ? row.attributes["provider"] : fallbackProvider;
71
- const model = typeof row.attributes["model"] === "string" ? row.attributes["model"] : fallbackModel;
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
- export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): UsageGraph {
76
- const end = options.now;
77
- const start = usagePeriodStart(options.period, end);
78
- const requestedBuckets = options.bucketCount ?? usagePeriod(options.period).bucketCount;
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 (row.source !== "pi" || row.unit !== "tokens" || !breakdownKey || typeof row.value !== "number" || row.value < 0) continue;
93
- if (row.observedAt < start || row.observedAt > end) continue;
94
- const bucketIndex = Math.min(bucketCount - 1, Math.floor((row.observedAt - start) / bucketSize));
95
- const bucket = buckets[bucketIndex]!;
96
- const series = identity(row);
97
- bucket.total += row.value;
98
- bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
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.value;
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;
@@ -39,4 +39,5 @@ export interface RouterController {
39
39
  clearOverride(): RouterStatus;
40
40
  setCurrentRoute(route: Route): RouterStatus;
41
41
  setAvailableRoutes(routes: Route[]): RouterStatus;
42
+ applyModelRanking?(candidates: Route[]): RouterStatus;
42
43
  }
@@ -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
+ }