@trazum/core 1.26.0 → 1.28.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/dist/config-schema.d.ts +7 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +4 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/context-pressure.d.ts +60 -0
- package/dist/context-pressure.d.ts.map +1 -0
- package/dist/context-pressure.js +26 -0
- package/dist/context-pressure.js.map +1 -0
- package/dist/csv.d.ts +8 -1
- package/dist/csv.d.ts.map +1 -1
- package/dist/csv.js +16 -0
- package/dist/csv.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/truncation-retry.d.ts +73 -0
- package/dist/truncation-retry.d.ts.map +1 -0
- package/dist/truncation-retry.js +90 -0
- package/dist/truncation-retry.js.map +1 -0
- package/dist/usage.d.ts +55 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +60 -2
- package/dist/usage.js.map +1 -1
- package/package.json +1 -1
- package/src/config-schema.ts +10 -1
- package/src/context-pressure.ts +89 -0
- package/src/csv.ts +18 -1
- package/src/index.ts +10 -0
- package/src/truncation-retry.ts +175 -0
- package/src/usage.ts +102 -3
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { effectivePricing, multipliersFor } from './pricing.js';
|
|
2
|
+
import { UNLABELLED } from './usage.js';
|
|
3
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
4
|
+
import type { UsageRecord } from './usage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The retry bill of truncation.
|
|
8
|
+
*
|
|
9
|
+
* ## The half of the cost the truncation line could not see
|
|
10
|
+
*
|
|
11
|
+
* `truncatedOutputUsd` prices the answers cut off at `max_tokens` — paid in
|
|
12
|
+
* full, and the attempt bought nothing. The sentence next to it has always
|
|
13
|
+
* said "frequently retried, billed again", and that second half was an
|
|
14
|
+
* assertion rather than a measurement: nothing counted the retries.
|
|
15
|
+
*
|
|
16
|
+
* This does. A truncated answer followed **within a couple of minutes by
|
|
17
|
+
* another call in the same conversation** is the shape a retry has — the
|
|
18
|
+
* user or the harness asked again, usually with a raised ceiling — and both
|
|
19
|
+
* sides of that pair are in the log. The first call's full price is money
|
|
20
|
+
* that bought a cut-off answer; the follow-up is the same question billed a
|
|
21
|
+
* second time, at conversation prices, with the history re-sent.
|
|
22
|
+
*
|
|
23
|
+
* ## What it compares, and why only that
|
|
24
|
+
*
|
|
25
|
+
* Each call is checked against the one immediately before it in the same
|
|
26
|
+
* session — the bounded-memory design `repeats.ts` uses, for the same
|
|
27
|
+
* reasons — and only when the previous call was truncated and the gap is
|
|
28
|
+
* non-negative and inside the window. Two minutes by default, wider than the
|
|
29
|
+
* repeat window: a human rephrasing after a cut-off answer takes longer than
|
|
30
|
+
* a harness retrying a timeout.
|
|
31
|
+
*
|
|
32
|
+
* ## What it refuses to conclude
|
|
33
|
+
*
|
|
34
|
+
* It cannot see content, so it cannot tell a retry from a user changing the
|
|
35
|
+
* subject right after a truncated answer. The pair is a pattern, stated as
|
|
36
|
+
* one. A single pair is not reported — one retry is an anecdote — and the
|
|
37
|
+
* per-slice denominator (`truncatedCalls` that *could* be checked) travels
|
|
38
|
+
* with the count, so "3 of 40" and "3 of 3" read as differently as they are.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Truncated answers followed by another call in the same conversation. */
|
|
42
|
+
export interface TruncationRetry {
|
|
43
|
+
label: string;
|
|
44
|
+
model: string;
|
|
45
|
+
modelName: string;
|
|
46
|
+
/** Truncated calls that were followed up inside the window. */
|
|
47
|
+
retried: number;
|
|
48
|
+
/** Truncated calls in this slice that carried a session and a clock. */
|
|
49
|
+
truncatedCalls: number;
|
|
50
|
+
/** The full price of the truncated attempts that were followed up. */
|
|
51
|
+
wastedUsd: number;
|
|
52
|
+
/** The full price of the follow-up calls. */
|
|
53
|
+
retryUsd: number;
|
|
54
|
+
/** The window the follow-up had to fall inside, in milliseconds. */
|
|
55
|
+
withinMs: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TruncationRetryOptions {
|
|
59
|
+
catalogue: PricingCatalogue;
|
|
60
|
+
on?: Date;
|
|
61
|
+
/**
|
|
62
|
+
* How close the follow-up has to be. Two minutes by default — a human
|
|
63
|
+
* rephrasing after a cut-off answer takes longer than a harness retrying,
|
|
64
|
+
* and past a couple of minutes the next call is a next question.
|
|
65
|
+
*/
|
|
66
|
+
withinMs?: number;
|
|
67
|
+
/** Slices below this many retried pairs are dropped. Default 2. */
|
|
68
|
+
minRetried?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TruncationRetryTracker {
|
|
72
|
+
add(record: UsageRecord): void;
|
|
73
|
+
finish(): TruncationRetry[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Slice {
|
|
77
|
+
retried: number;
|
|
78
|
+
truncatedCalls: number;
|
|
79
|
+
wastedUsd: number;
|
|
80
|
+
retryUsd: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An accumulator, fed in the pass a profile already makes. */
|
|
84
|
+
export function createTruncationRetryTracker(options: TruncationRetryOptions): TruncationRetryTracker {
|
|
85
|
+
const { catalogue, on = new Date(), withinMs = 120_000, minRetried = 2 } = options;
|
|
86
|
+
const slices = new Map<string, Slice>();
|
|
87
|
+
/** The previous call of each session: whether it truncated, when, its price, and whose slice it was. */
|
|
88
|
+
const previous = new Map<string, { truncated: boolean; ts: number; usd: number; key: string }>();
|
|
89
|
+
|
|
90
|
+
const priceOf = (record: UsageRecord): number | null => {
|
|
91
|
+
const model = catalogue.byId.get(record.model);
|
|
92
|
+
if (!model) return null;
|
|
93
|
+
const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
|
|
94
|
+
const rates = multipliersFor(model);
|
|
95
|
+
const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
|
|
96
|
+
return (
|
|
97
|
+
per(record.inputTokens, inputPerMTok) +
|
|
98
|
+
per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
|
|
99
|
+
per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
|
|
100
|
+
per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
|
|
101
|
+
per(record.outputTokens, outputPerMTok)
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const add = (record: UsageRecord): void => {
|
|
106
|
+
if (record.session === null || record.ts === null) return;
|
|
107
|
+
const usd = priceOf(record);
|
|
108
|
+
// An unpriced model has no dollars anywhere else in the report; a retry
|
|
109
|
+
// bill stated for it would be a figure with nothing behind it.
|
|
110
|
+
if (usd === null) return;
|
|
111
|
+
|
|
112
|
+
const key = `${record.label ?? UNLABELLED}\n${record.model}`;
|
|
113
|
+
const before = previous.get(record.session);
|
|
114
|
+
previous.set(record.session, { truncated: record.truncated === true, ts: record.ts, usd, key });
|
|
115
|
+
|
|
116
|
+
if (record.truncated === true) {
|
|
117
|
+
let slice = slices.get(key);
|
|
118
|
+
if (!slice) {
|
|
119
|
+
slice = { retried: 0, truncatedCalls: 0, wastedUsd: 0, retryUsd: 0 };
|
|
120
|
+
slices.set(key, slice);
|
|
121
|
+
}
|
|
122
|
+
slice.truncatedCalls += 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (before === undefined || !before.truncated) return;
|
|
126
|
+
const gap = record.ts - before.ts;
|
|
127
|
+
// Out of order is not a retry, and neither is tomorrow's next question.
|
|
128
|
+
if (gap < 0 || gap >= withinMs) return;
|
|
129
|
+
|
|
130
|
+
// Attributed to the slice of the *truncated* call: that is where the
|
|
131
|
+
// ceiling that caused this lives, and where the fix is applied.
|
|
132
|
+
let slice = slices.get(before.key);
|
|
133
|
+
if (!slice) {
|
|
134
|
+
slice = { retried: 0, truncatedCalls: 0, wastedUsd: 0, retryUsd: 0 };
|
|
135
|
+
slices.set(before.key, slice);
|
|
136
|
+
}
|
|
137
|
+
slice.retried += 1;
|
|
138
|
+
slice.wastedUsd += before.usd;
|
|
139
|
+
slice.retryUsd += usd;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const finish = (): TruncationRetry[] => {
|
|
143
|
+
const out: TruncationRetry[] = [];
|
|
144
|
+
for (const [key, slice] of slices) {
|
|
145
|
+
if (slice.retried < minRetried) continue;
|
|
146
|
+
const split = key.indexOf('\n');
|
|
147
|
+
const modelId = key.slice(split + 1);
|
|
148
|
+
const model = catalogue.byId.get(modelId);
|
|
149
|
+
if (!model) continue;
|
|
150
|
+
out.push({
|
|
151
|
+
label: key.slice(0, split),
|
|
152
|
+
model: modelId,
|
|
153
|
+
modelName: model.displayName,
|
|
154
|
+
retried: slice.retried,
|
|
155
|
+
truncatedCalls: slice.truncatedCalls,
|
|
156
|
+
wastedUsd: slice.wastedUsd,
|
|
157
|
+
retryUsd: slice.retryUsd,
|
|
158
|
+
withinMs,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return out.sort((a, b) => b.wastedUsd + b.retryUsd - (a.wastedUsd + a.retryUsd));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return { add, finish };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The same measurement over a list of records, for a caller holding one. */
|
|
168
|
+
export function truncationRetries(
|
|
169
|
+
records: readonly UsageRecord[],
|
|
170
|
+
options: TruncationRetryOptions,
|
|
171
|
+
): TruncationRetry[] {
|
|
172
|
+
const tracker = createTruncationRetryTracker(options);
|
|
173
|
+
for (const record of records) tracker.add(record);
|
|
174
|
+
return tracker.finish();
|
|
175
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createConversationTracker } from './conversation.js';
|
|
|
3
3
|
import { createOutputShapeTracker } from './output-shape.js';
|
|
4
4
|
import { createInputShapeTracker } from './input-shape.js';
|
|
5
5
|
import { createRepeatsTracker } from './repeats.js';
|
|
6
|
+
import { createTruncationRetryTracker } from './truncation-retry.js';
|
|
6
7
|
import { createTtlFitTracker } from './ttl-fit.js';
|
|
7
8
|
import { createSessionLedgerTracker } from './session-ledger.js';
|
|
8
9
|
import { createSessionCostTracker } from './session-cost.js';
|
|
@@ -13,6 +14,7 @@ import type { ConversationGrowth } from './conversation.js';
|
|
|
13
14
|
import type { OutputShape } from './output-shape.js';
|
|
14
15
|
import type { InputShape } from './input-shape.js';
|
|
15
16
|
import type { RepeatedTurns } from './repeats.js';
|
|
17
|
+
import type { TruncationRetry } from './truncation-retry.js';
|
|
16
18
|
import type { PricingCatalogue } from './pricing.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -313,6 +315,15 @@ export interface UsageProfileReport {
|
|
|
313
315
|
* carries neither, which is a different statement from "none happened".
|
|
314
316
|
*/
|
|
315
317
|
repeatedTurns: RepeatedTurns[];
|
|
318
|
+
/**
|
|
319
|
+
* Truncated answers followed within two minutes by another call in the
|
|
320
|
+
* same conversation — the "frequently retried, billed again" half of the
|
|
321
|
+
* truncation finding, measured instead of asserted. The wasted attempt's
|
|
322
|
+
* full price and the follow-up's travel together, with the checkable
|
|
323
|
+
* denominator. Needs a session, a clock and a stop reason; a pattern,
|
|
324
|
+
* never a certainty — the log cannot see content.
|
|
325
|
+
*/
|
|
326
|
+
truncationRetries: TruncationRetry[];
|
|
316
327
|
/**
|
|
317
328
|
* The period the log covers, when its records carry a clock, over every
|
|
318
329
|
* parsed record — priced and unpriced alike, because when a call happened is
|
|
@@ -346,6 +357,12 @@ export interface UsageProfileReport {
|
|
|
346
357
|
/** The label that spent the most this day, or null when nothing had one. */
|
|
347
358
|
topLabel: string | null;
|
|
348
359
|
topLabelUsd: number;
|
|
360
|
+
/**
|
|
361
|
+
* The day's spend per model, largest first — the series `modelMixDrift`
|
|
362
|
+
* summarises, exposed whole so a spreadsheet or a chart can draw the
|
|
363
|
+
* migration day by day instead of in two halves.
|
|
364
|
+
*/
|
|
365
|
+
byModel: Array<{ model: string; usd: number; calls: number }>;
|
|
349
366
|
}>;
|
|
350
367
|
/**
|
|
351
368
|
* Lines that are exact duplicates of an earlier line, and what they added
|
|
@@ -417,6 +434,35 @@ export interface UsageProfileReport {
|
|
|
417
434
|
* would be guessing.
|
|
418
435
|
*/
|
|
419
436
|
spendByHour: Array<{ hour: number; usd: number; calls: number }>;
|
|
437
|
+
/**
|
|
438
|
+
* How the model mix moved across this log's own span — the drift `--against`
|
|
439
|
+
* can only see with a second log.
|
|
440
|
+
*
|
|
441
|
+
* A bill can grow with no workload growing: traffic quietly migrating from
|
|
442
|
+
* the cheap model to the expensive one, a deploy that flipped a default, a
|
|
443
|
+
* fallback that became the main path. Day totals cannot show it (both
|
|
444
|
+
* models land in the same number) and per-model totals cannot either (a
|
|
445
|
+
* total has no direction). So this splits the log's days into two halves,
|
|
446
|
+
* chronologically, and states each model's **share of the priced spend** in
|
|
447
|
+
* each half, exactly.
|
|
448
|
+
*
|
|
449
|
+
* `null` — not empty — when the log has fewer than four days with priced,
|
|
450
|
+
* dated spend: two points per half is the least a "half" can honestly
|
|
451
|
+
* claim, and a drift computed over one day against one day would be
|
|
452
|
+
* weather presented as climate. The renderings decide what counts as
|
|
453
|
+
* "moved"; the data states the shares and stops. No forecast: where the
|
|
454
|
+
* mix goes next is not in the log.
|
|
455
|
+
*/
|
|
456
|
+
modelMixDrift: {
|
|
457
|
+
/** Days in each half. `firstDays + lastDays` = every day with priced spend. */
|
|
458
|
+
firstDays: number;
|
|
459
|
+
lastDays: number;
|
|
460
|
+
/** Priced spend in each half, so a share can be turned back into money. */
|
|
461
|
+
firstUsd: number;
|
|
462
|
+
lastUsd: number;
|
|
463
|
+
/** Per model, every model either half saw. Shares are of that half's spend. */
|
|
464
|
+
models: Array<{ model: string; firstShare: number; lastShare: number; firstUsd: number; lastUsd: number }>;
|
|
465
|
+
} | null;
|
|
420
466
|
/**
|
|
421
467
|
* Whether each slice's cache TTL fits how fast its turns arrive — the
|
|
422
468
|
* mechanism behind a losing cache verdict, and the one place an overlong TTL
|
|
@@ -876,6 +922,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
876
922
|
const output = createOutputShapeTracker({ catalogue, on });
|
|
877
923
|
const input = createInputShapeTracker({ catalogue, on });
|
|
878
924
|
const repeats = createRepeatsTracker({ catalogue, on });
|
|
925
|
+
const truncRetries = createTruncationRetryTracker({ catalogue, on });
|
|
879
926
|
const ttlFit = createTtlFitTracker({ catalogue, on });
|
|
880
927
|
const ledger = createSessionLedgerTracker({ catalogue, on });
|
|
881
928
|
const sessionCosts = createSessionCostTracker({ catalogue, on });
|
|
@@ -892,7 +939,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
892
939
|
let spanTo = -Infinity;
|
|
893
940
|
let spanCalls = 0;
|
|
894
941
|
/** Per UTC day: spend, calls, and spend per label. Bounded by days × labels. */
|
|
895
|
-
const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number> }>();
|
|
942
|
+
const days = new Map<string, { usd: number; calls: number; byLabel: Map<string, number>; byModel: Map<string, { usd: number; calls: number }> }>();
|
|
896
943
|
/** Per hour of the UTC day. Bounded by twenty-four entries, whatever the log. */
|
|
897
944
|
const hours = new Map<number, { usd: number; calls: number }>();
|
|
898
945
|
|
|
@@ -952,6 +999,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
952
999
|
output.add(record);
|
|
953
1000
|
input.add(record);
|
|
954
1001
|
repeats.add(record);
|
|
1002
|
+
truncRetries.add(record);
|
|
955
1003
|
ttlFit.add(record);
|
|
956
1004
|
ledger.add(record);
|
|
957
1005
|
sessionCosts.add(record);
|
|
@@ -991,13 +1039,17 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
991
1039
|
const usd = total.totalUsd - usdBefore;
|
|
992
1040
|
let entry = days.get(day);
|
|
993
1041
|
if (!entry) {
|
|
994
|
-
entry = { usd: 0, calls: 0, byLabel: new Map() };
|
|
1042
|
+
entry = { usd: 0, calls: 0, byLabel: new Map(), byModel: new Map() };
|
|
995
1043
|
days.set(day, entry);
|
|
996
1044
|
}
|
|
997
1045
|
entry.usd += usd;
|
|
998
1046
|
entry.calls += 1;
|
|
999
1047
|
const labelKey = record.label ?? UNLABELLED;
|
|
1000
1048
|
entry.byLabel.set(labelKey, (entry.byLabel.get(labelKey) ?? 0) + usd);
|
|
1049
|
+
const modelCell = entry.byModel.get(record.model) ?? { usd: 0, calls: 0 };
|
|
1050
|
+
modelCell.usd += usd;
|
|
1051
|
+
modelCell.calls += 1;
|
|
1052
|
+
entry.byModel.set(record.model, modelCell);
|
|
1001
1053
|
|
|
1002
1054
|
// The same exact per-record dollar, bucketed by hour of the UTC day.
|
|
1003
1055
|
const hour = new Date(record.ts).getUTCHours();
|
|
@@ -1050,6 +1102,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
1050
1102
|
outputShapes: output.finish(total.totalUsd),
|
|
1051
1103
|
inputShapes: input.finish(total.totalUsd),
|
|
1052
1104
|
repeatedTurns: repeats.finish(),
|
|
1105
|
+
truncationRetries: truncRetries.finish(),
|
|
1053
1106
|
span: spanCalls > 0 ? { fromMs: spanFrom, toMs: spanTo, calls: spanCalls } : null,
|
|
1054
1107
|
spendByDay: [...days.entries()]
|
|
1055
1108
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
@@ -1062,10 +1115,56 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
|
|
|
1062
1115
|
topLabelUsd = usd;
|
|
1063
1116
|
}
|
|
1064
1117
|
}
|
|
1065
|
-
return {
|
|
1118
|
+
return {
|
|
1119
|
+
day,
|
|
1120
|
+
usd: entry.usd,
|
|
1121
|
+
calls: entry.calls,
|
|
1122
|
+
topLabel,
|
|
1123
|
+
topLabelUsd,
|
|
1124
|
+
byModel: [...entry.byModel.entries()]
|
|
1125
|
+
.map(([model, cell]) => ({ model, usd: cell.usd, calls: cell.calls }))
|
|
1126
|
+
.sort((a, b) => b.usd - a.usd),
|
|
1127
|
+
};
|
|
1066
1128
|
}),
|
|
1067
1129
|
duplicateLines: duplicates,
|
|
1068
1130
|
fieldCoverage: coverage,
|
|
1131
|
+
modelMixDrift: (() => {
|
|
1132
|
+
const ordered = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
1133
|
+
if (ordered.length < 4) return null;
|
|
1134
|
+
const mid = Math.floor(ordered.length / 2);
|
|
1135
|
+
const halves = [ordered.slice(0, mid), ordered.slice(mid)] as const;
|
|
1136
|
+
const totals = halves.map((half) => half.reduce((sum, [, e]) => sum + e.usd, 0));
|
|
1137
|
+
// A half with no priced spend has no shares to state — division by zero
|
|
1138
|
+
// is not a drift, and neither is a mix over zero dollars.
|
|
1139
|
+
if (totals[0]! <= 0 || totals[1]! <= 0) return null;
|
|
1140
|
+
const perModel = new Map<string, { first: number; last: number }>();
|
|
1141
|
+
halves.forEach((half, index) => {
|
|
1142
|
+
for (const [, entry] of half) {
|
|
1143
|
+
for (const [model, dayCell] of entry.byModel) {
|
|
1144
|
+
const cell = perModel.get(model) ?? { first: 0, last: 0 };
|
|
1145
|
+
if (index === 0) cell.first += dayCell.usd;
|
|
1146
|
+
else cell.last += dayCell.usd;
|
|
1147
|
+
perModel.set(model, cell);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
return {
|
|
1152
|
+
firstDays: halves[0].length,
|
|
1153
|
+
lastDays: halves[1].length,
|
|
1154
|
+
firstUsd: totals[0]!,
|
|
1155
|
+
lastUsd: totals[1]!,
|
|
1156
|
+
models: [...perModel.entries()]
|
|
1157
|
+
.map(([model, cell]) => ({
|
|
1158
|
+
model,
|
|
1159
|
+
firstShare: cell.first / totals[0]!,
|
|
1160
|
+
lastShare: cell.last / totals[1]!,
|
|
1161
|
+
firstUsd: cell.first,
|
|
1162
|
+
lastUsd: cell.last,
|
|
1163
|
+
}))
|
|
1164
|
+
// The biggest movement first — the order a reader would act in.
|
|
1165
|
+
.sort((a, b) => Math.abs(b.lastShare - b.firstShare) - Math.abs(a.lastShare - a.firstShare)),
|
|
1166
|
+
};
|
|
1167
|
+
})(),
|
|
1069
1168
|
spendByHour: [...hours.entries()]
|
|
1070
1169
|
.sort((a, b) => a[0] - b[0])
|
|
1071
1170
|
.map(([hour, entry]) => ({ hour, usd: entry.usd, calls: entry.calls })),
|