@danypops/jittor 0.5.0 → 0.6.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 +51 -7
- package/docs/BENCHMARK_SOURCES.md +32 -0
- package/docs/OUTPUT_CHANNELS.md +31 -0
- package/docs/PROVIDER_RESEARCH.md +38 -0
- package/extension/src/benchmark-tui.ts +103 -0
- package/extension/src/footer.ts +43 -3
- package/extension/src/index.ts +196 -33
- package/extension/src/tui.ts +32 -7
- package/extension/src/usage.ts +147 -38
- package/package.json +4 -1
- package/src/adapters/metric-benchmark-store.ts +99 -0
- package/src/adapters/openrouter-benchmark-index-source.ts +93 -0
- package/src/adapters/openrouter-benchmark-source.ts +109 -0
- package/src/cli.ts +531 -9
- package/src/constants.ts +38 -1
- package/src/daemon.ts +23 -2
- 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 +181 -0
- package/src/domain/model-ranking-service.ts +40 -0
- package/src/domain/model-ranking.ts +213 -0
- package/src/domain/usage.ts +84 -7
- 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/router-controller.ts +1 -0
- package/src/providers/anthropic-contracts.ts +116 -0
- package/src/providers/google-vertex-contracts.ts +104 -0
- package/src/router.ts +12 -0
- package/src/service.ts +60 -3
package/extension/src/usage.ts
CHANGED
|
@@ -1,28 +1,65 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { USAGE_CHART_HEIGHT, USAGE_TOKEN_QUERY_LIMIT, USAGE_Y_AXIS_WIDTH } from "../../src/constants.ts";
|
|
3
|
+
import { HUMAN_TEXT_FIELD_MAX_CHARACTERS, USAGE_CHART_HEIGHT, USAGE_RENDER_MAX_SERIES, USAGE_TOKEN_QUERY_LIMIT, USAGE_Y_AXIS_WIDTH } from "../../src/constants.ts";
|
|
4
4
|
import type { StoredMetricObservation } from "../../src/domain/metric.ts";
|
|
5
5
|
import {
|
|
6
|
+
buildCostGraph,
|
|
6
7
|
buildUsageGraph,
|
|
7
8
|
USAGE_PERIODS,
|
|
8
9
|
usagePeriod,
|
|
9
10
|
usagePeriodStart,
|
|
10
|
-
type
|
|
11
|
+
type CostGraph,
|
|
11
12
|
type UsageGraph,
|
|
12
13
|
type UsagePeriod,
|
|
13
14
|
} from "../../src/domain/usage.ts";
|
|
14
15
|
import type { UsageBudgetControl } from "./settings.ts";
|
|
15
16
|
import type { JittorPanelClient } from "./tui.ts";
|
|
16
17
|
|
|
17
|
-
type UsageAction = "period-prev" | "period-next" | "refresh" | "close";
|
|
18
|
-
type UsageColor =
|
|
18
|
+
type UsageAction = "period-prev" | "period-next" | "view-next" | "refresh" | "close";
|
|
19
|
+
type UsageColor =
|
|
20
|
+
| "accent" | "success" | "warning" | "error" | "thinkingText" | "muted" | "dim" | "borderMuted"
|
|
21
|
+
| "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator";
|
|
19
22
|
|
|
20
23
|
export interface UsageTheme {
|
|
21
24
|
fg(color: UsageColor, text: string): string;
|
|
22
25
|
bold(text: string): string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Categorical palette for per-provider/model series. Deliberately excludes "success"/"warning"/
|
|
30
|
+
* "error": those already carry a fixed status meaning elsewhere in this same panel (the budget
|
|
31
|
+
* threshold line, freshness state), so reusing them for arbitrary model identity would make a
|
|
32
|
+
* model's bar segment look like a warning or a failure to a pre-attentive reader — a real
|
|
33
|
+
* categorical-color-design pitfall (see e.g. ColorBrewer/Okabe-Ito guidance on qualitative
|
|
34
|
+
* palettes: colors for nominal categories should not imply an order, magnitude, or judgment).
|
|
35
|
+
*
|
|
36
|
+
* Instead this reuses the syntax-highlighting color roles, because theme authors already tune
|
|
37
|
+
* those specifically to be simultaneously distinguishable on screen — that is the same design
|
|
38
|
+
* problem as a categorical data palette (many hues coexisting in one view that all need to read
|
|
39
|
+
* as different from each other). The order below interleaves the hue families syntax themes
|
|
40
|
+
* conventionally assign to keyword/function/string/number/type/variable/operator (violet, blue,
|
|
41
|
+
* green, orange, teal, cyan, neutral) so the first colors used are spread around the hue wheel
|
|
42
|
+
* rather than clustered, which is the standard categorical-palette heuristic for maximizing
|
|
43
|
+
* perceptual separation between adjacent categories.
|
|
44
|
+
*
|
|
45
|
+
* Terminal foreground color is a single channel with a hard ceiling on how many hues stay mutually
|
|
46
|
+
* distinguishable (most qualitative-palette guidance caps around 8–12). Once the hue palette is
|
|
47
|
+
* exhausted, seriesStyle adds bold as a second, independent visual channel before any exact
|
|
48
|
+
* color+weight combination repeats — a standard visualization technique (encode extra categories
|
|
49
|
+
* on an additional channel rather than silently reusing an indistinguishable color).
|
|
50
|
+
*/
|
|
51
|
+
const SERIES_HUES: UsageColor[] = [
|
|
52
|
+
"accent", "syntaxFunction", "syntaxString", "syntaxNumber",
|
|
53
|
+
"syntaxKeyword", "syntaxType", "thinkingText", "syntaxVariable", "syntaxOperator",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Returns a style function for the Nth series: cycles hue, then adds bold once the palette wraps. */
|
|
57
|
+
function seriesStyle(index: number, theme: UsageTheme): (text: string) => string {
|
|
58
|
+
const hue = SERIES_HUES[index % SERIES_HUES.length]!;
|
|
59
|
+
const useBold = Math.floor(index / SERIES_HUES.length) % 2 === 1;
|
|
60
|
+
return (text: string) => useBold ? theme.bold(theme.fg(hue, text)) : theme.fg(hue, text);
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
const PARTIAL_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
27
64
|
|
|
28
65
|
function compact(value: number): string {
|
|
@@ -32,9 +69,13 @@ function compact(value: number): string {
|
|
|
32
69
|
return String(Math.round(value));
|
|
33
70
|
}
|
|
34
71
|
|
|
35
|
-
|
|
72
|
+
interface RenderableSeries { key: string; provider: string; model: string; total: number }
|
|
73
|
+
interface RenderableBucket { start: number; end: number; total: number; series: Record<string, number> }
|
|
74
|
+
interface RenderableChart { period: UsagePeriod; start: number; end: number; buckets: RenderableBucket[]; series: RenderableSeries[]; total: number; truncated: boolean }
|
|
75
|
+
|
|
76
|
+
function mergeBuckets(buckets: RenderableBucket[], maximum: number): RenderableBucket[] {
|
|
36
77
|
if (buckets.length <= maximum) return buckets;
|
|
37
|
-
const result:
|
|
78
|
+
const result: RenderableBucket[] = [];
|
|
38
79
|
for (let index = 0; index < maximum; index += 1) {
|
|
39
80
|
const from = Math.floor(index * buckets.length / maximum);
|
|
40
81
|
const to = Math.max(from + 1, Math.floor((index + 1) * buckets.length / maximum));
|
|
@@ -53,15 +94,24 @@ function mergeBuckets(buckets: UsageBucket[], maximum: number): UsageBucket[] {
|
|
|
53
94
|
return result;
|
|
54
95
|
}
|
|
55
96
|
|
|
56
|
-
function seriesAt(bucket:
|
|
97
|
+
function seriesAt(bucket: RenderableBucket, chart: RenderableChart, valueHeight: number): number {
|
|
57
98
|
let cumulative = 0;
|
|
58
99
|
for (let index = 0; index < chart.series.length; index += 1) {
|
|
59
100
|
cumulative += bucket.series[chart.series[index]!.key] ?? 0;
|
|
60
|
-
if (
|
|
101
|
+
if (valueHeight <= cumulative) return index;
|
|
61
102
|
}
|
|
62
103
|
return Math.max(0, chart.series.length - 1);
|
|
63
104
|
}
|
|
64
105
|
|
|
106
|
+
/** Compact USD formatter: full cents below $1k, then the same k/M suffix convention as compact(). */
|
|
107
|
+
function formatUsd(value: number): string {
|
|
108
|
+
const magnitude = Math.abs(value);
|
|
109
|
+
if (magnitude === 0) return "$0";
|
|
110
|
+
if (magnitude < 1_000) return `$${value.toFixed(magnitude < 0.01 ? 4 : 2)}`;
|
|
111
|
+
if (magnitude < 1_000_000) return `$${(value / 1_000).toFixed(1)}k`;
|
|
112
|
+
return `$${(value / 1_000_000).toFixed(1)}M`;
|
|
113
|
+
}
|
|
114
|
+
|
|
65
115
|
function formatPeriodPoint(value: number, period: UsagePeriod): string {
|
|
66
116
|
const date = new Date(value);
|
|
67
117
|
if (period === "hourly" || period === "daily") return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
@@ -80,11 +130,27 @@ function axisLabels(start: number, end: number, period: UsagePeriod, width: numb
|
|
|
80
130
|
return characters.join("");
|
|
81
131
|
}
|
|
82
132
|
|
|
133
|
+
function displayIdentity(value: string): string {
|
|
134
|
+
return value.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim().slice(0, HUMAN_TEXT_FIELD_MAX_CHARACTERS);
|
|
135
|
+
}
|
|
136
|
+
|
|
83
137
|
function plainTheme(): UsageTheme {
|
|
84
138
|
return { fg: (_color, text) => text, bold: (text) => text };
|
|
85
139
|
}
|
|
86
140
|
|
|
87
|
-
|
|
141
|
+
interface ChartRenderOptions {
|
|
142
|
+
title: string;
|
|
143
|
+
formatValue: (value: number) => string;
|
|
144
|
+
/** Appended after the observed/budget amount, e.g. " tokens"; empty when formatValue already carries a unit prefix like "$". */
|
|
145
|
+
unitSuffix: string;
|
|
146
|
+
subtitle?: string;
|
|
147
|
+
budget?: number;
|
|
148
|
+
noDataText: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Shared cumulative bar-chart renderer behind renderUsageGraph and renderCostGraph. */
|
|
152
|
+
function renderChart(chart: RenderableChart, width: number, theme: UsageTheme, options: ChartRenderOptions): string[] {
|
|
153
|
+
const { formatValue, unitSuffix } = options;
|
|
88
154
|
const safeWidth = Math.max(20, width);
|
|
89
155
|
const chartColumns = Math.max(1, Math.floor((safeWidth - USAGE_Y_AXIS_WIDTH - 1) / 2));
|
|
90
156
|
const increments = mergeBuckets(chart.buckets, chartColumns);
|
|
@@ -97,24 +163,24 @@ export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageT
|
|
|
97
163
|
});
|
|
98
164
|
const barStep = buckets.length * 2 <= safeWidth - USAGE_Y_AXIS_WIDTH ? 2 : 1;
|
|
99
165
|
const plotWidth = buckets.length * barStep;
|
|
100
|
-
const budget = typeof
|
|
101
|
-
const maximum = Math.max(chart.
|
|
102
|
-
const observed = chart.truncated ? `at least ${
|
|
166
|
+
const budget = typeof options.budget === "number" && Number.isFinite(options.budget) && options.budget > 0 ? options.budget : undefined;
|
|
167
|
+
const maximum = Math.max(chart.total, budget ?? 0);
|
|
168
|
+
const observed = chart.truncated ? `at least ${formatValue(chart.total)}` : formatValue(chart.total);
|
|
103
169
|
const budgetState = budget === undefined
|
|
104
|
-
? `${observed}
|
|
105
|
-
: chart.
|
|
106
|
-
? `${observed} / ${
|
|
170
|
+
? `${observed}${unitSuffix} · budget not configured${chart.truncated ? " · query limit reached" : ""}`
|
|
171
|
+
: chart.total > budget
|
|
172
|
+
? `${observed}${unitSuffix} / ${formatValue(budget)} budget · OVER BUDGET by ${chart.truncated ? "at least " : ""}${formatValue(chart.total - budget)}`
|
|
107
173
|
: chart.truncated
|
|
108
|
-
? `${observed} / ${
|
|
109
|
-
: `${observed} / ${
|
|
174
|
+
? `${observed}${unitSuffix} / ${formatValue(budget)} budget · state unknown · query limit reached`
|
|
175
|
+
: `${observed}${unitSuffix} / ${formatValue(budget)} budget · ${formatValue(budget - chart.total)} remaining`;
|
|
110
176
|
const lines = [
|
|
111
|
-
truncateToWidth(theme.bold(
|
|
177
|
+
truncateToWidth(theme.bold(options.title), safeWidth, ""),
|
|
112
178
|
truncateToWidth(budgetState, safeWidth, "…"),
|
|
113
|
-
|
|
179
|
+
...(options.subtitle ? [truncateToWidth(options.subtitle, safeWidth, "…")] : []),
|
|
114
180
|
"",
|
|
115
181
|
];
|
|
116
182
|
if (maximum === 0) {
|
|
117
|
-
lines.push(theme.fg("dim",
|
|
183
|
+
lines.push(theme.fg("dim", options.noDataText));
|
|
118
184
|
return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
|
|
119
185
|
}
|
|
120
186
|
|
|
@@ -123,9 +189,9 @@ export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageT
|
|
|
123
189
|
const lower = maximum * fromBottom / USAGE_CHART_HEIGHT;
|
|
124
190
|
const upper = maximum * (fromBottom + 1) / USAGE_CHART_HEIGHT;
|
|
125
191
|
const thresholdRow = budget !== undefined && budget > lower && budget <= upper;
|
|
126
|
-
const label = thresholdRow ?
|
|
192
|
+
const label = thresholdRow ? formatValue(budget) : row === 0 ? formatValue(maximum) : row === Math.floor(USAGE_CHART_HEIGHT / 2) ? formatValue(maximum / 2) : "";
|
|
127
193
|
if (thresholdRow) {
|
|
128
|
-
const color = chart.
|
|
194
|
+
const color = chart.total > budget ? "error" : "warning";
|
|
129
195
|
lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${theme.fg(color, "┄".repeat(plotWidth))}`);
|
|
130
196
|
continue;
|
|
131
197
|
}
|
|
@@ -138,24 +204,57 @@ export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageT
|
|
|
138
204
|
continue;
|
|
139
205
|
}
|
|
140
206
|
const block = PARTIAL_BLOCKS[Math.max(0, Math.ceil(occupancy * PARTIAL_BLOCKS.length) - 1)]!;
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
plot += theme.fg(color, block) + (barStep === 2 ? " " : "");
|
|
207
|
+
const valueHeight = Math.min(bucket.total, maximum * (fromBottom + Math.min(occupancy, 0.5)) / USAGE_CHART_HEIGHT);
|
|
208
|
+
plot += seriesStyle(seriesAt(bucket, chart, valueHeight), theme)(block) + (barStep === 2 ? " " : "");
|
|
144
209
|
}
|
|
145
210
|
lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${plot}`);
|
|
146
211
|
}
|
|
147
212
|
lines.push(`${"0".padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", `└${"─".repeat(plotWidth)}`)}`);
|
|
148
213
|
lines.push(`${" ".repeat(USAGE_Y_AXIS_WIDTH)}${axisLabels(chart.start, chart.end, chart.period, plotWidth)}`);
|
|
149
214
|
lines.push("");
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
215
|
+
const displayedSeries = chart.series.slice(0, USAGE_RENDER_MAX_SERIES);
|
|
216
|
+
for (let index = 0; index < displayedSeries.length; index += 1) {
|
|
217
|
+
const series = displayedSeries[index]!;
|
|
218
|
+
const bullet = seriesStyle(index, theme)("■");
|
|
219
|
+
lines.push(truncateToWidth(`${bullet} ${displayIdentity(series.provider)}/${displayIdentity(series.model)} ${formatValue(series.total)}`, safeWidth, "…"));
|
|
154
220
|
}
|
|
221
|
+
if (chart.series.length > displayedSeries.length) lines.push(truncateToWidth(theme.fg("muted", `… ${chart.series.length - displayedSeries.length} more series omitted`), safeWidth, "…"));
|
|
155
222
|
return lines.map((line) => visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…"));
|
|
156
223
|
}
|
|
157
224
|
|
|
158
|
-
|
|
225
|
+
export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageTheme, tokenBudget?: number): string[] {
|
|
226
|
+
return renderChart(
|
|
227
|
+
{ period: chart.period, start: chart.start, end: chart.end, buckets: chart.buckets, series: chart.series, total: chart.totalTokens, truncated: chart.truncated },
|
|
228
|
+
width, theme,
|
|
229
|
+
{
|
|
230
|
+
title: `${usagePeriod(chart.period).label} token usage`,
|
|
231
|
+
formatValue: compact,
|
|
232
|
+
unitSuffix: " tokens",
|
|
233
|
+
subtitle: `input ${compact(chart.breakdown.input)} · output ${compact(chart.breakdown.output)} · cache ${compact(chart.breakdown.cacheRead + chart.breakdown.cacheWrite)}`,
|
|
234
|
+
budget: tokenBudget,
|
|
235
|
+
noDataText: "No recorded Pi token usage in this period.",
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderCostGraph(chart: CostGraph, width: number, theme: UsageTheme, costBudget?: number): string[] {
|
|
241
|
+
return renderChart(
|
|
242
|
+
{ period: chart.period, start: chart.start, end: chart.end, buckets: chart.buckets, series: chart.series, total: chart.totalUsd, truncated: chart.truncated },
|
|
243
|
+
width, theme,
|
|
244
|
+
{
|
|
245
|
+
title: `${usagePeriod(chart.period).label} cost`,
|
|
246
|
+
formatValue: formatUsd,
|
|
247
|
+
unitSuffix: "",
|
|
248
|
+
budget: costBudget,
|
|
249
|
+
noDataText: "No recorded Pi cost in this period.",
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export type UsageViewKind = "tokens" | "cost";
|
|
255
|
+
const USAGE_VIEWS: UsageViewKind[] = ["tokens", "cost"];
|
|
256
|
+
|
|
257
|
+
async function loadPiMetrics(client: JittorPanelClient, period: UsagePeriod, now: number): Promise<{ rows: StoredMetricObservation[]; truncated: boolean }> {
|
|
159
258
|
const rows = await client.call("metrics.query", {
|
|
160
259
|
source: "pi",
|
|
161
260
|
since: usagePeriodStart(period, now),
|
|
@@ -163,7 +262,7 @@ async function loadUsage(client: JittorPanelClient, period: UsagePeriod, now: nu
|
|
|
163
262
|
order: "desc",
|
|
164
263
|
limit: USAGE_TOKEN_QUERY_LIMIT,
|
|
165
264
|
}) as StoredMetricObservation[];
|
|
166
|
-
return
|
|
265
|
+
return { rows, truncated: rows.length >= USAGE_TOKEN_QUERY_LIMIT };
|
|
167
266
|
}
|
|
168
267
|
|
|
169
268
|
export async function showUsagePanel(
|
|
@@ -171,32 +270,42 @@ export async function showUsagePanel(
|
|
|
171
270
|
client: JittorPanelClient,
|
|
172
271
|
budgets: Pick<UsageBudgetControl, "getUsageTokenBudget">,
|
|
173
272
|
now = Date.now(),
|
|
273
|
+
initialView: UsageViewKind = "tokens",
|
|
174
274
|
): Promise<void> {
|
|
175
275
|
let periodIndex = 0;
|
|
276
|
+
let viewIndex = Math.max(0, USAGE_VIEWS.indexOf(initialView));
|
|
176
277
|
for (;;) {
|
|
177
278
|
const period = USAGE_PERIODS[periodIndex]!.id;
|
|
178
|
-
|
|
279
|
+
// One bounded query serves both views: token and cost metrics share the same "pi" source rows.
|
|
280
|
+
const { rows, truncated } = await loadPiMetrics(client, period, now);
|
|
281
|
+
const view = USAGE_VIEWS[viewIndex]!;
|
|
282
|
+
const tokenChart = buildUsageGraph(rows, { period, now, truncated });
|
|
283
|
+
const costChart = buildCostGraph(rows, { period, now, truncated });
|
|
179
284
|
const tokenBudget = budgets.getUsageTokenBudget(period);
|
|
285
|
+
const renderActive = (width: number, theme: UsageTheme): string[] =>
|
|
286
|
+
view === "tokens" ? renderUsageGraph(tokenChart, width, theme, tokenBudget) : renderCostGraph(costChart, width, theme);
|
|
180
287
|
if (ctx.mode !== "tui") {
|
|
181
|
-
ctx.ui.notify(
|
|
288
|
+
ctx.ui.notify(renderActive(80, plainTheme()).join("\n"), "info");
|
|
182
289
|
return;
|
|
183
290
|
}
|
|
184
291
|
const action = await ctx.ui.custom<UsageAction>((_tui, theme, _keybindings, done) => ({
|
|
185
292
|
invalidate() {},
|
|
186
293
|
render(width: number): string[] {
|
|
187
|
-
const lines =
|
|
188
|
-
const controls = theme.fg("dim", "
|
|
294
|
+
const lines = renderActive(width, theme);
|
|
295
|
+
const controls = theme.fg("dim", "←/→/Tab period · v view · r refresh · Esc close");
|
|
189
296
|
return [...lines, "", truncateToWidth(controls, width, "…")];
|
|
190
297
|
},
|
|
191
298
|
handleInput(data: string): void {
|
|
192
299
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done("close");
|
|
193
|
-
else if (matchesKey(data, "left")) done("period-prev");
|
|
194
|
-
else if (matchesKey(data, "right")) done("period-next");
|
|
300
|
+
else if (matchesKey(data, "left") || matchesKey(data, "shift+tab")) done("period-prev");
|
|
301
|
+
else if (matchesKey(data, "right") || matchesKey(data, "tab")) done("period-next");
|
|
302
|
+
else if (data === "v") done("view-next");
|
|
195
303
|
else if (data === "r") done("refresh");
|
|
196
304
|
},
|
|
197
305
|
}));
|
|
198
306
|
if (!action || action === "close") return;
|
|
199
307
|
if (action === "period-prev") periodIndex = (periodIndex - 1 + USAGE_PERIODS.length) % USAGE_PERIODS.length;
|
|
200
308
|
if (action === "period-next") periodIndex = (periodIndex + 1) % USAGE_PERIODS.length;
|
|
309
|
+
if (action === "view-next") viewIndex = (viewIndex + 1) % USAGE_VIEWS.length;
|
|
201
310
|
}
|
|
202
311
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/jittor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Just-in-Time Token Optimizing Router for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package", "llm-router", "token-budget"],
|
|
7
|
+
"bin": {
|
|
8
|
+
"jittor": "src/cli.ts"
|
|
9
|
+
},
|
|
7
10
|
"scripts": {
|
|
8
11
|
"test": "bun test",
|
|
9
12
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT,
|
|
3
|
+
BENCHMARK_STORE_QUERY_LIMIT,
|
|
4
|
+
} from "../constants.ts";
|
|
5
|
+
import {
|
|
6
|
+
validateBenchmarkObservation,
|
|
7
|
+
type BenchmarkObservation,
|
|
8
|
+
type BenchmarkSnapshot,
|
|
9
|
+
} from "../domain/benchmark.ts";
|
|
10
|
+
import type { StoredMetricObservation } from "../domain/metric.ts";
|
|
11
|
+
import type { BenchmarkStore } from "../ports/benchmark-store.ts";
|
|
12
|
+
import type { MetricStore } from "../ports/metric-store.ts";
|
|
13
|
+
|
|
14
|
+
const COMPLETE_METRIC = "snapshot-complete";
|
|
15
|
+
const SNAPSHOT_SCOPE = "snapshot";
|
|
16
|
+
|
|
17
|
+
function metricSource(sourceId: string): string {
|
|
18
|
+
return `benchmark:${sourceId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function observationAttributes(snapshotId: string, observation: BenchmarkObservation): Record<string, unknown> {
|
|
22
|
+
return {
|
|
23
|
+
snapshotId,
|
|
24
|
+
model: observation.model,
|
|
25
|
+
provenance: observation.provenance,
|
|
26
|
+
methodology: observation.methodology,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decodeObservation(row: StoredMetricObservation): BenchmarkObservation {
|
|
31
|
+
return validateBenchmarkObservation({
|
|
32
|
+
model: row.attributes["model"],
|
|
33
|
+
dimension: row.metric,
|
|
34
|
+
value: row.value,
|
|
35
|
+
unit: row.unit,
|
|
36
|
+
provenance: row.attributes["provenance"],
|
|
37
|
+
methodology: row.attributes["methodology"],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class MetricBenchmarkStore implements BenchmarkStore {
|
|
42
|
+
constructor(private readonly metrics: MetricStore) {}
|
|
43
|
+
|
|
44
|
+
publish(sourceId: string, snapshotId: string, input: BenchmarkObservation[]): BenchmarkSnapshot {
|
|
45
|
+
if (input.length > BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT) throw new Error("benchmark snapshot exceeds the observation limit");
|
|
46
|
+
const observations = input.map(validateBenchmarkObservation);
|
|
47
|
+
const existing = this.latest(sourceId);
|
|
48
|
+
if (existing?.snapshotId === snapshotId) return existing;
|
|
49
|
+
const retrievedAt = observations[0]?.provenance.retrievedAt;
|
|
50
|
+
if (retrievedAt === undefined || observations.some((observation) => observation.provenance.sourceId !== sourceId || observation.provenance.retrievedAt !== retrievedAt)) {
|
|
51
|
+
throw new Error("benchmark snapshot provenance mismatch");
|
|
52
|
+
}
|
|
53
|
+
for (const observation of observations) {
|
|
54
|
+
this.metrics.record({
|
|
55
|
+
source: metricSource(sourceId),
|
|
56
|
+
scope: observation.model.canonical,
|
|
57
|
+
metric: observation.dimension,
|
|
58
|
+
value: observation.value,
|
|
59
|
+
unit: observation.unit,
|
|
60
|
+
observedAt: retrievedAt,
|
|
61
|
+
attributes: observationAttributes(snapshotId, observation),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
this.metrics.record({
|
|
65
|
+
source: metricSource(sourceId),
|
|
66
|
+
scope: SNAPSHOT_SCOPE,
|
|
67
|
+
metric: COMPLETE_METRIC,
|
|
68
|
+
value: observations.length,
|
|
69
|
+
unit: "count",
|
|
70
|
+
observedAt: retrievedAt,
|
|
71
|
+
attributes: { snapshotId, sourceId, retrievedAt },
|
|
72
|
+
});
|
|
73
|
+
return { sourceId, snapshotId, retrievedAt, publishedAt: retrievedAt, observations: structuredClone(observations) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
latest(sourceId: string): BenchmarkSnapshot | null {
|
|
77
|
+
const marker = this.metrics.query({ source: metricSource(sourceId), scope: SNAPSHOT_SCOPE, metric: COMPLETE_METRIC, order: "desc", limit: 1 })[0];
|
|
78
|
+
if (!marker) return null;
|
|
79
|
+
const rows = this.metrics.query({ source: metricSource(sourceId), until: marker.observedAt, order: "desc", limit: BENCHMARK_STORE_QUERY_LIMIT });
|
|
80
|
+
const markerIndex = rows.findIndex((row) => row.id === marker.id);
|
|
81
|
+
if (markerIndex < 0) return null;
|
|
82
|
+
const snapshotId = marker.attributes["snapshotId"];
|
|
83
|
+
const expectedCount = marker.value;
|
|
84
|
+
if (typeof snapshotId !== "string" || typeof expectedCount !== "number" || !Number.isSafeInteger(expectedCount) || expectedCount < 0) return null;
|
|
85
|
+
const matching = rows.slice(markerIndex + 1).filter((row) => row.attributes["snapshotId"] === snapshotId).slice(0, expectedCount);
|
|
86
|
+
if (matching.length !== expectedCount) return null;
|
|
87
|
+
try {
|
|
88
|
+
return {
|
|
89
|
+
sourceId,
|
|
90
|
+
snapshotId,
|
|
91
|
+
retrievedAt: marker.observedAt,
|
|
92
|
+
publishedAt: marker.observedAt,
|
|
93
|
+
observations: matching.reverse().map(decodeObservation),
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BENCHMARK_MAX_MODELS_PER_SOURCE,
|
|
3
|
+
BENCHMARK_REFRESH_INTERVAL_MS,
|
|
4
|
+
BENCHMARK_SOURCE_MAX_RESPONSE_BYTES,
|
|
5
|
+
} from "../constants.ts";
|
|
6
|
+
import { normalizeModelIdentity, validateBenchmarkObservation, type BenchmarkObservation, type BenchmarkSourceSnapshot } from "../domain/benchmark.ts";
|
|
7
|
+
import type { BenchmarkSource } from "../ports/benchmark-source.ts";
|
|
8
|
+
import { contractRecord } from "../providers/openrouter-contracts.ts";
|
|
9
|
+
import type { OpenRouterBenchmarkTransport } from "./openrouter-benchmark-source.ts";
|
|
10
|
+
|
|
11
|
+
const SOURCE_ID = "openrouter-artificial-analysis";
|
|
12
|
+
const ENDPOINT = `https://openrouter.ai/api/v1/benchmarks?source=artificial-analysis&task_type=coding&max_results=${BENCHMARK_MAX_MODELS_PER_SOURCE}`;
|
|
13
|
+
|
|
14
|
+
function requiredText(value: unknown, name: string): string {
|
|
15
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 500) throw new Error(`OpenRouter benchmark ${name} schema changed`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requiredNumber(value: unknown, name: string): number {
|
|
20
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`OpenRouter benchmark ${name} schema changed`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function price(value: unknown, name: string): number {
|
|
25
|
+
const parsed = Number(requiredText(value, name));
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed < 0) throw new Error(`OpenRouter benchmark ${name} schema changed`);
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class OpenRouterBenchmarkIndexSource implements BenchmarkSource {
|
|
31
|
+
readonly id = SOURCE_ID;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly apiKey: string,
|
|
35
|
+
private readonly transport: OpenRouterBenchmarkTransport = fetch,
|
|
36
|
+
private readonly clock: () => number = Date.now,
|
|
37
|
+
) {
|
|
38
|
+
if (apiKey.length === 0) throw new Error("OpenRouter API key is required for benchmark indices");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async fetch(): Promise<BenchmarkSourceSnapshot> {
|
|
42
|
+
const response = await this.transport(new Request(ENDPOINT, { headers: { authorization: `Bearer ${this.apiKey}` } }));
|
|
43
|
+
if (!response.ok) throw new Error(`OpenRouter benchmarks failed with HTTP ${response.status}`);
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
if (new TextEncoder().encode(text).byteLength > BENCHMARK_SOURCE_MAX_RESPONSE_BYTES) throw new Error("OpenRouter benchmark response exceeds the size limit");
|
|
46
|
+
let payload: unknown;
|
|
47
|
+
try { payload = JSON.parse(text); } catch { throw new Error("OpenRouter benchmark response is not valid JSON"); }
|
|
48
|
+
const root = contractRecord(payload, "benchmark response");
|
|
49
|
+
const meta = contractRecord(root["meta"], "benchmark metadata");
|
|
50
|
+
if (!Array.isArray(root["data"]) || root["data"].length === 0 || root["data"].length > BENCHMARK_MAX_MODELS_PER_SOURCE) throw new Error("OpenRouter benchmark result count is invalid");
|
|
51
|
+
if (requiredText(meta["source"], "source") !== "artificial-analysis") throw new Error("OpenRouter benchmark source schema changed");
|
|
52
|
+
const version = requiredText(meta["version"], "version");
|
|
53
|
+
const asOf = requiredText(meta["as_of"], "as-of date");
|
|
54
|
+
const publishedAt = Date.parse(asOf);
|
|
55
|
+
if (!Number.isSafeInteger(publishedAt) || publishedAt <= 0) throw new Error("OpenRouter benchmark publication date schema changed");
|
|
56
|
+
const upstreamUrl = new URL(requiredText(meta["source_url"], "source URL"));
|
|
57
|
+
if (upstreamUrl.protocol !== "https:") throw new Error("OpenRouter benchmark source URL must use HTTPS");
|
|
58
|
+
const retrievedAt = this.clock();
|
|
59
|
+
if (!Number.isSafeInteger(retrievedAt) || retrievedAt <= 0) throw new Error("benchmark retrieval time is invalid");
|
|
60
|
+
const revision = `${version}:${asOf}`;
|
|
61
|
+
const observations = root["data"].flatMap((value): BenchmarkObservation[] => {
|
|
62
|
+
const row = contractRecord(value, "benchmark row");
|
|
63
|
+
if (requiredText(row["source"], "row source") !== "artificial-analysis") throw new Error("OpenRouter benchmark row source schema changed");
|
|
64
|
+
const permaslug = requiredText(row["model_permaslug"], "model permaslug");
|
|
65
|
+
const separator = permaslug.indexOf("/");
|
|
66
|
+
if (separator <= 0 || separator === permaslug.length - 1) throw new Error("OpenRouter benchmark model identity schema changed");
|
|
67
|
+
const model = normalizeModelIdentity(permaslug.slice(0, separator), permaslug.slice(separator + 1), [`openrouter/${permaslug}`]);
|
|
68
|
+
const pricing = contractRecord(row["pricing"], "benchmark pricing");
|
|
69
|
+
const provenance = {
|
|
70
|
+
sourceId: SOURCE_ID,
|
|
71
|
+
sourceType: "independent" as const,
|
|
72
|
+
publisher: "Artificial Analysis via OpenRouter",
|
|
73
|
+
url: ENDPOINT,
|
|
74
|
+
revision,
|
|
75
|
+
publishedAt,
|
|
76
|
+
retrievedAt,
|
|
77
|
+
freshUntil: retrievedAt + BENCHMARK_REFRESH_INTERVAL_MS,
|
|
78
|
+
license: "OpenRouter API terms; upstream terms apply",
|
|
79
|
+
confidence: 0.8,
|
|
80
|
+
};
|
|
81
|
+
const common = { model, provenance };
|
|
82
|
+
const methodology = { basis: "Artificial Analysis index via OpenRouter", upstreamUrl: upstreamUrl.toString(), version, asOf };
|
|
83
|
+
return [
|
|
84
|
+
validateBenchmarkObservation({ ...common, dimension: "quality-coding", value: requiredNumber(row["coding_index"], "coding index"), unit: "ratio", methodology }),
|
|
85
|
+
validateBenchmarkObservation({ ...common, dimension: "quality-general", value: requiredNumber(row["intelligence_index"], "intelligence index"), unit: "ratio", methodology }),
|
|
86
|
+
validateBenchmarkObservation({ ...common, dimension: "quality-planning", value: requiredNumber(row["agentic_index"], "agentic index"), unit: "ratio", methodology }),
|
|
87
|
+
validateBenchmarkObservation({ ...common, dimension: "price-input", value: price(pricing["prompt"], "prompt pricing"), unit: "usd", methodology: { ...methodology, basis: "OpenRouter USD per input token" } }),
|
|
88
|
+
validateBenchmarkObservation({ ...common, dimension: "price-output", value: price(pricing["completion"], "completion pricing"), unit: "usd", methodology: { ...methodology, basis: "OpenRouter USD per output token" } }),
|
|
89
|
+
];
|
|
90
|
+
});
|
|
91
|
+
return { sourceId: this.id, snapshotId: `${this.id}:${revision}`, retrievedAt, observations };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BENCHMARK_MAX_MODELS_PER_SOURCE,
|
|
3
|
+
BENCHMARK_REFRESH_INTERVAL_MS,
|
|
4
|
+
BENCHMARK_SOURCE_MAX_RESPONSE_BYTES,
|
|
5
|
+
BENCHMARK_SOURCE_MAX_TOTAL_RESPONSE_BYTES,
|
|
6
|
+
} from "../constants.ts";
|
|
7
|
+
import { normalizeModelIdentity, validateBenchmarkObservation, type BenchmarkObservation, type BenchmarkSourceSnapshot } from "../domain/benchmark.ts";
|
|
8
|
+
import type { BenchmarkSource } from "../ports/benchmark-source.ts";
|
|
9
|
+
import { contractRecord, parseOpenRouterModels, type OpenRouterModel } from "../providers/openrouter-contracts.ts";
|
|
10
|
+
|
|
11
|
+
const OPENROUTER_MODELS_BASE_URL = "https://openrouter.ai/api/v1/models";
|
|
12
|
+
const OPENROUTER_MODELS_URL = `${OPENROUTER_MODELS_BASE_URL}?limit=${BENCHMARK_MAX_MODELS_PER_SOURCE}`;
|
|
13
|
+
const OPENROUTER_LATENCY_URL = `${OPENROUTER_MODELS_URL}&sort=latency-low-to-high`;
|
|
14
|
+
const OPENROUTER_THROUGHPUT_URL = `${OPENROUTER_MODELS_URL}&sort=throughput-high-to-low`;
|
|
15
|
+
const SOURCE_ID = "openrouter-models";
|
|
16
|
+
|
|
17
|
+
export type OpenRouterBenchmarkTransport = (request: Request) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
function identity(model: OpenRouterModel) {
|
|
20
|
+
const separator = model.id.indexOf("/");
|
|
21
|
+
if (separator <= 0 || separator === model.id.length - 1) throw new Error("OpenRouter model identity schema changed");
|
|
22
|
+
const provider = model.id.slice(0, separator);
|
|
23
|
+
const modelId = model.id.slice(separator + 1);
|
|
24
|
+
const aliases = [`openrouter/${model.id}`, ...(model.canonicalSlug === model.id ? [] : [model.canonicalSlug])];
|
|
25
|
+
return normalizeModelIdentity(provider, modelId, aliases);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function provenance(retrievedAt: number, revision: string, url: string, confidence: number) {
|
|
29
|
+
return {
|
|
30
|
+
sourceId: SOURCE_ID,
|
|
31
|
+
sourceType: "marketplace" as const,
|
|
32
|
+
publisher: "OpenRouter",
|
|
33
|
+
url,
|
|
34
|
+
revision,
|
|
35
|
+
publishedAt: null,
|
|
36
|
+
retrievedAt,
|
|
37
|
+
freshUntil: retrievedAt + BENCHMARK_REFRESH_INTERVAL_MS,
|
|
38
|
+
license: "OpenRouter API terms",
|
|
39
|
+
confidence,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function modelObservations(model: OpenRouterModel, retrievedAt: number, revision: string): BenchmarkObservation[] {
|
|
44
|
+
const common = { model: identity(model), provenance: provenance(retrievedAt, revision, OPENROUTER_MODELS_URL, 0.9) };
|
|
45
|
+
return [
|
|
46
|
+
validateBenchmarkObservation({ ...common, dimension: "context-window", value: model.contextLength, unit: "tokens", methodology: { basis: "OpenRouter model context_length" } }),
|
|
47
|
+
...(model.maxCompletionTokens === null ? [] : [validateBenchmarkObservation({ ...common, dimension: "max-output", value: model.maxCompletionTokens, unit: "tokens", methodology: { basis: "OpenRouter top_provider max_completion_tokens" } })]),
|
|
48
|
+
validateBenchmarkObservation({ ...common, dimension: "price-input", value: model.pricing.prompt, unit: "usd", methodology: { basis: "USD per input token" } }),
|
|
49
|
+
validateBenchmarkObservation({ ...common, dimension: "price-output", value: model.pricing.completion, unit: "usd", methodology: { basis: "USD per output token" } }),
|
|
50
|
+
validateBenchmarkObservation({ ...common, dimension: "parameter-count", value: model.supportedParameters.length, unit: "count", methodology: { basis: "OpenRouter supported_parameters", parameters: [...model.supportedParameters].sort() } }),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rankObservations(models: OpenRouterModel[], dimension: "latency-rank" | "throughput-rank", retrievedAt: number, revision: string, url: string): BenchmarkObservation[] {
|
|
55
|
+
return models.map((model, index) => validateBenchmarkObservation({
|
|
56
|
+
model: identity(model),
|
|
57
|
+
dimension,
|
|
58
|
+
value: index + 1,
|
|
59
|
+
unit: "count",
|
|
60
|
+
provenance: provenance(retrievedAt, revision, url, 0.7),
|
|
61
|
+
methodology: { basis: dimension === "latency-rank" ? "OpenRouter p50 TTFT server-side ordering" : "OpenRouter p50 throughput server-side ordering", rank: index + 1 },
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ModelResponse {
|
|
66
|
+
models: OpenRouterModel[];
|
|
67
|
+
etag: string | null;
|
|
68
|
+
bytes: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class OpenRouterBenchmarkSource implements BenchmarkSource {
|
|
72
|
+
readonly id = SOURCE_ID;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly transport: OpenRouterBenchmarkTransport = fetch,
|
|
76
|
+
private readonly clock: () => number = Date.now,
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
private async readModels(url: string): Promise<ModelResponse> {
|
|
80
|
+
const response = await this.transport(new Request(url));
|
|
81
|
+
if (!response.ok) throw new Error(`OpenRouter models failed with HTTP ${response.status}`);
|
|
82
|
+
const text = await response.text();
|
|
83
|
+
const bytes = new TextEncoder().encode(text).byteLength;
|
|
84
|
+
if (bytes > BENCHMARK_SOURCE_MAX_RESPONSE_BYTES) throw new Error("OpenRouter models response exceeds the size limit");
|
|
85
|
+
let payload: unknown;
|
|
86
|
+
try { payload = JSON.parse(text); } catch { throw new Error("OpenRouter models response is not valid JSON"); }
|
|
87
|
+
const root = contractRecord(payload, "models response");
|
|
88
|
+
if (!Array.isArray(root["data"]) || root["data"].length === 0 || root["data"].length > BENCHMARK_MAX_MODELS_PER_SOURCE) throw new Error("OpenRouter models response exceeds the model limit");
|
|
89
|
+
return { models: parseOpenRouterModels(payload), etag: response.headers.get("etag")?.slice(0, 120) ?? null, bytes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async fetch(): Promise<BenchmarkSourceSnapshot> {
|
|
93
|
+
const [catalog, latency, throughput] = await Promise.all([
|
|
94
|
+
this.readModels(OPENROUTER_MODELS_URL),
|
|
95
|
+
this.readModels(OPENROUTER_LATENCY_URL),
|
|
96
|
+
this.readModels(OPENROUTER_THROUGHPUT_URL),
|
|
97
|
+
]);
|
|
98
|
+
if (catalog.bytes + latency.bytes + throughput.bytes > BENCHMARK_SOURCE_MAX_TOTAL_RESPONSE_BYTES) throw new Error("OpenRouter model evidence exceeds the total size limit");
|
|
99
|
+
const retrievedAt = this.clock();
|
|
100
|
+
if (!Number.isSafeInteger(retrievedAt) || retrievedAt <= 0) throw new Error("benchmark retrieval time is invalid");
|
|
101
|
+
const revision = catalog.etag || latency.etag || throughput.etag || `retrieved:${retrievedAt}`;
|
|
102
|
+
const observations = [
|
|
103
|
+
...catalog.models.flatMap((model) => modelObservations(model, retrievedAt, revision)),
|
|
104
|
+
...rankObservations(latency.models, "latency-rank", retrievedAt, revision, OPENROUTER_LATENCY_URL),
|
|
105
|
+
...rankObservations(throughput.models, "throughput-rank", retrievedAt, revision, OPENROUTER_THROUGHPUT_URL),
|
|
106
|
+
];
|
|
107
|
+
return { sourceId: this.id, snapshotId: `${this.id}:${revision}`, retrievedAt, observations };
|
|
108
|
+
}
|
|
109
|
+
}
|