@ymys4ever/opencode-throughput-v2 4.0.0 → 4.0.2
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/index.js +698 -0
- package/dist/tui.js +663 -0
- package/package.json +9 -9
- package/src/benchmark.ts +0 -112
- package/src/core/events.ts +0 -141
- package/src/core/log-codec.ts +0 -90
- package/src/core/ownership.ts +0 -13
- package/src/core/stats.ts +0 -105
- package/src/core/step-tracker.ts +0 -204
- package/src/format/entry.ts +0 -39
- package/src/format/perf.ts +0 -14
- package/src/index.ts +0 -19
- package/src/io/log-store.ts +0 -55
- package/src/io/perf-file.ts +0 -30
- package/src/plugins/throughput.ts +0 -124
- package/src/runtime/pipeline.ts +0 -163
- package/src/tui/controller.ts +0 -191
- package/src/tui.tsx +0 -36
- package/src/types.ts +0 -44
- package/src/ui/sidebar.ts +0 -90
- package/src/ui/throughput-widget.tsx +0 -95
package/dist/index.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// src/core/stats.ts
|
|
2
|
+
function aggregateAt(entries, now) {
|
|
3
|
+
const groups = new Map;
|
|
4
|
+
for (const entry of entries) {
|
|
5
|
+
let g = groups.get(entry.model);
|
|
6
|
+
if (!g) {
|
|
7
|
+
g = {
|
|
8
|
+
count: 0,
|
|
9
|
+
ttft: newMetricFold(),
|
|
10
|
+
tps: newMetricFold(),
|
|
11
|
+
latency: newMetricFold(),
|
|
12
|
+
totalInputTokens: 0,
|
|
13
|
+
totalOutputTokens: 0,
|
|
14
|
+
totalReasoningTokens: 0,
|
|
15
|
+
totalCacheReadTokens: 0,
|
|
16
|
+
totalCacheWriteTokens: 0,
|
|
17
|
+
totalCost: 0
|
|
18
|
+
};
|
|
19
|
+
groups.set(entry.model, g);
|
|
20
|
+
}
|
|
21
|
+
g.count++;
|
|
22
|
+
if (entry.ttft_ms != null)
|
|
23
|
+
foldMetric(g.ttft, entry.ttft_ms);
|
|
24
|
+
if (entry.tps != null)
|
|
25
|
+
foldMetric(g.tps, entry.tps);
|
|
26
|
+
if (entry.latency_ms != null)
|
|
27
|
+
foldMetric(g.latency, entry.latency_ms);
|
|
28
|
+
g.totalInputTokens += entry.inputTokens;
|
|
29
|
+
g.totalOutputTokens += entry.outputTokens;
|
|
30
|
+
g.totalReasoningTokens += entry.reasoningTokens;
|
|
31
|
+
g.totalCacheReadTokens += entry.cacheReadTokens;
|
|
32
|
+
g.totalCacheWriteTokens += entry.cacheWriteTokens;
|
|
33
|
+
g.totalCost += entry.cost;
|
|
34
|
+
}
|
|
35
|
+
const result = new Map;
|
|
36
|
+
for (const [model, g] of groups) {
|
|
37
|
+
result.set(model, {
|
|
38
|
+
model,
|
|
39
|
+
count: g.count,
|
|
40
|
+
avgTTFT: metricAvg(g.ttft),
|
|
41
|
+
minTTFT: metricMin(g.ttft),
|
|
42
|
+
maxTTFT: metricMax(g.ttft),
|
|
43
|
+
avgTPS: metricAvg(g.tps),
|
|
44
|
+
maxTPS: metricMax(g.tps),
|
|
45
|
+
avgLatency: metricAvg(g.latency),
|
|
46
|
+
minLatency: metricMin(g.latency),
|
|
47
|
+
maxLatency: metricMax(g.latency),
|
|
48
|
+
totalInputTokens: g.totalInputTokens,
|
|
49
|
+
totalOutputTokens: g.totalOutputTokens,
|
|
50
|
+
totalReasoningTokens: g.totalReasoningTokens,
|
|
51
|
+
totalCacheReadTokens: g.totalCacheReadTokens,
|
|
52
|
+
totalCacheWriteTokens: g.totalCacheWriteTokens,
|
|
53
|
+
totalCost: g.totalCost,
|
|
54
|
+
lastUpdated: now
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
function newMetricFold() {
|
|
60
|
+
return { sum: 0, count: 0, min: Infinity, max: -Infinity };
|
|
61
|
+
}
|
|
62
|
+
function foldMetric(m, v) {
|
|
63
|
+
m.sum += v;
|
|
64
|
+
m.count++;
|
|
65
|
+
if (v < m.min)
|
|
66
|
+
m.min = v;
|
|
67
|
+
if (v > m.max)
|
|
68
|
+
m.max = v;
|
|
69
|
+
}
|
|
70
|
+
function metricAvg(m) {
|
|
71
|
+
return m.count > 0 ? m.sum / m.count : null;
|
|
72
|
+
}
|
|
73
|
+
function metricMin(m) {
|
|
74
|
+
return m.count > 0 ? m.min : null;
|
|
75
|
+
}
|
|
76
|
+
function metricMax(m) {
|
|
77
|
+
return m.count > 0 ? m.max : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/plugins/throughput.ts
|
|
81
|
+
import { Plugin } from "@opencode/plugin";
|
|
82
|
+
|
|
83
|
+
// src/core/step-tracker.ts
|
|
84
|
+
function getModelKey(providerID, modelID) {
|
|
85
|
+
return `${providerID}/${modelID}`;
|
|
86
|
+
}
|
|
87
|
+
var ZERO_TOKENS = {
|
|
88
|
+
input: 0,
|
|
89
|
+
output: 0,
|
|
90
|
+
reasoning: 0,
|
|
91
|
+
cache: { read: 0, write: 0 }
|
|
92
|
+
};
|
|
93
|
+
function buildLogEntry(state, eventAt, terminal, recordedAt) {
|
|
94
|
+
const tokens = terminal.tokens ?? ZERO_TOKENS;
|
|
95
|
+
const latencyMs = eventAt - state.started;
|
|
96
|
+
const ttftMs = state.firstOutput != null ? state.firstOutput - state.started : null;
|
|
97
|
+
const genEnd = state.streamEnded ?? eventAt;
|
|
98
|
+
const genTimeMs = state.firstOutput != null ? genEnd - state.firstOutput : eventAt - state.started;
|
|
99
|
+
const tps = tokens.output > 0 && genTimeMs > 0 ? tokens.output / genTimeMs * 1000 : null;
|
|
100
|
+
return {
|
|
101
|
+
ts: new Date(recordedAt).toISOString(),
|
|
102
|
+
model: state.modelKey,
|
|
103
|
+
providerID: state.providerID,
|
|
104
|
+
modelID: state.modelID,
|
|
105
|
+
sessionID: terminal.sessionID,
|
|
106
|
+
messageID: terminal.assistantMessageID,
|
|
107
|
+
ttft_ms: ttftMs,
|
|
108
|
+
tps,
|
|
109
|
+
latency_ms: latencyMs,
|
|
110
|
+
inputTokens: tokens.input,
|
|
111
|
+
outputTokens: tokens.output,
|
|
112
|
+
reasoningTokens: tokens.reasoning,
|
|
113
|
+
cacheReadTokens: tokens.cache.read,
|
|
114
|
+
cacheWriteTokens: tokens.cache.write,
|
|
115
|
+
cost: terminal.cost ?? 0,
|
|
116
|
+
finish: terminal.finish ?? "error"
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function buildAbortedEntry(state, assistantMessageID, now) {
|
|
120
|
+
return buildLogEntry(state, now, { sessionID: state.sessionID, assistantMessageID, finish: "aborted" }, now);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class StepTracker {
|
|
124
|
+
active = new Map;
|
|
125
|
+
parked = new Map;
|
|
126
|
+
activeTtlMs;
|
|
127
|
+
constructor(options = {}) {
|
|
128
|
+
const ttl = options.activeTtlMs ?? 600000;
|
|
129
|
+
this.activeTtlMs = ttl > 0 ? ttl : 0;
|
|
130
|
+
}
|
|
131
|
+
start(data) {
|
|
132
|
+
this.active.set(data.assistantMessageID, {
|
|
133
|
+
sessionID: data.sessionID,
|
|
134
|
+
started: data.started,
|
|
135
|
+
providerID: data.model.providerID,
|
|
136
|
+
modelID: data.model.id,
|
|
137
|
+
modelKey: getModelKey(data.model.providerID, data.model.id),
|
|
138
|
+
firstOutput: null,
|
|
139
|
+
streamEnded: null
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
markFirstOutput(assistantMessageID, created) {
|
|
143
|
+
const state = this.lookup(assistantMessageID);
|
|
144
|
+
if (state && state.firstOutput == null)
|
|
145
|
+
state.firstOutput = created;
|
|
146
|
+
}
|
|
147
|
+
markStreamed(assistantMessageID, created) {
|
|
148
|
+
const state = this.lookup(assistantMessageID);
|
|
149
|
+
if (state && state.streamEnded == null)
|
|
150
|
+
state.streamEnded = created;
|
|
151
|
+
}
|
|
152
|
+
settle(assistantMessageID, eventAt, terminal, recordedAt) {
|
|
153
|
+
const activeState = this.active.get(assistantMessageID);
|
|
154
|
+
if (activeState) {
|
|
155
|
+
this.active.delete(assistantMessageID);
|
|
156
|
+
return buildLogEntry(activeState, eventAt, terminal, recordedAt);
|
|
157
|
+
}
|
|
158
|
+
const parked = this.parked.get(assistantMessageID);
|
|
159
|
+
if (parked) {
|
|
160
|
+
this.parked.delete(assistantMessageID);
|
|
161
|
+
return buildLogEntry(parked.state, eventAt, terminal, recordedAt);
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
parkSession(sessionID, now, ttlMs) {
|
|
166
|
+
const deadline = now + ttlMs;
|
|
167
|
+
for (const [id, state] of this.active) {
|
|
168
|
+
if (state.sessionID === sessionID) {
|
|
169
|
+
this.parked.set(id, { state, deadline });
|
|
170
|
+
this.active.delete(id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
collectExpired(now) {
|
|
175
|
+
const expired = [];
|
|
176
|
+
for (const [id, parked] of this.parked) {
|
|
177
|
+
if (parked.deadline <= now) {
|
|
178
|
+
expired.push({ assistantMessageID: id, state: parked.state });
|
|
179
|
+
this.parked.delete(id);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (this.activeTtlMs > 0) {
|
|
183
|
+
for (const [id, state] of this.active) {
|
|
184
|
+
if (now - state.started >= this.activeTtlMs) {
|
|
185
|
+
expired.push({ assistantMessageID: id, state });
|
|
186
|
+
this.active.delete(id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return expired;
|
|
191
|
+
}
|
|
192
|
+
nextDeadline() {
|
|
193
|
+
let next = null;
|
|
194
|
+
const consider = (deadline) => {
|
|
195
|
+
if (next == null || deadline < next)
|
|
196
|
+
next = deadline;
|
|
197
|
+
};
|
|
198
|
+
for (const { deadline } of this.parked.values())
|
|
199
|
+
consider(deadline);
|
|
200
|
+
if (this.activeTtlMs > 0) {
|
|
201
|
+
for (const { started } of this.active.values())
|
|
202
|
+
consider(started + this.activeTtlMs);
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
get size() {
|
|
207
|
+
return this.active.size + this.parked.size;
|
|
208
|
+
}
|
|
209
|
+
lookup(assistantMessageID) {
|
|
210
|
+
return this.active.get(assistantMessageID) ?? this.parked.get(assistantMessageID)?.state;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/core/events.ts
|
|
215
|
+
var SUBSCRIBED_EVENT_TYPES = [
|
|
216
|
+
"session.step.started",
|
|
217
|
+
"session.text.started",
|
|
218
|
+
"session.reasoning.started",
|
|
219
|
+
"session.step.streamed",
|
|
220
|
+
"session.step.ended",
|
|
221
|
+
"session.step.failed",
|
|
222
|
+
"session.idle"
|
|
223
|
+
];
|
|
224
|
+
function normalizeEvent(raw) {
|
|
225
|
+
if (typeof raw === "object" && raw != null && "type" in raw) {
|
|
226
|
+
const type = raw.type;
|
|
227
|
+
if (typeof type === "string" && SUBSCRIBED_EVENT_TYPES.includes(type)) {
|
|
228
|
+
return { ok: true, event: raw };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ok: false, reason: "unknown" };
|
|
232
|
+
}
|
|
233
|
+
function applyThroughputEvent(tracker, ev, deps) {
|
|
234
|
+
switch (ev.type) {
|
|
235
|
+
case "session.step.started":
|
|
236
|
+
tracker.start(ev.data);
|
|
237
|
+
return null;
|
|
238
|
+
case "session.text.started":
|
|
239
|
+
case "session.reasoning.started":
|
|
240
|
+
tracker.markFirstOutput(ev.data.assistantMessageID, ev.created);
|
|
241
|
+
return null;
|
|
242
|
+
case "session.step.streamed":
|
|
243
|
+
tracker.markStreamed(ev.data.assistantMessageID, ev.created);
|
|
244
|
+
return null;
|
|
245
|
+
case "session.step.ended":
|
|
246
|
+
case "session.step.failed":
|
|
247
|
+
return tracker.settle(ev.data.assistantMessageID, ev.created, ev.data, deps.recordedAt);
|
|
248
|
+
case "session.idle":
|
|
249
|
+
tracker.parkSession(ev.data.sessionID, ev.created, deps.abortTtlMs);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/runtime/pipeline.ts
|
|
256
|
+
function makeSafeOnError(onError) {
|
|
257
|
+
return (e) => {
|
|
258
|
+
try {
|
|
259
|
+
onError(e);
|
|
260
|
+
} catch (reporterError) {
|
|
261
|
+
console.error(reporterError);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function createPipeline(deps) {
|
|
266
|
+
const timers = deps.timers ?? {
|
|
267
|
+
set: (cb, ms) => setTimeout(cb, ms),
|
|
268
|
+
clear: (h) => clearTimeout(h)
|
|
269
|
+
};
|
|
270
|
+
const safeOnError = makeSafeOnError(deps.onError);
|
|
271
|
+
const { tracker, now, abortTtlMs, onEntry } = deps;
|
|
272
|
+
let flushTimer = null;
|
|
273
|
+
let disposed = false;
|
|
274
|
+
function drainExpired() {
|
|
275
|
+
for (const { assistantMessageID, state } of tracker.collectExpired(now())) {
|
|
276
|
+
try {
|
|
277
|
+
onEntry(buildAbortedEntry(state, assistantMessageID, now()));
|
|
278
|
+
} catch (e) {
|
|
279
|
+
safeOnError(e);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function rearm() {
|
|
284
|
+
if (flushTimer != null)
|
|
285
|
+
timers.clear(flushTimer);
|
|
286
|
+
flushTimer = null;
|
|
287
|
+
if (disposed)
|
|
288
|
+
return;
|
|
289
|
+
const next = tracker.nextDeadline();
|
|
290
|
+
if (next == null)
|
|
291
|
+
return;
|
|
292
|
+
flushTimer = timers.set(() => {
|
|
293
|
+
flushTimer = null;
|
|
294
|
+
if (disposed)
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
drainExpired();
|
|
298
|
+
} catch (e) {
|
|
299
|
+
safeOnError(e);
|
|
300
|
+
} finally {
|
|
301
|
+
rearm();
|
|
302
|
+
}
|
|
303
|
+
}, Math.max(0, next - now()));
|
|
304
|
+
}
|
|
305
|
+
function dispatch(raw) {
|
|
306
|
+
if (disposed)
|
|
307
|
+
return;
|
|
308
|
+
let owned;
|
|
309
|
+
try {
|
|
310
|
+
owned = deps.owns(raw);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
safeOnError(e);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (!owned)
|
|
316
|
+
return;
|
|
317
|
+
const n = normalizeEvent(raw);
|
|
318
|
+
if (!n.ok && n.reason === "malformed") {
|
|
319
|
+
safeOnError(new Error("throughput: malformed event rejected"));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (n.ok) {
|
|
324
|
+
const recordedAt = now();
|
|
325
|
+
const entry = applyThroughputEvent(tracker, n.event, { recordedAt, abortTtlMs });
|
|
326
|
+
if (entry)
|
|
327
|
+
onEntry(entry);
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
safeOnError(e);
|
|
331
|
+
} finally {
|
|
332
|
+
drainExpired();
|
|
333
|
+
rearm();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function dispose() {
|
|
337
|
+
if (disposed)
|
|
338
|
+
return;
|
|
339
|
+
disposed = true;
|
|
340
|
+
if (flushTimer != null) {
|
|
341
|
+
timers.clear(flushTimer);
|
|
342
|
+
flushTimer = null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { dispatch, dispose };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/core/ownership.ts
|
|
349
|
+
function makeOwns(selfDir) {
|
|
350
|
+
return (ev) => {
|
|
351
|
+
const dir = ev.location?.directory;
|
|
352
|
+
return dir == null || selfDir == null || dir === selfDir;
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/core/log-codec.ts
|
|
357
|
+
function isString(v) {
|
|
358
|
+
return typeof v === "string";
|
|
359
|
+
}
|
|
360
|
+
function isFiniteNumber(v) {
|
|
361
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
362
|
+
}
|
|
363
|
+
function decodeLogLine(line) {
|
|
364
|
+
let raw;
|
|
365
|
+
try {
|
|
366
|
+
raw = JSON.parse(line);
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (typeof raw !== "object" || raw == null)
|
|
371
|
+
return null;
|
|
372
|
+
const o = raw;
|
|
373
|
+
if (!isString(o.model) || !isString(o.providerID) || !isString(o.modelID) || !isString(o.sessionID) || !isString(o.messageID)) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const metrics = [o.ttft_ms, o.tps, o.latency_ms];
|
|
377
|
+
const amounts = [
|
|
378
|
+
o.inputTokens,
|
|
379
|
+
o.outputTokens,
|
|
380
|
+
o.reasoningTokens,
|
|
381
|
+
o.cacheReadTokens,
|
|
382
|
+
o.cacheWriteTokens,
|
|
383
|
+
o.cost
|
|
384
|
+
];
|
|
385
|
+
const validMetric = (v) => v == null || isFiniteNumber(v);
|
|
386
|
+
if (!metrics.every(validMetric) || !amounts.every(validMetric))
|
|
387
|
+
return null;
|
|
388
|
+
const metric = (v) => isFiniteNumber(v) ? v : null;
|
|
389
|
+
const amount = (v) => isFiniteNumber(v) ? v : 0;
|
|
390
|
+
return {
|
|
391
|
+
ts: isString(o.ts) ? o.ts : "",
|
|
392
|
+
model: o.model,
|
|
393
|
+
providerID: o.providerID,
|
|
394
|
+
modelID: o.modelID,
|
|
395
|
+
sessionID: o.sessionID,
|
|
396
|
+
messageID: o.messageID,
|
|
397
|
+
ttft_ms: metric(o.ttft_ms),
|
|
398
|
+
tps: metric(o.tps),
|
|
399
|
+
latency_ms: metric(o.latency_ms),
|
|
400
|
+
inputTokens: amount(o.inputTokens),
|
|
401
|
+
outputTokens: amount(o.outputTokens),
|
|
402
|
+
reasoningTokens: amount(o.reasoningTokens),
|
|
403
|
+
cacheReadTokens: amount(o.cacheReadTokens),
|
|
404
|
+
cacheWriteTokens: amount(o.cacheWriteTokens),
|
|
405
|
+
cost: amount(o.cost),
|
|
406
|
+
finish: isString(o.finish) ? o.finish : "error"
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function decodeLogContent(content) {
|
|
410
|
+
const entries = [];
|
|
411
|
+
let skipped = 0;
|
|
412
|
+
for (const line of content.split(`
|
|
413
|
+
`)) {
|
|
414
|
+
if (!line.trim())
|
|
415
|
+
continue;
|
|
416
|
+
const entry = decodeLogLine(line);
|
|
417
|
+
if (entry)
|
|
418
|
+
entries.push(entry);
|
|
419
|
+
else
|
|
420
|
+
skipped++;
|
|
421
|
+
}
|
|
422
|
+
return { entries, skipped };
|
|
423
|
+
}
|
|
424
|
+
function encodeLogEntry(entry) {
|
|
425
|
+
return JSON.stringify(entry) + `
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/io/log-store.ts
|
|
430
|
+
import fs from "fs";
|
|
431
|
+
import os from "os";
|
|
432
|
+
import path from "path";
|
|
433
|
+
function createLogStore(opts) {
|
|
434
|
+
const file = opts?.file ?? path.join(os.homedir(), ".opencode", "throughput.jsonl");
|
|
435
|
+
const onError = opts?.onError ?? ((e) => console.warn(e));
|
|
436
|
+
let dirReady = false;
|
|
437
|
+
const ensureDir = () => {
|
|
438
|
+
if (dirReady)
|
|
439
|
+
return;
|
|
440
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
441
|
+
dirReady = true;
|
|
442
|
+
};
|
|
443
|
+
return {
|
|
444
|
+
append(entry) {
|
|
445
|
+
try {
|
|
446
|
+
ensureDir();
|
|
447
|
+
fs.appendFileSync(file, encodeLogEntry(entry), "utf-8");
|
|
448
|
+
} catch (e) {
|
|
449
|
+
onError(e);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
read() {
|
|
453
|
+
let content;
|
|
454
|
+
try {
|
|
455
|
+
content = fs.readFileSync(file, "utf-8");
|
|
456
|
+
} catch (e) {
|
|
457
|
+
if (e.code === "ENOENT")
|
|
458
|
+
return { entries: [], skipped: 0 };
|
|
459
|
+
throw e;
|
|
460
|
+
}
|
|
461
|
+
return decodeLogContent(content);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/format/entry.ts
|
|
467
|
+
function formatMs(ms) {
|
|
468
|
+
if (ms == null)
|
|
469
|
+
return "N/A";
|
|
470
|
+
if (ms >= 60000)
|
|
471
|
+
return (ms / 60000).toFixed(1) + "m";
|
|
472
|
+
if (ms >= 1000)
|
|
473
|
+
return (ms / 1000).toFixed(1) + "s";
|
|
474
|
+
return ms.toFixed(0) + "ms";
|
|
475
|
+
}
|
|
476
|
+
function formatTokenCount(n) {
|
|
477
|
+
if (n >= 1e6)
|
|
478
|
+
return (n / 1e6).toFixed(2) + "M";
|
|
479
|
+
if (n >= 1000)
|
|
480
|
+
return (n / 1000).toFixed(1) + "k";
|
|
481
|
+
return n.toFixed(n >= 100 ? 0 : 1);
|
|
482
|
+
}
|
|
483
|
+
function formatNum(n) {
|
|
484
|
+
if (n == null)
|
|
485
|
+
return "N/A";
|
|
486
|
+
return formatTokenCount(n);
|
|
487
|
+
}
|
|
488
|
+
function truncate(s, max) {
|
|
489
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
490
|
+
}
|
|
491
|
+
function buildLogMsg(entry) {
|
|
492
|
+
const parts = [
|
|
493
|
+
truncate(entry.modelID, 28),
|
|
494
|
+
formatMs(entry.ttft_ms) + " TTFT",
|
|
495
|
+
formatNum(entry.tps) + " tok/s",
|
|
496
|
+
formatMs(entry.latency_ms),
|
|
497
|
+
"↑" + formatNum(entry.inputTokens) + " ↓" + formatNum(entry.outputTokens)
|
|
498
|
+
];
|
|
499
|
+
return parts.join(" | ");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/format/perf.ts
|
|
503
|
+
function formatPerfLine(entry) {
|
|
504
|
+
const finishSuffix = entry.finish && entry.finish !== "stop" ? " | " + entry.finish : "";
|
|
505
|
+
return `${entry.ts} | ${entry.modelID} | TTFT ${formatMs(entry.ttft_ms)}` + ` | TPS ${formatNum(entry.tps)} tok/s | Latency ${formatMs(entry.latency_ms)}` + ` | ↑${formatNum(entry.inputTokens)} ↓${formatNum(entry.outputTokens)}` + ` ↓r${formatNum(entry.reasoningTokens)} | Cost $${entry.cost.toFixed(4)}` + `${finishSuffix}
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/io/perf-file.ts
|
|
510
|
+
import fs2 from "fs";
|
|
511
|
+
import path2 from "path";
|
|
512
|
+
function createPerfFileWriter(directory, onError) {
|
|
513
|
+
const file = path2.join(directory, ".opencode", "throughput.md");
|
|
514
|
+
const handleError = onError ?? (() => {});
|
|
515
|
+
let dirReady = false;
|
|
516
|
+
return (entry) => {
|
|
517
|
+
try {
|
|
518
|
+
if (!dirReady) {
|
|
519
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
520
|
+
dirReady = true;
|
|
521
|
+
}
|
|
522
|
+
fs2.appendFileSync(file, formatPerfLine(entry), "utf-8");
|
|
523
|
+
} catch (e) {
|
|
524
|
+
handleError(e);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/benchmark.ts
|
|
530
|
+
async function executeBenchmark(argsRaw, deps) {
|
|
531
|
+
const args = argsRaw ?? {};
|
|
532
|
+
const store = deps?.logStore ?? createLogStore();
|
|
533
|
+
let entries;
|
|
534
|
+
let skipped = 0;
|
|
535
|
+
try {
|
|
536
|
+
const read = store.read();
|
|
537
|
+
entries = read.entries;
|
|
538
|
+
skipped = read.skipped;
|
|
539
|
+
} catch (e) {
|
|
540
|
+
return {
|
|
541
|
+
content: `Failed to read log file: ${e instanceof Error ? e.message : String(e)}`
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (entries.length === 0) {
|
|
545
|
+
return {
|
|
546
|
+
content: "No benchmark data yet. The throughput plugin needs to process at least one LLM response before stats are available."
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const keyword = args.model?.toLowerCase();
|
|
550
|
+
const filtered = keyword ? entries.filter((l) => l.model.toLowerCase().includes(keyword) || l.modelID.toLowerCase().includes(keyword)) : entries;
|
|
551
|
+
if (keyword && filtered.length === 0) {
|
|
552
|
+
return { content: `No entries match filter: ${args.model}` };
|
|
553
|
+
}
|
|
554
|
+
const last = args.last !== undefined && Number.isInteger(args.last) && args.last >= 1 ? args.last : undefined;
|
|
555
|
+
const sliced = last ? filtered.slice(-last) : filtered;
|
|
556
|
+
const byModel = new Map;
|
|
557
|
+
for (const entry of sliced) {
|
|
558
|
+
const arr = byModel.get(entry.model) ?? [];
|
|
559
|
+
arr.push(entry);
|
|
560
|
+
byModel.set(entry.model, arr);
|
|
561
|
+
}
|
|
562
|
+
const statsByModel = aggregateAt(sliced, Date.now());
|
|
563
|
+
const lines = [];
|
|
564
|
+
lines.push(`## Model Performance Benchmark`);
|
|
565
|
+
lines.push(`Total requests: ${sliced.length}`);
|
|
566
|
+
if (args.model)
|
|
567
|
+
lines.push(`Filter: ${args.model}`);
|
|
568
|
+
lines.push(``);
|
|
569
|
+
for (const [model, modelEntries] of byModel) {
|
|
570
|
+
const s = statsByModel.get(model);
|
|
571
|
+
if (!s)
|
|
572
|
+
continue;
|
|
573
|
+
lines.push(`### ${model}`);
|
|
574
|
+
lines.push(` Requests: ${modelEntries.length}`);
|
|
575
|
+
lines.push(``);
|
|
576
|
+
lines.push(` **Latency:**`);
|
|
577
|
+
lines.push(` TTFT: avg ${formatMs(s.avgTTFT)} | min ${formatMs(s.minTTFT)} | max ${formatMs(s.maxTTFT)}`);
|
|
578
|
+
lines.push(` Total: avg ${formatMs(s.avgLatency)} | min ${formatMs(s.minLatency)} | max ${formatMs(s.maxLatency)}`);
|
|
579
|
+
lines.push(``);
|
|
580
|
+
lines.push(` **Throughput:**`);
|
|
581
|
+
lines.push(` TPS: avg ${formatNum(s.avgTPS)} | max ${formatNum(s.maxTPS)}`);
|
|
582
|
+
lines.push(``);
|
|
583
|
+
lines.push(` **Tokens:**`);
|
|
584
|
+
lines.push(` Input: ${formatNum(s.totalInputTokens)}`);
|
|
585
|
+
lines.push(` Output: ${formatNum(s.totalOutputTokens)}`);
|
|
586
|
+
lines.push(` Reasoning: ${formatNum(s.totalReasoningTokens)}`);
|
|
587
|
+
lines.push(` Cache R/W: ${formatNum(s.totalCacheReadTokens)} / ${formatNum(s.totalCacheWriteTokens)}`);
|
|
588
|
+
lines.push(``);
|
|
589
|
+
lines.push(` **Cost:** $${s.totalCost.toFixed(4)}`);
|
|
590
|
+
if (modelEntries.length <= 5) {
|
|
591
|
+
lines.push(``);
|
|
592
|
+
lines.push(` Recent entries:`);
|
|
593
|
+
for (const e of modelEntries) {
|
|
594
|
+
lines.push(` ${e.ts} | ${formatMs(e.ttft_ms)} TTFT | ${formatNum(e.tps)} tok/s | ${formatMs(e.latency_ms)} | cost $${e.cost.toFixed(4)}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
lines.push(``);
|
|
598
|
+
}
|
|
599
|
+
if (skipped > 0) {
|
|
600
|
+
lines.push(`(${skipped} corrupt lines ignored)`);
|
|
601
|
+
}
|
|
602
|
+
return { content: lines.join(`
|
|
603
|
+
`) };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/plugins/throughput.ts
|
|
607
|
+
function createThroughputPlugin(options = {}) {
|
|
608
|
+
return Plugin.define({
|
|
609
|
+
id: "opencode-throughput",
|
|
610
|
+
async setup(ctx) {
|
|
611
|
+
const onError = options.onError ?? ((e) => console.error(e));
|
|
612
|
+
const tracker = new StepTracker({ activeTtlMs: options.activeTtlMs });
|
|
613
|
+
const logStore = options.logStore ?? createLogStore({ onError });
|
|
614
|
+
const writePerf = createPerfFileWriter(ctx.location.directory, onError);
|
|
615
|
+
const owns = makeOwns(ctx.location.directory);
|
|
616
|
+
const now = options.now ?? Date.now;
|
|
617
|
+
const abortTtlMs = options.abortTtlMs ?? 5000;
|
|
618
|
+
await ctx.tool.transform((editor) => {
|
|
619
|
+
editor.add({
|
|
620
|
+
name: "benchmark",
|
|
621
|
+
description: "Query model performance benchmarks including latency, throughput (TTFT, TPS), token usage, and cost. Shows stats aggregated by model.",
|
|
622
|
+
input: {
|
|
623
|
+
type: "object",
|
|
624
|
+
properties: {
|
|
625
|
+
model: {
|
|
626
|
+
type: "string",
|
|
627
|
+
description: "Filter by model name (partial match, e.g. 'claude' or 'gpt'). Omit for all models."
|
|
628
|
+
},
|
|
629
|
+
last: {
|
|
630
|
+
type: "integer",
|
|
631
|
+
minimum: 1,
|
|
632
|
+
description: "Only show the last N entries (default: show all)"
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
additionalProperties: false
|
|
636
|
+
},
|
|
637
|
+
async execute(args) {
|
|
638
|
+
return await executeBenchmark(args, { logStore });
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
const controller = new AbortController;
|
|
643
|
+
function record(entry) {
|
|
644
|
+
try {
|
|
645
|
+
logStore.append(entry);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
onError(e);
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
writePerf(entry);
|
|
651
|
+
} catch (e) {
|
|
652
|
+
onError(e);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const pipeline = createPipeline({
|
|
656
|
+
tracker,
|
|
657
|
+
owns,
|
|
658
|
+
now,
|
|
659
|
+
abortTtlMs,
|
|
660
|
+
onEntry: record,
|
|
661
|
+
onError,
|
|
662
|
+
timers: {
|
|
663
|
+
set: (cb, ms) => setTimeout(cb, ms),
|
|
664
|
+
clear: (h) => clearTimeout(h)
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
const task = (async () => {
|
|
668
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
669
|
+
pipeline.dispatch(ev);
|
|
670
|
+
}
|
|
671
|
+
})();
|
|
672
|
+
const reportSubscriptionDeath = makeSafeOnError(onError);
|
|
673
|
+
task.catch((e) => {
|
|
674
|
+
reportSubscriptionDeath(new Error(`throughput: event subscription died: ${e instanceof Error ? e.message : String(e)}`, {
|
|
675
|
+
cause: e
|
|
676
|
+
}));
|
|
677
|
+
});
|
|
678
|
+
return () => {
|
|
679
|
+
pipeline.dispose();
|
|
680
|
+
controller.abort();
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
var ThroughputPlugin = createThroughputPlugin();
|
|
686
|
+
var throughput_default = ThroughputPlugin;
|
|
687
|
+
|
|
688
|
+
// src/index.ts
|
|
689
|
+
function aggregate(entries) {
|
|
690
|
+
return aggregateAt(entries, Date.now());
|
|
691
|
+
}
|
|
692
|
+
export {
|
|
693
|
+
throughput_default as default,
|
|
694
|
+
createThroughputPlugin,
|
|
695
|
+
createLogStore,
|
|
696
|
+
aggregate,
|
|
697
|
+
ThroughputPlugin
|
|
698
|
+
};
|