oh-my-opencode-dashboard 0.1.3 → 0.1.5
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/assets/index-DSUWrfsI.js +40 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +187 -0
- package/src/app-payload.test.ts +106 -1
- package/src/ingest/token-usage-core.test.ts +205 -0
- package/src/ingest/token-usage-core.ts +113 -0
- package/src/ingest/token-usage.test.ts +154 -0
- package/src/ingest/token-usage.ts +66 -0
- package/src/server/dashboard.test.ts +101 -0
- package/src/server/dashboard.ts +9 -0
- package/dist/assets/index-BUlMOk-O.js +0 -40
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { deriveTokenUsage } from "./token-usage"
|
|
6
|
+
import { getStorageRoots } from "./session"
|
|
7
|
+
|
|
8
|
+
function mkStorageRoot(): string {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
|
|
10
|
+
fs.mkdirSync(path.join(root, "session"), { recursive: true })
|
|
11
|
+
fs.mkdirSync(path.join(root, "message"), { recursive: true })
|
|
12
|
+
fs.mkdirSync(path.join(root, "part"), { recursive: true })
|
|
13
|
+
return root
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeMessageMeta(opts: {
|
|
17
|
+
messageDir: string
|
|
18
|
+
messageId: string
|
|
19
|
+
meta: Record<string, unknown>
|
|
20
|
+
}): void {
|
|
21
|
+
fs.mkdirSync(opts.messageDir, { recursive: true })
|
|
22
|
+
fs.writeFileSync(
|
|
23
|
+
path.join(opts.messageDir, `${opts.messageId}.json`),
|
|
24
|
+
JSON.stringify({ id: opts.messageId, sessionID: "", role: "assistant", ...opts.meta }),
|
|
25
|
+
"utf8"
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("deriveTokenUsage token usage", () => {
|
|
30
|
+
it("aggregates token usage across main + background sessions", () => {
|
|
31
|
+
// #given
|
|
32
|
+
const storageRoot = mkStorageRoot()
|
|
33
|
+
const storage = getStorageRoots(storageRoot)
|
|
34
|
+
const mainSessionId = "ses_main"
|
|
35
|
+
const backgroundSessionId = "ses_bg"
|
|
36
|
+
|
|
37
|
+
writeMessageMeta({
|
|
38
|
+
messageDir: path.join(storage.message, mainSessionId),
|
|
39
|
+
messageId: "msg_main",
|
|
40
|
+
meta: {
|
|
41
|
+
sessionID: mainSessionId,
|
|
42
|
+
role: "assistant",
|
|
43
|
+
providerID: "openai",
|
|
44
|
+
modelID: "gpt-5.2",
|
|
45
|
+
tokens: { input: 2, output: 1, reasoning: 1, cache: { read: 0, write: 0 } },
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
writeMessageMeta({
|
|
50
|
+
messageDir: path.join(storage.message, backgroundSessionId),
|
|
51
|
+
messageId: "msg_bg",
|
|
52
|
+
meta: {
|
|
53
|
+
sessionID: backgroundSessionId,
|
|
54
|
+
role: "assistant",
|
|
55
|
+
providerID: "openai",
|
|
56
|
+
modelID: "gpt-5.2",
|
|
57
|
+
tokens: { input: 3, output: 0, reasoning: 0, cache: { read: 1, write: 0 } },
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// #when
|
|
62
|
+
const result = deriveTokenUsage({
|
|
63
|
+
storage,
|
|
64
|
+
mainSessionId,
|
|
65
|
+
backgroundSessionIds: [backgroundSessionId],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// #then
|
|
69
|
+
expect(result.rows.length).toBe(1)
|
|
70
|
+
expect(result.rows[0]).toEqual({
|
|
71
|
+
model: "openai/gpt-5.2",
|
|
72
|
+
input: 5,
|
|
73
|
+
output: 1,
|
|
74
|
+
reasoning: 1,
|
|
75
|
+
cacheRead: 1,
|
|
76
|
+
cacheWrite: 0,
|
|
77
|
+
total: 8,
|
|
78
|
+
})
|
|
79
|
+
expect(result.totals).toEqual({
|
|
80
|
+
input: 5,
|
|
81
|
+
output: 1,
|
|
82
|
+
reasoning: 1,
|
|
83
|
+
cacheRead: 1,
|
|
84
|
+
cacheWrite: 0,
|
|
85
|
+
total: 8,
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("returns empty payload when message directories are missing", () => {
|
|
90
|
+
// #given
|
|
91
|
+
const storageRoot = mkStorageRoot()
|
|
92
|
+
const storage = getStorageRoots(storageRoot)
|
|
93
|
+
|
|
94
|
+
// #when
|
|
95
|
+
const result = deriveTokenUsage({
|
|
96
|
+
storage,
|
|
97
|
+
mainSessionId: "ses_missing",
|
|
98
|
+
backgroundSessionIds: ["ses_other"],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// #then
|
|
102
|
+
expect(result.rows).toEqual([])
|
|
103
|
+
expect(result.totals).toEqual({
|
|
104
|
+
input: 0,
|
|
105
|
+
output: 0,
|
|
106
|
+
reasoning: 0,
|
|
107
|
+
cacheRead: 0,
|
|
108
|
+
cacheWrite: 0,
|
|
109
|
+
total: 0,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("dedupes message ids across sessions and ignores empty ids", () => {
|
|
114
|
+
// #given
|
|
115
|
+
const storageRoot = mkStorageRoot()
|
|
116
|
+
const storage = getStorageRoots(storageRoot)
|
|
117
|
+
const mainSessionId = "ses_main"
|
|
118
|
+
const backgroundSessionId = "ses_bg"
|
|
119
|
+
|
|
120
|
+
writeMessageMeta({
|
|
121
|
+
messageDir: path.join(storage.message, mainSessionId),
|
|
122
|
+
messageId: "msg_dupe",
|
|
123
|
+
meta: {
|
|
124
|
+
sessionID: mainSessionId,
|
|
125
|
+
role: "assistant",
|
|
126
|
+
providerID: "openai",
|
|
127
|
+
modelID: "gpt-5.2",
|
|
128
|
+
tokens: { input: 4, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
writeMessageMeta({
|
|
133
|
+
messageDir: path.join(storage.message, backgroundSessionId),
|
|
134
|
+
messageId: "msg_dupe",
|
|
135
|
+
meta: {
|
|
136
|
+
sessionID: backgroundSessionId,
|
|
137
|
+
role: "assistant",
|
|
138
|
+
providerID: "openai",
|
|
139
|
+
modelID: "gpt-5.2",
|
|
140
|
+
tokens: { input: 9, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// #when
|
|
145
|
+
const result = deriveTokenUsage({
|
|
146
|
+
storage,
|
|
147
|
+
mainSessionId,
|
|
148
|
+
backgroundSessionIds: [backgroundSessionId, "", " ", null],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// #then
|
|
152
|
+
expect(result.totals.input).toBe(4)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import type { OpenCodeStorageRoots } from "./session"
|
|
4
|
+
import { getMessageDir } from "./session"
|
|
5
|
+
import { aggregateTokenUsage } from "./token-usage-core"
|
|
6
|
+
|
|
7
|
+
function listJsonFiles(dir: string): string[] {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
10
|
+
} catch {
|
|
11
|
+
return []
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readJsonFile(filePath: string): unknown | null {
|
|
16
|
+
try {
|
|
17
|
+
const content = fs.readFileSync(filePath, "utf8")
|
|
18
|
+
return JSON.parse(content) as unknown
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeSessionId(value: unknown): string | null {
|
|
25
|
+
if (typeof value !== "string") return null
|
|
26
|
+
const trimmed = value.trim()
|
|
27
|
+
return trimmed ? trimmed : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readSessionMetas(messageDir: string): unknown[] {
|
|
31
|
+
if (!messageDir) return []
|
|
32
|
+
const files = listJsonFiles(messageDir)
|
|
33
|
+
const metas: unknown[] = []
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const meta = readJsonFile(path.join(messageDir, file))
|
|
36
|
+
if (meta) metas.push(meta)
|
|
37
|
+
}
|
|
38
|
+
return metas
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function deriveTokenUsage(opts: {
|
|
42
|
+
storage: OpenCodeStorageRoots
|
|
43
|
+
mainSessionId: string | null
|
|
44
|
+
backgroundSessionIds?: Array<string | null | undefined>
|
|
45
|
+
}): ReturnType<typeof aggregateTokenUsage> {
|
|
46
|
+
const sessionIds: string[] = []
|
|
47
|
+
const seen = new Set<string>()
|
|
48
|
+
const push = (value: unknown): void => {
|
|
49
|
+
const id = normalizeSessionId(value)
|
|
50
|
+
if (!id || seen.has(id)) return
|
|
51
|
+
seen.add(id)
|
|
52
|
+
sessionIds.push(id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
push(opts.mainSessionId)
|
|
56
|
+
for (const id of opts.backgroundSessionIds ?? []) push(id)
|
|
57
|
+
|
|
58
|
+
const metas: unknown[] = []
|
|
59
|
+
for (const sessionId of sessionIds) {
|
|
60
|
+
const messageDir = getMessageDir(opts.storage.message, sessionId)
|
|
61
|
+
if (!messageDir) continue
|
|
62
|
+
metas.push(...readSessionMetas(messageDir))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return aggregateTokenUsage(metas)
|
|
66
|
+
}
|
|
@@ -355,4 +355,105 @@ describe("buildDashboardPayload", () => {
|
|
|
355
355
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
356
356
|
}
|
|
357
357
|
})
|
|
358
|
+
|
|
359
|
+
it("includes tokenUsage totals and rows for main session", () => {
|
|
360
|
+
const storageRoot = mkStorageRoot()
|
|
361
|
+
const storage = getStorageRoots(storageRoot)
|
|
362
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
363
|
+
const sessionId = "ses_token_usage"
|
|
364
|
+
const messageId = "msg_token_1"
|
|
365
|
+
const projectID = "proj_1"
|
|
366
|
+
const providerID = "openai"
|
|
367
|
+
const modelID = "gpt-4o"
|
|
368
|
+
const expectedMessageTokens = {
|
|
369
|
+
input: 12,
|
|
370
|
+
output: 34,
|
|
371
|
+
reasoning: 5,
|
|
372
|
+
cache: {
|
|
373
|
+
read: 2,
|
|
374
|
+
write: 3,
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
const expectedTotals = {
|
|
378
|
+
input: 12,
|
|
379
|
+
output: 34,
|
|
380
|
+
reasoning: 5,
|
|
381
|
+
cacheRead: 2,
|
|
382
|
+
cacheWrite: 3,
|
|
383
|
+
total: 56,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
388
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
389
|
+
fs.writeFileSync(
|
|
390
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
391
|
+
JSON.stringify({
|
|
392
|
+
id: sessionId,
|
|
393
|
+
projectID,
|
|
394
|
+
directory: projectRoot,
|
|
395
|
+
time: { created: 1000, updated: 1000 },
|
|
396
|
+
}),
|
|
397
|
+
"utf8"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
401
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
402
|
+
fs.writeFileSync(
|
|
403
|
+
path.join(messageDir, `${messageId}.json`),
|
|
404
|
+
JSON.stringify({
|
|
405
|
+
id: messageId,
|
|
406
|
+
sessionID: sessionId,
|
|
407
|
+
role: "assistant",
|
|
408
|
+
providerID,
|
|
409
|
+
modelID,
|
|
410
|
+
tokens: expectedMessageTokens,
|
|
411
|
+
time: { created: 1200 },
|
|
412
|
+
}),
|
|
413
|
+
"utf8"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
type TokenUsageTotals = typeof expectedTotals
|
|
417
|
+
type TokenUsageRow = {
|
|
418
|
+
model: string
|
|
419
|
+
input: number
|
|
420
|
+
output: number
|
|
421
|
+
reasoning: number
|
|
422
|
+
cacheRead: number
|
|
423
|
+
cacheWrite: number
|
|
424
|
+
total: number
|
|
425
|
+
}
|
|
426
|
+
type DashboardPayloadWithTokenUsage = ReturnType<typeof buildDashboardPayload> & {
|
|
427
|
+
tokenUsage: {
|
|
428
|
+
totals: TokenUsageTotals
|
|
429
|
+
rows: TokenUsageRow[]
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const payload = buildDashboardPayload({
|
|
434
|
+
projectRoot,
|
|
435
|
+
storage,
|
|
436
|
+
nowMs: 2000,
|
|
437
|
+
}) as DashboardPayloadWithTokenUsage
|
|
438
|
+
|
|
439
|
+
expect(payload).toHaveProperty("tokenUsage")
|
|
440
|
+
expect(payload.tokenUsage.totals).toEqual(expectedTotals)
|
|
441
|
+
expect(payload.tokenUsage.rows).toEqual(
|
|
442
|
+
expect.arrayContaining([
|
|
443
|
+
expect.objectContaining({
|
|
444
|
+
model: `${providerID}/${modelID}`,
|
|
445
|
+
input: expectedTotals.input,
|
|
446
|
+
output: expectedTotals.output,
|
|
447
|
+
reasoning: expectedTotals.reasoning,
|
|
448
|
+
cacheRead: expectedTotals.cacheRead,
|
|
449
|
+
cacheWrite: expectedTotals.cacheWrite,
|
|
450
|
+
total: expectedTotals.total,
|
|
451
|
+
}),
|
|
452
|
+
])
|
|
453
|
+
)
|
|
454
|
+
} finally {
|
|
455
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
456
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
457
|
+
}
|
|
458
|
+
})
|
|
358
459
|
})
|
package/src/server/dashboard.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { deriveBackgroundTasks } from "../ingest/background-tasks"
|
|
|
5
5
|
import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
|
|
6
6
|
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
7
7
|
import { deriveToolCalls } from "../ingest/tool-calls"
|
|
8
|
+
import { deriveTokenUsage } from "../ingest/token-usage"
|
|
8
9
|
|
|
9
10
|
export type DashboardPayload = {
|
|
10
11
|
mainSession: {
|
|
@@ -48,6 +49,7 @@ export type DashboardPayload = {
|
|
|
48
49
|
sessionId: string | null
|
|
49
50
|
}>
|
|
50
51
|
timeSeries: TimeSeriesPayload
|
|
52
|
+
tokenUsage?: ReturnType<typeof deriveTokenUsage>
|
|
51
53
|
raw: unknown
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -212,6 +214,12 @@ export function buildDashboardPayload(opts: {
|
|
|
212
214
|
]
|
|
213
215
|
})()
|
|
214
216
|
|
|
217
|
+
const tokenUsage = deriveTokenUsage({
|
|
218
|
+
storage: opts.storage,
|
|
219
|
+
mainSessionId: sessionId ?? null,
|
|
220
|
+
backgroundSessionIds: tasks.map((task) => task.sessionId ?? null),
|
|
221
|
+
})
|
|
222
|
+
|
|
215
223
|
const payload: DashboardPayload = {
|
|
216
224
|
mainSession: {
|
|
217
225
|
agent: main.agent,
|
|
@@ -243,6 +251,7 @@ export function buildDashboardPayload(opts: {
|
|
|
243
251
|
})),
|
|
244
252
|
mainSessionTasks,
|
|
245
253
|
timeSeries,
|
|
254
|
+
tokenUsage,
|
|
246
255
|
raw: null,
|
|
247
256
|
}
|
|
248
257
|
|