@ymys4ever/opencode-throughput-v2 4.0.1 → 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 +6 -6
- 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/src/benchmark.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "./types.js"
|
|
2
|
-
import { aggregateAt } from "./core/stats.js"
|
|
3
|
-
import { formatMs, formatNum } from "./format/entry.js"
|
|
4
|
-
import { createLogStore, type LogStore } from "./io/log-store.js"
|
|
5
|
-
|
|
6
|
-
export interface BenchmarkDeps {
|
|
7
|
-
logStore?: LogStore
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function executeBenchmark(
|
|
11
|
-
argsRaw: unknown,
|
|
12
|
-
deps?: BenchmarkDeps
|
|
13
|
-
): Promise<{ content: string }> {
|
|
14
|
-
const args = (argsRaw ?? {}) as { model?: string; last?: number }
|
|
15
|
-
const store = deps?.logStore ?? createLogStore()
|
|
16
|
-
|
|
17
|
-
let entries: LogEntry[]
|
|
18
|
-
let skipped = 0
|
|
19
|
-
try {
|
|
20
|
-
const read = store.read()
|
|
21
|
-
entries = read.entries
|
|
22
|
-
skipped = read.skipped
|
|
23
|
-
} catch (e) {
|
|
24
|
-
return {
|
|
25
|
-
content: `Failed to read log file: ${e instanceof Error ? e.message : String(e)}`,
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (entries.length === 0) {
|
|
30
|
-
return {
|
|
31
|
-
content:
|
|
32
|
-
"No benchmark data yet. The throughput plugin needs to process at least one LLM response before stats are available.",
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const keyword = args.model?.toLowerCase()
|
|
37
|
-
const filtered = keyword
|
|
38
|
-
? entries.filter(
|
|
39
|
-
(l: LogEntry) =>
|
|
40
|
-
l.model.toLowerCase().includes(keyword) ||
|
|
41
|
-
l.modelID.toLowerCase().includes(keyword)
|
|
42
|
-
)
|
|
43
|
-
: entries
|
|
44
|
-
|
|
45
|
-
if (keyword && filtered.length === 0) {
|
|
46
|
-
return { content: `No entries match filter: ${args.model}` }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Defensive last: only a positive integer is honored, otherwise ignored.
|
|
50
|
-
const last =
|
|
51
|
-
args.last !== undefined && Number.isInteger(args.last) && args.last >= 1 ? args.last : undefined
|
|
52
|
-
const sliced = last ? filtered.slice(-last) : filtered
|
|
53
|
-
|
|
54
|
-
const byModel = new Map<string, LogEntry[]>()
|
|
55
|
-
for (const entry of sliced) {
|
|
56
|
-
const arr = byModel.get(entry.model) ?? []
|
|
57
|
-
arr.push(entry)
|
|
58
|
-
byModel.set(entry.model, arr)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const statsByModel = aggregateAt(sliced, Date.now())
|
|
62
|
-
|
|
63
|
-
const lines: string[] = []
|
|
64
|
-
lines.push(`## Model Performance Benchmark`)
|
|
65
|
-
lines.push(`Total requests: ${sliced.length}`)
|
|
66
|
-
if (args.model) lines.push(`Filter: ${args.model}`)
|
|
67
|
-
lines.push(``)
|
|
68
|
-
|
|
69
|
-
for (const [model, modelEntries] of byModel) {
|
|
70
|
-
const s = statsByModel.get(model)
|
|
71
|
-
if (!s) continue
|
|
72
|
-
lines.push(`### ${model}`)
|
|
73
|
-
lines.push(` Requests: ${modelEntries.length}`)
|
|
74
|
-
lines.push(``)
|
|
75
|
-
lines.push(` **Latency:**`)
|
|
76
|
-
lines.push(
|
|
77
|
-
` TTFT: avg ${formatMs(s.avgTTFT)} | min ${formatMs(s.minTTFT)} | max ${formatMs(s.maxTTFT)}`
|
|
78
|
-
)
|
|
79
|
-
lines.push(
|
|
80
|
-
` Total: avg ${formatMs(s.avgLatency)} | min ${formatMs(s.minLatency)} | max ${formatMs(s.maxLatency)}`
|
|
81
|
-
)
|
|
82
|
-
lines.push(``)
|
|
83
|
-
lines.push(` **Throughput:**`)
|
|
84
|
-
lines.push(` TPS: avg ${formatNum(s.avgTPS)} | max ${formatNum(s.maxTPS)}`)
|
|
85
|
-
lines.push(``)
|
|
86
|
-
lines.push(` **Tokens:**`)
|
|
87
|
-
lines.push(` Input: ${formatNum(s.totalInputTokens)}`)
|
|
88
|
-
lines.push(` Output: ${formatNum(s.totalOutputTokens)}`)
|
|
89
|
-
lines.push(` Reasoning: ${formatNum(s.totalReasoningTokens)}`)
|
|
90
|
-
lines.push(` Cache R/W: ${formatNum(s.totalCacheReadTokens)} / ${formatNum(s.totalCacheWriteTokens)}`)
|
|
91
|
-
lines.push(``)
|
|
92
|
-
lines.push(` **Cost:** $${s.totalCost.toFixed(4)}`)
|
|
93
|
-
|
|
94
|
-
if (modelEntries.length <= 5) {
|
|
95
|
-
lines.push(``)
|
|
96
|
-
lines.push(` Recent entries:`)
|
|
97
|
-
for (const e of modelEntries) {
|
|
98
|
-
lines.push(
|
|
99
|
-
` ${e.ts} | ${formatMs(e.ttft_ms)} TTFT | ${formatNum(e.tps)} tok/s | ${formatMs(e.latency_ms)} | cost $${e.cost.toFixed(4)}`
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
lines.push(``)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (skipped > 0) {
|
|
108
|
-
lines.push(`(${skipped} corrupt lines ignored)`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { content: lines.join("\n") }
|
|
112
|
-
}
|
package/src/core/events.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import type { StepTracker, TerminalStepData } from "./step-tracker.js"
|
|
2
|
-
import type { LogEntry } from "../types.js"
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Event mapping for the throughput plugin — the single source of truth for
|
|
6
|
-
* which session events are monitored and how each mutates the StepTracker.
|
|
7
|
-
*
|
|
8
|
-
* Type derivation strategy: the SDK's session-event union (V2Event and its
|
|
9
|
-
* SessionStepStarted/SessionTextStarted/... members) lives in generated
|
|
10
|
-
* types inside @opencode/client, a transitive dependency, and is not
|
|
11
|
-
* re-exported from the @opencode/plugin public entry. A Pick/Extract
|
|
12
|
-
* derivation would pin deep internal SDK paths, so ThroughputEvent below is
|
|
13
|
-
* a hand-written minimal structural union mirroring the SDK shapes,
|
|
14
|
-
* restricted to the fields this plugin actually consumes:
|
|
15
|
-
* type / created / location.directory / data.sessionID /
|
|
16
|
-
* data.assistantMessageID / data.model.{id,providerID} / data.started /
|
|
17
|
-
* data.finish / data.cost / data.tokens.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export type ThroughputEvent =
|
|
21
|
-
| {
|
|
22
|
-
type: "session.step.started"
|
|
23
|
-
created: number
|
|
24
|
-
location?: { directory?: string }
|
|
25
|
-
data: {
|
|
26
|
-
sessionID: string
|
|
27
|
-
assistantMessageID: string
|
|
28
|
-
model: { id: string; providerID: string }
|
|
29
|
-
started: number
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
| {
|
|
33
|
-
type: "session.text.started"
|
|
34
|
-
created: number
|
|
35
|
-
location?: { directory?: string }
|
|
36
|
-
data: { sessionID: string; assistantMessageID: string }
|
|
37
|
-
}
|
|
38
|
-
| {
|
|
39
|
-
type: "session.reasoning.started"
|
|
40
|
-
created: number
|
|
41
|
-
location?: { directory?: string }
|
|
42
|
-
data: { sessionID: string; assistantMessageID: string }
|
|
43
|
-
}
|
|
44
|
-
| {
|
|
45
|
-
type: "session.step.streamed"
|
|
46
|
-
created: number
|
|
47
|
-
location?: { directory?: string }
|
|
48
|
-
data: { sessionID: string; assistantMessageID: string }
|
|
49
|
-
}
|
|
50
|
-
| {
|
|
51
|
-
type: "session.step.ended"
|
|
52
|
-
created: number
|
|
53
|
-
location?: { directory?: string }
|
|
54
|
-
data: TerminalStepData
|
|
55
|
-
}
|
|
56
|
-
| {
|
|
57
|
-
type: "session.step.failed"
|
|
58
|
-
created: number
|
|
59
|
-
location?: { directory?: string }
|
|
60
|
-
data: TerminalStepData
|
|
61
|
-
}
|
|
62
|
-
| {
|
|
63
|
-
type: "session.idle"
|
|
64
|
-
created: number
|
|
65
|
-
location?: { directory?: string }
|
|
66
|
-
data: { sessionID: string }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Exactly the event types both entrypoints subscribe to. */
|
|
70
|
-
export const SUBSCRIBED_EVENT_TYPES: readonly string[] = [
|
|
71
|
-
"session.step.started",
|
|
72
|
-
"session.text.started",
|
|
73
|
-
"session.reasoning.started",
|
|
74
|
-
"session.step.streamed",
|
|
75
|
-
"session.step.ended",
|
|
76
|
-
"session.step.failed",
|
|
77
|
-
"session.idle",
|
|
78
|
-
] as const satisfies readonly ThroughputEvent["type"][]
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* normalizeEvent outcome. `malformed` is reserved for a future payload
|
|
82
|
-
* pre-validation pass and is never produced today: known-type events are
|
|
83
|
-
* passed through unvalidated (see normalizeEvent).
|
|
84
|
-
*/
|
|
85
|
-
export type NormalizedEvent =
|
|
86
|
-
| { ok: true; event: ThroughputEvent }
|
|
87
|
-
| { ok: false; reason: "unknown" | "malformed" }
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Dual-channel classification, NO payload validation (current semantics):
|
|
91
|
-
* a known event type passes through as-is — a malformed payload throws
|
|
92
|
-
* naturally inside applyThroughputEvent's property accesses and is caught
|
|
93
|
-
* by the existing per-event onError boundary; anything else (unknown type,
|
|
94
|
-
* non-object, missing type) is silently skipped, matching the previous
|
|
95
|
-
* switch fall-through.
|
|
96
|
-
*/
|
|
97
|
-
export function normalizeEvent(raw: unknown): NormalizedEvent {
|
|
98
|
-
if (typeof raw === "object" && raw != null && "type" in raw) {
|
|
99
|
-
const type = (raw as { type: unknown }).type
|
|
100
|
-
if (typeof type === "string" && SUBSCRIBED_EVENT_TYPES.includes(type)) {
|
|
101
|
-
return { ok: true, event: raw as ThroughputEvent }
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return { ok: false, reason: "unknown" }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Apply one known event to the tracker; returns the settled LogEntry for
|
|
109
|
-
* terminal events (step.ended/failed), else null. Verbatim move of the
|
|
110
|
-
* event switch previously duplicated in plugins/throughput.ts and
|
|
111
|
-
* tui/controller.ts.
|
|
112
|
-
*/
|
|
113
|
-
export function applyThroughputEvent(
|
|
114
|
-
tracker: StepTracker,
|
|
115
|
-
ev: ThroughputEvent,
|
|
116
|
-
deps: { recordedAt: number; abortTtlMs: number }
|
|
117
|
-
): LogEntry | null {
|
|
118
|
-
switch (ev.type) {
|
|
119
|
-
case "session.step.started":
|
|
120
|
-
tracker.start(ev.data)
|
|
121
|
-
return null
|
|
122
|
-
case "session.text.started":
|
|
123
|
-
case "session.reasoning.started":
|
|
124
|
-
// Reasoning often streams before text; the first one wins.
|
|
125
|
-
tracker.markFirstOutput(ev.data.assistantMessageID, ev.created)
|
|
126
|
-
return null
|
|
127
|
-
// streamed = provider body fully consumed: the best TPS end boundary (tool time excluded); absent on older hosts -> terminal-event boundary (see buildLogEntry).
|
|
128
|
-
case "session.step.streamed":
|
|
129
|
-
tracker.markStreamed(ev.data.assistantMessageID, ev.created)
|
|
130
|
-
return null
|
|
131
|
-
case "session.step.ended":
|
|
132
|
-
case "session.step.failed":
|
|
133
|
-
return tracker.settle(ev.data.assistantMessageID, ev.created, ev.data, deps.recordedAt)
|
|
134
|
-
case "session.idle":
|
|
135
|
-
// Park instead of dropping so late terminal events can still settle.
|
|
136
|
-
tracker.parkSession(ev.data.sessionID, ev.created, deps.abortTtlMs)
|
|
137
|
-
return null
|
|
138
|
-
}
|
|
139
|
-
// Defensive only: unreachable for events that passed normalizeEvent.
|
|
140
|
-
return null
|
|
141
|
-
}
|
package/src/core/log-codec.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
|
|
3
|
-
function isString(v: unknown): v is string {
|
|
4
|
-
return typeof v === "string"
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function isFiniteNumber(v: unknown): v is number {
|
|
8
|
-
return typeof v === "number" && Number.isFinite(v)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Decode one JSONL line into a LogEntry, or null when the line is corrupt.
|
|
13
|
-
* Hard violations (-> null): non-string model/providerID/modelID/sessionID/messageID,
|
|
14
|
-
* or a present-but-wrong-typed metric (ttft_ms/tps/latency_ms) or amount
|
|
15
|
-
* (tokens/cost). Coercions: missing metrics -> null, missing amounts -> 0;
|
|
16
|
-
* missing/non-string finish -> "error", missing ts -> "".
|
|
17
|
-
*/
|
|
18
|
-
export function decodeLogLine(line: string): LogEntry | null {
|
|
19
|
-
let raw: unknown
|
|
20
|
-
try {
|
|
21
|
-
raw = JSON.parse(line)
|
|
22
|
-
} catch {
|
|
23
|
-
return null
|
|
24
|
-
}
|
|
25
|
-
if (typeof raw !== "object" || raw == null) return null
|
|
26
|
-
const o = raw as Record<string, unknown>
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
!isString(o.model) ||
|
|
30
|
-
!isString(o.providerID) ||
|
|
31
|
-
!isString(o.modelID) ||
|
|
32
|
-
!isString(o.sessionID) ||
|
|
33
|
-
!isString(o.messageID)
|
|
34
|
-
) {
|
|
35
|
-
return null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const metrics = [o.ttft_ms, o.tps, o.latency_ms]
|
|
39
|
-
const amounts = [
|
|
40
|
-
o.inputTokens,
|
|
41
|
-
o.outputTokens,
|
|
42
|
-
o.reasoningTokens,
|
|
43
|
-
o.cacheReadTokens,
|
|
44
|
-
o.cacheWriteTokens,
|
|
45
|
-
o.cost,
|
|
46
|
-
]
|
|
47
|
-
const validMetric = (v: unknown): boolean => v == null || isFiniteNumber(v)
|
|
48
|
-
if (!metrics.every(validMetric) || !amounts.every(validMetric)) return null
|
|
49
|
-
|
|
50
|
-
// Coercion helpers: absence -> metric null / amount 0; validity checked above.
|
|
51
|
-
const metric = (v: unknown): number | null => (isFiniteNumber(v) ? v : null)
|
|
52
|
-
const amount = (v: unknown): number => (isFiniteNumber(v) ? v : 0)
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
ts: isString(o.ts) ? o.ts : "",
|
|
56
|
-
model: o.model,
|
|
57
|
-
providerID: o.providerID,
|
|
58
|
-
modelID: o.modelID,
|
|
59
|
-
sessionID: o.sessionID,
|
|
60
|
-
messageID: o.messageID,
|
|
61
|
-
ttft_ms: metric(o.ttft_ms),
|
|
62
|
-
tps: metric(o.tps),
|
|
63
|
-
latency_ms: metric(o.latency_ms),
|
|
64
|
-
inputTokens: amount(o.inputTokens),
|
|
65
|
-
outputTokens: amount(o.outputTokens),
|
|
66
|
-
reasoningTokens: amount(o.reasoningTokens),
|
|
67
|
-
cacheReadTokens: amount(o.cacheReadTokens),
|
|
68
|
-
cacheWriteTokens: amount(o.cacheWriteTokens),
|
|
69
|
-
cost: amount(o.cost),
|
|
70
|
-
finish: isString(o.finish) ? o.finish : "error",
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Decode full JSONL content; corrupt non-blank lines are counted, not fatal. */
|
|
75
|
-
export function decodeLogContent(content: string): { entries: LogEntry[]; skipped: number } {
|
|
76
|
-
const entries: LogEntry[] = []
|
|
77
|
-
let skipped = 0
|
|
78
|
-
for (const line of content.split("\n")) {
|
|
79
|
-
if (!line.trim()) continue
|
|
80
|
-
const entry = decodeLogLine(line)
|
|
81
|
-
if (entry) entries.push(entry)
|
|
82
|
-
else skipped++
|
|
83
|
-
}
|
|
84
|
-
return { entries, skipped }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Encode one entry as a JSONL line (trailing newline included). */
|
|
88
|
-
export function encodeLogEntry(entry: LogEntry): string {
|
|
89
|
-
return JSON.stringify(entry) + "\n"
|
|
90
|
-
}
|
package/src/core/ownership.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export type LocationLike = { location?: { directory?: string } }
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Unified location-ownership policy for both entrypoints.
|
|
5
|
-
* Loose semantics: dir == null || selfDir == null || dir === selfDir.
|
|
6
|
-
* TODO: tighten `dir == null` to false once host broadcast behavior is verified.
|
|
7
|
-
*/
|
|
8
|
-
export function makeOwns(selfDir: string | undefined): (ev: LocationLike) => boolean {
|
|
9
|
-
return (ev) => {
|
|
10
|
-
const dir = ev.location?.directory
|
|
11
|
-
return dir == null || selfDir == null || dir === selfDir
|
|
12
|
-
}
|
|
13
|
-
}
|
package/src/core/stats.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { LogEntry, ModelStats } from "../types.js"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Single source of truth for aggregation. One-pass fold; null metrics are
|
|
5
|
-
* skipped (never 0-filled); no samples -> null (never Infinity). `now` is
|
|
6
|
-
* stamped into every row's lastUpdated — the caller owns the clock, so this
|
|
7
|
-
* module never reads the wall clock.
|
|
8
|
-
*/
|
|
9
|
-
export function aggregateAt(entries: readonly LogEntry[], now: number): Map<string, ModelStats> {
|
|
10
|
-
const groups = new Map<string, GroupFold>()
|
|
11
|
-
for (const entry of entries) {
|
|
12
|
-
let g = groups.get(entry.model)
|
|
13
|
-
if (!g) {
|
|
14
|
-
g = {
|
|
15
|
-
count: 0,
|
|
16
|
-
ttft: newMetricFold(),
|
|
17
|
-
tps: newMetricFold(),
|
|
18
|
-
latency: newMetricFold(),
|
|
19
|
-
totalInputTokens: 0,
|
|
20
|
-
totalOutputTokens: 0,
|
|
21
|
-
totalReasoningTokens: 0,
|
|
22
|
-
totalCacheReadTokens: 0,
|
|
23
|
-
totalCacheWriteTokens: 0,
|
|
24
|
-
totalCost: 0,
|
|
25
|
-
}
|
|
26
|
-
groups.set(entry.model, g)
|
|
27
|
-
}
|
|
28
|
-
g.count++
|
|
29
|
-
// Each metric is folded independently; null samples are skipped entirely.
|
|
30
|
-
if (entry.ttft_ms != null) foldMetric(g.ttft, entry.ttft_ms)
|
|
31
|
-
if (entry.tps != null) foldMetric(g.tps, entry.tps)
|
|
32
|
-
if (entry.latency_ms != null) foldMetric(g.latency, entry.latency_ms)
|
|
33
|
-
// Token/cost fields are always non-null on disk; plain sums.
|
|
34
|
-
g.totalInputTokens += entry.inputTokens
|
|
35
|
-
g.totalOutputTokens += entry.outputTokens
|
|
36
|
-
g.totalReasoningTokens += entry.reasoningTokens
|
|
37
|
-
g.totalCacheReadTokens += entry.cacheReadTokens
|
|
38
|
-
g.totalCacheWriteTokens += entry.cacheWriteTokens
|
|
39
|
-
g.totalCost += entry.cost
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const result = new Map<string, ModelStats>()
|
|
43
|
-
for (const [model, g] of groups) {
|
|
44
|
-
result.set(model, {
|
|
45
|
-
model,
|
|
46
|
-
count: g.count,
|
|
47
|
-
avgTTFT: metricAvg(g.ttft),
|
|
48
|
-
minTTFT: metricMin(g.ttft),
|
|
49
|
-
maxTTFT: metricMax(g.ttft),
|
|
50
|
-
avgTPS: metricAvg(g.tps),
|
|
51
|
-
maxTPS: metricMax(g.tps),
|
|
52
|
-
avgLatency: metricAvg(g.latency),
|
|
53
|
-
minLatency: metricMin(g.latency),
|
|
54
|
-
maxLatency: metricMax(g.latency),
|
|
55
|
-
totalInputTokens: g.totalInputTokens,
|
|
56
|
-
totalOutputTokens: g.totalOutputTokens,
|
|
57
|
-
totalReasoningTokens: g.totalReasoningTokens,
|
|
58
|
-
totalCacheReadTokens: g.totalCacheReadTokens,
|
|
59
|
-
totalCacheWriteTokens: g.totalCacheWriteTokens,
|
|
60
|
-
totalCost: g.totalCost,
|
|
61
|
-
lastUpdated: now,
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
return result
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Running fold for one metric: sum/count/min/max tracked in a single pass. */
|
|
68
|
-
type MetricFold = { sum: number; count: number; min: number; max: number }
|
|
69
|
-
|
|
70
|
-
type GroupFold = {
|
|
71
|
-
count: number
|
|
72
|
-
ttft: MetricFold
|
|
73
|
-
tps: MetricFold
|
|
74
|
-
latency: MetricFold
|
|
75
|
-
totalInputTokens: number
|
|
76
|
-
totalOutputTokens: number
|
|
77
|
-
totalReasoningTokens: number
|
|
78
|
-
totalCacheReadTokens: number
|
|
79
|
-
totalCacheWriteTokens: number
|
|
80
|
-
totalCost: number
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function newMetricFold(): MetricFold {
|
|
84
|
-
// min/max are only meaningful once count > 0; every read is guarded by count.
|
|
85
|
-
return { sum: 0, count: 0, min: Infinity, max: -Infinity }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function foldMetric(m: MetricFold, v: number): void {
|
|
89
|
-
m.sum += v
|
|
90
|
-
m.count++
|
|
91
|
-
if (v < m.min) m.min = v
|
|
92
|
-
if (v > m.max) m.max = v
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function metricAvg(m: MetricFold): number | null {
|
|
96
|
-
return m.count > 0 ? m.sum / m.count : null
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function metricMin(m: MetricFold): number | null {
|
|
100
|
-
return m.count > 0 ? m.min : null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function metricMax(m: MetricFold): number | null {
|
|
104
|
-
return m.count > 0 ? m.max : null
|
|
105
|
-
}
|
package/src/core/step-tracker.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import type { LogEntry } from "../types.js"
|
|
2
|
-
|
|
3
|
-
/** Canonical `providerID/modelID` key used to group entries per model. */
|
|
4
|
-
export function getModelKey(providerID: string, modelID: string): string {
|
|
5
|
-
return `${providerID}/${modelID}`
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
// In-flight state for one assistant step, keyed externally by assistantMessageID.
|
|
9
|
-
export type StepState = {
|
|
10
|
-
sessionID: string
|
|
11
|
-
started: number
|
|
12
|
-
providerID: string
|
|
13
|
-
modelID: string
|
|
14
|
-
modelKey: string
|
|
15
|
-
// Event time (ms epoch) of the first streamed text/reasoning output, if any.
|
|
16
|
-
firstOutput: number | null
|
|
17
|
-
// session.step.streamed time; null => TPS denominator falls back to terminal time.
|
|
18
|
-
streamEnded: number | null
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export const ZERO_TOKENS = {
|
|
22
|
-
input: 0,
|
|
23
|
-
output: 0,
|
|
24
|
-
reasoning: 0,
|
|
25
|
-
cache: { read: 0, write: 0 },
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type TerminalStepData = {
|
|
29
|
-
sessionID: string
|
|
30
|
-
assistantMessageID: string
|
|
31
|
-
finish?: string
|
|
32
|
-
cost?: number
|
|
33
|
-
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* TPS rule: genEnd = streamEnded ?? eventAt; with firstOutput:
|
|
38
|
-
* output/(genEnd - firstOutput)*1000; without firstOutput the full step span
|
|
39
|
-
* approximates generation: output/(eventAt - started)*1000.
|
|
40
|
-
* output <= 0 or genTime <= 0 -> null. ttft = firstOutput - started | null.
|
|
41
|
-
* @param eventAt event time (ms epoch) of the terminal event; drives every metric.
|
|
42
|
-
* @param recordedAt clock reading (ms epoch) taken when the entry is recorded;
|
|
43
|
-
* only `ts` derives from it (new Date(recordedAt).toISOString()), keeping
|
|
44
|
-
* this module free of wall-clock reads.
|
|
45
|
-
*/
|
|
46
|
-
export function buildLogEntry(
|
|
47
|
-
state: StepState,
|
|
48
|
-
eventAt: number,
|
|
49
|
-
terminal: TerminalStepData,
|
|
50
|
-
recordedAt: number
|
|
51
|
-
): LogEntry {
|
|
52
|
-
const tokens = terminal.tokens ?? ZERO_TOKENS
|
|
53
|
-
const latencyMs = eventAt - state.started
|
|
54
|
-
const ttftMs = state.firstOutput != null ? state.firstOutput - state.started : null
|
|
55
|
-
|
|
56
|
-
const genEnd = state.streamEnded ?? eventAt
|
|
57
|
-
const genTimeMs = state.firstOutput != null ? genEnd - state.firstOutput : eventAt - state.started
|
|
58
|
-
const tps = tokens.output > 0 && genTimeMs > 0 ? (tokens.output / genTimeMs) * 1000 : null
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
ts: new Date(recordedAt).toISOString(),
|
|
62
|
-
model: state.modelKey,
|
|
63
|
-
providerID: state.providerID,
|
|
64
|
-
modelID: state.modelID,
|
|
65
|
-
sessionID: terminal.sessionID,
|
|
66
|
-
messageID: terminal.assistantMessageID,
|
|
67
|
-
ttft_ms: ttftMs,
|
|
68
|
-
tps,
|
|
69
|
-
latency_ms: latencyMs,
|
|
70
|
-
inputTokens: tokens.input,
|
|
71
|
-
outputTokens: tokens.output,
|
|
72
|
-
reasoningTokens: tokens.reasoning,
|
|
73
|
-
cacheReadTokens: tokens.cache.read,
|
|
74
|
-
cacheWriteTokens: tokens.cache.write,
|
|
75
|
-
cost: terminal.cost ?? 0,
|
|
76
|
-
finish: terminal.finish ?? "error",
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Entry for a step that never reached a terminal event (parked and expired).
|
|
81
|
-
* `now` serves as both the event time (metrics) and the record time (ts). */
|
|
82
|
-
export function buildAbortedEntry(state: StepState, assistantMessageID: string, now: number): LogEntry {
|
|
83
|
-
return buildLogEntry(state, now, { sessionID: state.sessionID, assistantMessageID, finish: "aborted" }, now)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** In-flight state machine (no timers, no IO); parking = grace for late terminal events after session.idle.
|
|
87
|
-
* Active steps additionally carry a TTL: a step whose terminal event AND
|
|
88
|
-
* session.idle never arrive is evicted (recorded as finish:"aborted") once
|
|
89
|
-
* `now - started >= activeTtlMs`, exactly like a parked timeout. */
|
|
90
|
-
export class StepTracker {
|
|
91
|
-
private active = new Map<string, StepState>()
|
|
92
|
-
private parked = new Map<string, { state: StepState; deadline: number }>()
|
|
93
|
-
private readonly activeTtlMs: number
|
|
94
|
-
|
|
95
|
-
/** @param options.activeTtlMs eviction window for active steps (default 600_000 = 10 min);
|
|
96
|
-
* 0 or negative disables active eviction entirely. */
|
|
97
|
-
constructor(options: { activeTtlMs?: number } = {}) {
|
|
98
|
-
const ttl = options.activeTtlMs ?? 600_000
|
|
99
|
-
this.activeTtlMs = ttl > 0 ? ttl : 0
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
start(data: {
|
|
103
|
-
sessionID: string
|
|
104
|
-
assistantMessageID: string
|
|
105
|
-
started: number
|
|
106
|
-
model: { id: string; providerID: string }
|
|
107
|
-
}): void {
|
|
108
|
-
this.active.set(data.assistantMessageID, {
|
|
109
|
-
sessionID: data.sessionID,
|
|
110
|
-
started: data.started,
|
|
111
|
-
providerID: data.model.providerID,
|
|
112
|
-
modelID: data.model.id,
|
|
113
|
-
modelKey: getModelKey(data.model.providerID, data.model.id),
|
|
114
|
-
firstOutput: null,
|
|
115
|
-
streamEnded: null,
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Idempotent; the first output event wins. */
|
|
120
|
-
markFirstOutput(assistantMessageID: string, created: number): void {
|
|
121
|
-
const state = this.lookup(assistantMessageID)
|
|
122
|
-
if (state && state.firstOutput == null) state.firstOutput = created
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Idempotent; the first streamed event wins. */
|
|
126
|
-
markStreamed(assistantMessageID: string, created: number): void {
|
|
127
|
-
const state = this.lookup(assistantMessageID)
|
|
128
|
-
if (state && state.streamEnded == null) state.streamEnded = created
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Removes state and builds the final entry; unknown id -> null.
|
|
132
|
-
* Checks active first, then parked (a late ended after idle lands normally).
|
|
133
|
-
* `eventAt` drives the metrics; `recordedAt` (record time) drives ts. */
|
|
134
|
-
settle(assistantMessageID: string, eventAt: number, terminal: TerminalStepData, recordedAt: number): LogEntry | null {
|
|
135
|
-
const activeState = this.active.get(assistantMessageID)
|
|
136
|
-
if (activeState) {
|
|
137
|
-
this.active.delete(assistantMessageID)
|
|
138
|
-
return buildLogEntry(activeState, eventAt, terminal, recordedAt)
|
|
139
|
-
}
|
|
140
|
-
const parked = this.parked.get(assistantMessageID)
|
|
141
|
-
if (parked) {
|
|
142
|
-
this.parked.delete(assistantMessageID)
|
|
143
|
-
return buildLogEntry(parked.state, eventAt, terminal, recordedAt)
|
|
144
|
-
}
|
|
145
|
-
return null
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Move a session's in-flight steps to parking with deadline = now + ttlMs. */
|
|
149
|
-
parkSession(sessionID: string, now: number, ttlMs: number): void {
|
|
150
|
-
const deadline = now + ttlMs
|
|
151
|
-
for (const [id, state] of this.active) {
|
|
152
|
-
if (state.sessionID === sessionID) {
|
|
153
|
-
this.parked.set(id, { state, deadline })
|
|
154
|
-
this.active.delete(id)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Remove and return steps whose deadline has passed: parked steps past
|
|
160
|
-
* their parking deadline, and active steps past their TTL (same
|
|
161
|
-
* semantics — the caller records both as finish:"aborted" via
|
|
162
|
-
* buildAbortedEntry). Source is not distinguished in the result. */
|
|
163
|
-
collectExpired(now: number): Array<{ assistantMessageID: string; state: StepState }> {
|
|
164
|
-
const expired: Array<{ assistantMessageID: string; state: StepState }> = []
|
|
165
|
-
for (const [id, parked] of this.parked) {
|
|
166
|
-
if (parked.deadline <= now) {
|
|
167
|
-
expired.push({ assistantMessageID: id, state: parked.state })
|
|
168
|
-
this.parked.delete(id)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (this.activeTtlMs > 0) {
|
|
172
|
-
for (const [id, state] of this.active) {
|
|
173
|
-
if (now - state.started >= this.activeTtlMs) {
|
|
174
|
-
expired.push({ assistantMessageID: id, state })
|
|
175
|
-
this.active.delete(id)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return expired
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Earliest deadline among parked steps and active TTL expirations
|
|
183
|
-
* (started + activeTtlMs); null when nothing is scheduled. Feeds the
|
|
184
|
-
* pipeline's single flush timer, which then covers both sources. */
|
|
185
|
-
nextDeadline(): number | null {
|
|
186
|
-
let next: number | null = null
|
|
187
|
-
const consider = (deadline: number): void => {
|
|
188
|
-
if (next == null || deadline < next) next = deadline
|
|
189
|
-
}
|
|
190
|
-
for (const { deadline } of this.parked.values()) consider(deadline)
|
|
191
|
-
if (this.activeTtlMs > 0) {
|
|
192
|
-
for (const { started } of this.active.values()) consider(started + this.activeTtlMs)
|
|
193
|
-
}
|
|
194
|
-
return next
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
get size(): number {
|
|
198
|
-
return this.active.size + this.parked.size
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private lookup(assistantMessageID: string): StepState | undefined {
|
|
202
|
-
return this.active.get(assistantMessageID) ?? this.parked.get(assistantMessageID)?.state
|
|
203
|
-
}
|
|
204
|
-
}
|