opencode-models-usage-plugin 0.1.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/package.json +34 -0
- package/src/session-usage.test.ts +326 -0
- package/src/session-usage.ts +185 -0
- package/src/tui.tsx +230 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-models-usage-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Show per-model usage, live TPS, tokens, and cost in the OpenCode sidebar.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./tui": {
|
|
9
|
+
"import": "./src/tui.tsx"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"opencode": ">=1.4.3"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "tsx --test src/**/*.test.ts",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@opencode-ai/plugin": "1.4.3",
|
|
24
|
+
"@opentui/solid": "0.1.97",
|
|
25
|
+
"solid-js": "1.9.11"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "25.6.0",
|
|
29
|
+
"tsx": "4.21.0",
|
|
30
|
+
"typescript": "6.0.2"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [],
|
|
33
|
+
"author": ""
|
|
34
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import { getSessionUsage, getTokenTotal, type SessionMessageLike } from "./session-usage"
|
|
5
|
+
|
|
6
|
+
test("empty session returns no models", () => {
|
|
7
|
+
assert.deepEqual(getSessionUsage([]), {
|
|
8
|
+
models: [],
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("assistant message contributes all token counters and cost", () => {
|
|
13
|
+
const usage = getSessionUsage([
|
|
14
|
+
{
|
|
15
|
+
role: "assistant",
|
|
16
|
+
providerID: "openai",
|
|
17
|
+
modelID: "gpt-4.1",
|
|
18
|
+
cost: 0.05,
|
|
19
|
+
tokens: {
|
|
20
|
+
input: 11,
|
|
21
|
+
output: 7,
|
|
22
|
+
reasoning: 5,
|
|
23
|
+
cache: {
|
|
24
|
+
read: 3,
|
|
25
|
+
write: 2,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(usage, {
|
|
32
|
+
models: [
|
|
33
|
+
{
|
|
34
|
+
label: "openai/gpt-4.1",
|
|
35
|
+
totalTokens: 28,
|
|
36
|
+
cost: 0.05,
|
|
37
|
+
tps: 0,
|
|
38
|
+
isLiveTps: false,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("repeated model is listed once while tokens keep accumulating", () => {
|
|
45
|
+
const messages: SessionMessageLike[] = [
|
|
46
|
+
{
|
|
47
|
+
role: "assistant",
|
|
48
|
+
providerID: "openai",
|
|
49
|
+
modelID: "gpt-4.1",
|
|
50
|
+
cost: 0.01,
|
|
51
|
+
tokens: {
|
|
52
|
+
input: 10,
|
|
53
|
+
output: 5,
|
|
54
|
+
},
|
|
55
|
+
time: { created: 1000, completed: 3000 },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
role: "assistant",
|
|
59
|
+
providerID: "openai",
|
|
60
|
+
modelID: "gpt-4.1",
|
|
61
|
+
cost: 0.02,
|
|
62
|
+
tokens: {
|
|
63
|
+
input: 4,
|
|
64
|
+
output: 6,
|
|
65
|
+
},
|
|
66
|
+
time: { created: 4000, completed: 5000 },
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(getSessionUsage(messages), {
|
|
71
|
+
models: [
|
|
72
|
+
{
|
|
73
|
+
label: "openai/gpt-4.1",
|
|
74
|
+
totalTokens: 25,
|
|
75
|
+
cost: 0.03,
|
|
76
|
+
tps: 0,
|
|
77
|
+
isLiveTps: false, // (5 + 6) tokens / (2 + 1) seconds = 11 / 3
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("multiple models preserve first-seen order", () => {
|
|
84
|
+
const usage = getSessionUsage([
|
|
85
|
+
{
|
|
86
|
+
role: "assistant",
|
|
87
|
+
providerID: "anthropic",
|
|
88
|
+
modelID: "claude-sonnet-4",
|
|
89
|
+
tokens: {
|
|
90
|
+
input: 1,
|
|
91
|
+
output: 2,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
role: "assistant",
|
|
96
|
+
providerID: "openai",
|
|
97
|
+
modelID: "gpt-4.1",
|
|
98
|
+
tokens: {
|
|
99
|
+
input: 3,
|
|
100
|
+
output: 4,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(usage.models.map((m) => m.label), [
|
|
106
|
+
"anthropic/claude-sonnet-4",
|
|
107
|
+
"openai/gpt-4.1",
|
|
108
|
+
])
|
|
109
|
+
assert.equal(usage.models[0].totalTokens, 3)
|
|
110
|
+
assert.equal(usage.models[1].totalTokens, 7)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("non-assistant messages are ignored", () => {
|
|
114
|
+
const usage = getSessionUsage([
|
|
115
|
+
{
|
|
116
|
+
role: "user",
|
|
117
|
+
providerID: "openai",
|
|
118
|
+
modelID: "gpt-4.1",
|
|
119
|
+
cost: 10,
|
|
120
|
+
tokens: {
|
|
121
|
+
input: 999,
|
|
122
|
+
output: 999,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
role: "assistant",
|
|
127
|
+
providerID: "openai",
|
|
128
|
+
modelID: "gpt-4.1",
|
|
129
|
+
cost: 0.1,
|
|
130
|
+
tokens: {
|
|
131
|
+
input: 3,
|
|
132
|
+
output: 2,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(usage, {
|
|
138
|
+
models: [
|
|
139
|
+
{
|
|
140
|
+
label: "openai/gpt-4.1",
|
|
141
|
+
totalTokens: 5,
|
|
142
|
+
cost: 0.1,
|
|
143
|
+
tps: 0,
|
|
144
|
+
isLiveTps: false,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("provider catalog names are used when available", () => {
|
|
151
|
+
const providers = {
|
|
152
|
+
openai: {
|
|
153
|
+
id: "openai",
|
|
154
|
+
name: "OpenAI",
|
|
155
|
+
models: {
|
|
156
|
+
"gpt-4.1": {
|
|
157
|
+
id: "gpt-4.1",
|
|
158
|
+
name: "GPT-4.1",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const usage = getSessionUsage(
|
|
165
|
+
[
|
|
166
|
+
{
|
|
167
|
+
role: "assistant",
|
|
168
|
+
providerID: "openai",
|
|
169
|
+
modelID: "gpt-4.1",
|
|
170
|
+
tokens: {
|
|
171
|
+
input: 2,
|
|
172
|
+
output: 3,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
providers,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(usage, {
|
|
180
|
+
models: [
|
|
181
|
+
{
|
|
182
|
+
label: "OpenAI/GPT-4.1",
|
|
183
|
+
totalTokens: 5,
|
|
184
|
+
cost: 0,
|
|
185
|
+
tps: 0,
|
|
186
|
+
isLiveTps: false,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("provider catalog also works when providers are stored as an array", () => {
|
|
193
|
+
const providers = [
|
|
194
|
+
{
|
|
195
|
+
id: "anthropic",
|
|
196
|
+
name: "Anthropic",
|
|
197
|
+
models: {
|
|
198
|
+
"claude-sonnet-4": {
|
|
199
|
+
id: "claude-sonnet-4",
|
|
200
|
+
name: "Claude Sonnet 4",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
const usage = getSessionUsage(
|
|
207
|
+
[
|
|
208
|
+
{
|
|
209
|
+
role: "assistant",
|
|
210
|
+
providerID: "anthropic",
|
|
211
|
+
modelID: "claude-sonnet-4",
|
|
212
|
+
tokens: {
|
|
213
|
+
input: 4,
|
|
214
|
+
output: 6,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
providers,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert.deepEqual(usage, {
|
|
222
|
+
models: [
|
|
223
|
+
{
|
|
224
|
+
label: "Anthropic/Claude Sonnet 4",
|
|
225
|
+
totalTokens: 10,
|
|
226
|
+
cost: 0,
|
|
227
|
+
tps: 0,
|
|
228
|
+
isLiveTps: false,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("token helper tolerates missing counters", () => {
|
|
235
|
+
assert.equal(
|
|
236
|
+
getTokenTotal({
|
|
237
|
+
input: 1,
|
|
238
|
+
cache: {
|
|
239
|
+
read: 2,
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
3,
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("tps eligibility rules are applied correctly", () => {
|
|
247
|
+
const usage = getSessionUsage([
|
|
248
|
+
// valid
|
|
249
|
+
{
|
|
250
|
+
role: "assistant",
|
|
251
|
+
providerID: "openai",
|
|
252
|
+
modelID: "gpt-4",
|
|
253
|
+
tokens: { output: 10 },
|
|
254
|
+
time: { created: 1000, completed: 2000 },
|
|
255
|
+
},
|
|
256
|
+
// ignored: summary = true
|
|
257
|
+
{
|
|
258
|
+
role: "assistant",
|
|
259
|
+
providerID: "openai",
|
|
260
|
+
modelID: "gpt-4",
|
|
261
|
+
summary: true,
|
|
262
|
+
tokens: { output: 100 },
|
|
263
|
+
time: { created: 1000, completed: 2000 },
|
|
264
|
+
},
|
|
265
|
+
// ignored: no completed time
|
|
266
|
+
{
|
|
267
|
+
role: "assistant",
|
|
268
|
+
providerID: "openai",
|
|
269
|
+
modelID: "gpt-4",
|
|
270
|
+
tokens: { output: 100 },
|
|
271
|
+
time: { created: 1000 },
|
|
272
|
+
},
|
|
273
|
+
// ignored: duration <= 0
|
|
274
|
+
{
|
|
275
|
+
role: "assistant",
|
|
276
|
+
providerID: "openai",
|
|
277
|
+
modelID: "gpt-4",
|
|
278
|
+
tokens: { output: 100 },
|
|
279
|
+
time: { created: 1000, completed: 1000 },
|
|
280
|
+
},
|
|
281
|
+
// ignored: generated tokens <= 0
|
|
282
|
+
{
|
|
283
|
+
role: "assistant",
|
|
284
|
+
providerID: "openai",
|
|
285
|
+
modelID: "gpt-4",
|
|
286
|
+
tokens: { input: 100, output: 0 },
|
|
287
|
+
time: { created: 1000, completed: 2000 },
|
|
288
|
+
},
|
|
289
|
+
// ignored: finish === "tool-calls"
|
|
290
|
+
{
|
|
291
|
+
role: "assistant",
|
|
292
|
+
providerID: "openai",
|
|
293
|
+
modelID: "gpt-4",
|
|
294
|
+
finish: "tool-calls",
|
|
295
|
+
tokens: { output: 100 },
|
|
296
|
+
time: { created: 1000, completed: 2000 },
|
|
297
|
+
},
|
|
298
|
+
// valid: output + reasoning
|
|
299
|
+
{
|
|
300
|
+
role: "assistant",
|
|
301
|
+
providerID: "openai",
|
|
302
|
+
modelID: "gpt-4",
|
|
303
|
+
tokens: { output: 5, reasoning: 5 },
|
|
304
|
+
time: { created: 2000, completed: 3000 },
|
|
305
|
+
},
|
|
306
|
+
])
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(usage, {
|
|
309
|
+
models: [
|
|
310
|
+
{
|
|
311
|
+
label: "openai/gpt-4",
|
|
312
|
+
totalTokens: 520, // 10 + 100 + 100 + 100 + 100(input) + 100 + 10 = 520 actually wait
|
|
313
|
+
// Tokens: 10 + 100 + 100 + 100 + 100 + 100 + 10 = 520
|
|
314
|
+
// Cost: 0
|
|
315
|
+
// TPS: 20 tokens / 2 seconds = 10
|
|
316
|
+
cost: 0,
|
|
317
|
+
tps: 0,
|
|
318
|
+
isLiveTps: false,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// Update token expectation to match manual addition
|
|
324
|
+
const total = 10 + 100 + 100 + 100 + 100 + 100 + 10;
|
|
325
|
+
assert.equal(usage.models[0].totalTokens, total)
|
|
326
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export interface TokenUsageLike {
|
|
2
|
+
input?: number | null
|
|
3
|
+
output?: number | null
|
|
4
|
+
reasoning?: number | null
|
|
5
|
+
cache?: {
|
|
6
|
+
read?: number | null
|
|
7
|
+
write?: number | null
|
|
8
|
+
} | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SessionMessageLike {
|
|
12
|
+
id?: string
|
|
13
|
+
role: string
|
|
14
|
+
modelID?: string | null
|
|
15
|
+
providerID?: string | null
|
|
16
|
+
tokens?: TokenUsageLike | null
|
|
17
|
+
cost?: number | null
|
|
18
|
+
summary?: unknown
|
|
19
|
+
time?: {
|
|
20
|
+
created?: number | null
|
|
21
|
+
completed?: number | null
|
|
22
|
+
} | null
|
|
23
|
+
finish?: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModelUsage {
|
|
27
|
+
label: string
|
|
28
|
+
tps: number
|
|
29
|
+
totalTokens: number
|
|
30
|
+
cost: number
|
|
31
|
+
isLiveTps?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionUsage {
|
|
35
|
+
models: ModelUsage[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
39
|
+
typeof value === "object" && value !== null
|
|
40
|
+
|
|
41
|
+
const toNumber = (value: unknown): number =>
|
|
42
|
+
typeof value === "number" && Number.isFinite(value) ? value : 0
|
|
43
|
+
|
|
44
|
+
const getStringProp = (value: unknown, key: string): string | undefined => {
|
|
45
|
+
if (!isRecord(value)) {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const prop = value[key]
|
|
50
|
+
return typeof prop === "string" ? prop : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const getObjectProp = (value: unknown, key: string): unknown => {
|
|
54
|
+
if (!isRecord(value)) {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return value[key]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const findProvider = (providers: unknown, providerID: string): unknown => {
|
|
62
|
+
if (Array.isArray(providers)) {
|
|
63
|
+
return providers.find((provider) => getStringProp(provider, "id") === providerID)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return getObjectProp(providers, providerID)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const findModel = (models: unknown, modelID: string): unknown => {
|
|
70
|
+
if (Array.isArray(models)) {
|
|
71
|
+
return models.find((model) => getStringProp(model, "id") === modelID)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return getObjectProp(models, modelID)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const resolveModelLabel = (
|
|
78
|
+
modelID: string,
|
|
79
|
+
providerID: string | undefined,
|
|
80
|
+
providers: unknown,
|
|
81
|
+
): string => {
|
|
82
|
+
if (!providerID) {
|
|
83
|
+
return modelID
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const provider = findProvider(providers, providerID)
|
|
87
|
+
const providerName = getStringProp(provider, "name") ?? providerID
|
|
88
|
+
const model = findModel(getObjectProp(provider, "models"), modelID)
|
|
89
|
+
const modelName = getStringProp(model, "name") ?? modelID
|
|
90
|
+
|
|
91
|
+
return `${providerName}/${modelName}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const getTokenTotal = (tokens?: TokenUsageLike | null): number =>
|
|
95
|
+
toNumber(tokens?.input) +
|
|
96
|
+
toNumber(tokens?.output) +
|
|
97
|
+
toNumber(tokens?.reasoning) +
|
|
98
|
+
toNumber(tokens?.cache?.read) +
|
|
99
|
+
toNumber(tokens?.cache?.write)
|
|
100
|
+
|
|
101
|
+
export interface LiveTpsState {
|
|
102
|
+
modelID: string
|
|
103
|
+
providerID: string | undefined
|
|
104
|
+
tpsTokens: number
|
|
105
|
+
tpsDurationMs: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const getSessionUsage = (
|
|
109
|
+
messages: ReadonlyArray<SessionMessageLike>,
|
|
110
|
+
providers?: unknown,
|
|
111
|
+
liveTps?: LiveTpsState | null,
|
|
112
|
+
): SessionUsage => {
|
|
113
|
+
const modelStats = new Map<string, {
|
|
114
|
+
label: string
|
|
115
|
+
totalTokens: number
|
|
116
|
+
cost: number
|
|
117
|
+
}>()
|
|
118
|
+
const modelsUsed: string[] = []
|
|
119
|
+
|
|
120
|
+
for (const message of messages) {
|
|
121
|
+
if (message.role !== "assistant") {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const modelID = typeof message.modelID === "string" ? message.modelID : undefined
|
|
126
|
+
if (!modelID) {
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const providerID =
|
|
131
|
+
typeof message.providerID === "string" ? message.providerID : undefined
|
|
132
|
+
const modelKey = providerID ? `${providerID}:${modelID}` : modelID
|
|
133
|
+
|
|
134
|
+
if (!modelStats.has(modelKey)) {
|
|
135
|
+
modelStats.set(modelKey, {
|
|
136
|
+
label: resolveModelLabel(modelID, providerID, providers),
|
|
137
|
+
totalTokens: 0,
|
|
138
|
+
cost: 0,
|
|
139
|
+
})
|
|
140
|
+
modelsUsed.push(modelKey)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stats = modelStats.get(modelKey)!
|
|
144
|
+
stats.totalTokens += getTokenTotal(message.tokens)
|
|
145
|
+
stats.cost += toNumber(message.cost)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Inject live TPS
|
|
149
|
+
let liveTpsKey: string | undefined
|
|
150
|
+
if (liveTps && liveTps.modelID) {
|
|
151
|
+
const providerID = liveTps.providerID
|
|
152
|
+
const modelID = liveTps.modelID
|
|
153
|
+
liveTpsKey = providerID ? `${providerID}:${modelID}` : modelID
|
|
154
|
+
if (!modelStats.has(liveTpsKey)) {
|
|
155
|
+
modelStats.set(liveTpsKey, {
|
|
156
|
+
label: resolveModelLabel(modelID, providerID, providers),
|
|
157
|
+
totalTokens: 0,
|
|
158
|
+
cost: 0,
|
|
159
|
+
})
|
|
160
|
+
modelsUsed.push(liveTpsKey)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const models = modelsUsed.map((key) => {
|
|
165
|
+
const stats = modelStats.get(key)!
|
|
166
|
+
let isLiveTps = false
|
|
167
|
+
let tps = 0
|
|
168
|
+
|
|
169
|
+
if (key === liveTpsKey && liveTps) {
|
|
170
|
+
isLiveTps = true
|
|
171
|
+
const durationSeconds = liveTps.tpsDurationMs / 1000
|
|
172
|
+
tps = durationSeconds > 0 ? liveTps.tpsTokens / durationSeconds : 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
label: stats.label,
|
|
177
|
+
totalTokens: stats.totalTokens,
|
|
178
|
+
cost: stats.cost,
|
|
179
|
+
tps,
|
|
180
|
+
isLiveTps,
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return { models }
|
|
185
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import { For, createMemo, createSignal, onMount, onCleanup } from "solid-js"
|
|
5
|
+
|
|
6
|
+
import { getSessionUsage, type SessionMessageLike, type LiveTpsState } from "./session-usage"
|
|
7
|
+
|
|
8
|
+
const pluginID = "local:session-model-usage"
|
|
9
|
+
|
|
10
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
11
|
+
typeof value === "object" && value !== null
|
|
12
|
+
|
|
13
|
+
const mapMessage = (message: unknown): SessionMessageLike => {
|
|
14
|
+
if (!isRecord(message)) {
|
|
15
|
+
return { role: "unknown" }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
id: typeof message.id === "string" ? message.id : undefined,
|
|
20
|
+
role: typeof message.role === "string" ? message.role : "unknown",
|
|
21
|
+
modelID: typeof message.modelID === "string" ? message.modelID : undefined,
|
|
22
|
+
providerID: typeof message.providerID === "string" ? message.providerID : undefined,
|
|
23
|
+
tokens: isRecord(message.tokens) ? message.tokens : undefined,
|
|
24
|
+
cost: typeof message.cost === "number" ? message.cost : undefined,
|
|
25
|
+
summary: message.summary,
|
|
26
|
+
time: isRecord(message.time)
|
|
27
|
+
? {
|
|
28
|
+
created:
|
|
29
|
+
typeof message.time.created === "number" ? message.time.created : undefined,
|
|
30
|
+
completed:
|
|
31
|
+
typeof message.time.completed === "number" ? message.time.completed : undefined,
|
|
32
|
+
}
|
|
33
|
+
: undefined,
|
|
34
|
+
finish: typeof message.finish === "string" ? message.finish : undefined,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mapMessages = (messages: readonly unknown[]): SessionMessageLike[] => {
|
|
39
|
+
return messages.map((message) => mapMessage(message))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const useSessionMessages = (api: TuiPluginApi, rootSessionID: string) => {
|
|
43
|
+
const [extraMessages, setExtraMessages] = createSignal<SessionMessageLike[]>([])
|
|
44
|
+
const [trackedSessionIDs, setTrackedSessionIDs] = createSignal<ReadonlySet<string>>(
|
|
45
|
+
new Set([rootSessionID]),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const [liveInfo, setLiveInfo] = createSignal<{
|
|
49
|
+
messageID: string;
|
|
50
|
+
modelID: string;
|
|
51
|
+
providerID?: string;
|
|
52
|
+
startTime: number;
|
|
53
|
+
chars: number;
|
|
54
|
+
} | null>(null)
|
|
55
|
+
|
|
56
|
+
const fetchChildrenMessages = async (
|
|
57
|
+
sessionID: string,
|
|
58
|
+
visited = new Set<string>(),
|
|
59
|
+
): Promise<{ messages: SessionMessageLike[]; sessionIDs: Set<string> }> => {
|
|
60
|
+
if (visited.has(sessionID)) {
|
|
61
|
+
return { messages: [], sessionIDs: visited }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
visited.add(sessionID)
|
|
65
|
+
const res = await api.client.session.children({ sessionID })
|
|
66
|
+
if (res.error || !res.data) {
|
|
67
|
+
return { messages: [], sessionIDs: visited }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let msgs: SessionMessageLike[] = []
|
|
71
|
+
for (const child of res.data) {
|
|
72
|
+
const childID = child.id
|
|
73
|
+
visited.add(childID)
|
|
74
|
+
const mRes = await api.client.session.messages({ sessionID: childID })
|
|
75
|
+
if (!mRes.error && mRes.data) {
|
|
76
|
+
msgs.push(...mapMessages(mRes.data.map((message) => message.info)))
|
|
77
|
+
}
|
|
78
|
+
const nested = await fetchChildrenMessages(childID, visited)
|
|
79
|
+
msgs.push(...nested.messages)
|
|
80
|
+
}
|
|
81
|
+
return { messages: msgs, sessionIDs: visited }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const reloadExtraMessages = async () => {
|
|
85
|
+
const result = await fetchChildrenMessages(rootSessionID)
|
|
86
|
+
setExtraMessages(result.messages)
|
|
87
|
+
setTrackedSessionIDs(new Set(result.sessionIDs))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onMount(() => {
|
|
91
|
+
reloadExtraMessages()
|
|
92
|
+
|
|
93
|
+
const unsubs = [
|
|
94
|
+
api.event.on("message.part.delta", (ev) => {
|
|
95
|
+
const { sessionID, messageID, field, delta } = ev.properties
|
|
96
|
+
if (!sessionID || !messageID || typeof delta !== "string") return
|
|
97
|
+
if (field !== "text" && field !== "reasoning") return
|
|
98
|
+
|
|
99
|
+
setLiveInfo(prev => {
|
|
100
|
+
if (prev?.messageID === messageID) {
|
|
101
|
+
return { ...prev, chars: prev.chars + delta.length }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Try to find the message
|
|
105
|
+
const rootMsgs = mapMessages(api.state.session.messages(rootSessionID))
|
|
106
|
+
const extra = extraMessages()
|
|
107
|
+
let found = rootMsgs.find(m => m.id === messageID)
|
|
108
|
+
if (!found) {
|
|
109
|
+
found = extra.find(m => m.id === messageID)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!found || found.role !== "assistant" || !found.modelID) {
|
|
113
|
+
return prev
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const modelID = found.modelID
|
|
117
|
+
if (typeof modelID !== "string") {
|
|
118
|
+
return prev
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
messageID,
|
|
123
|
+
modelID,
|
|
124
|
+
providerID: typeof found.providerID === "string" ? found.providerID : undefined,
|
|
125
|
+
startTime: Date.now(),
|
|
126
|
+
chars: delta.length
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}),
|
|
130
|
+
api.event.on("message.updated", () => reloadExtraMessages()),
|
|
131
|
+
api.event.on("session.status", () => reloadExtraMessages()),
|
|
132
|
+
api.event.on("session.idle", (ev) => {
|
|
133
|
+
if (!trackedSessionIDs().has(ev.properties.sessionID)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setLiveInfo(null)
|
|
138
|
+
})
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
onCleanup(() => {
|
|
142
|
+
unsubs.forEach(u => u())
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Derive liveTps from liveInfo
|
|
147
|
+
const [now, setNow] = createSignal(Date.now())
|
|
148
|
+
|
|
149
|
+
onMount(() => {
|
|
150
|
+
const timer = setInterval(() => setNow(Date.now()), 500)
|
|
151
|
+
onCleanup(() => clearInterval(timer))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const liveTps = createMemo<LiveTpsState | null>(() => {
|
|
155
|
+
const info = liveInfo()
|
|
156
|
+
if (!info) return null
|
|
157
|
+
|
|
158
|
+
const durationMs = now() - info.startTime
|
|
159
|
+
// Estimate 1 token = 4 chars approx during streaming
|
|
160
|
+
const tpsTokens = Math.ceil(info.chars / 4)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
modelID: info.modelID,
|
|
164
|
+
providerID: info.providerID,
|
|
165
|
+
tpsTokens,
|
|
166
|
+
tpsDurationMs: Math.max(durationMs, 1)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return { extraMessages, liveTps }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const SessionUsagePanel = (props: { api: TuiPluginApi; sessionID: string }) => {
|
|
174
|
+
const { extraMessages, liveTps } = useSessionMessages(props.api, props.sessionID)
|
|
175
|
+
|
|
176
|
+
const usage = createMemo(() => {
|
|
177
|
+
const rootMsgs = mapMessages(props.api.state.session.messages(props.sessionID))
|
|
178
|
+
const allMsgs = [...rootMsgs, ...extraMessages()]
|
|
179
|
+
return getSessionUsage(allMsgs, props.api.state.provider, liveTps())
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<box flexDirection="column">
|
|
184
|
+
<text>📊 Models Usages</text>
|
|
185
|
+
{usage().models.length === 0 ? (
|
|
186
|
+
<text>None yet</text>
|
|
187
|
+
) : (
|
|
188
|
+
<For each={usage().models}>
|
|
189
|
+
{(model) => (
|
|
190
|
+
<box flexDirection="column">
|
|
191
|
+
<text>● {model.label}</text>
|
|
192
|
+
<box flexDirection="column" paddingLeft={2}>
|
|
193
|
+
<box flexDirection="row">
|
|
194
|
+
<text>■ TPS </text>
|
|
195
|
+
<text fg="gray">{model.isLiveTps && model.tps > 0 ? model.tps.toFixed(1) : "—"}</text>
|
|
196
|
+
</box>
|
|
197
|
+
<box flexDirection="row">
|
|
198
|
+
<text>■ Total Tokens </text>
|
|
199
|
+
<text fg="gray">{model.totalTokens.toLocaleString("en-US")}</text>
|
|
200
|
+
</box>
|
|
201
|
+
<box flexDirection="row">
|
|
202
|
+
<text>■ spent </text>
|
|
203
|
+
<text fg="gray">${model.cost.toFixed(4)}</text>
|
|
204
|
+
</box>
|
|
205
|
+
</box>
|
|
206
|
+
</box>
|
|
207
|
+
)}
|
|
208
|
+
</For>
|
|
209
|
+
)}
|
|
210
|
+
</box>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const tui: TuiPlugin = async (api) => {
|
|
215
|
+
api.slots.register({
|
|
216
|
+
order: 150,
|
|
217
|
+
slots: {
|
|
218
|
+
sidebar_content(_ctx, props) {
|
|
219
|
+
return <SessionUsagePanel api={api} sessionID={props.session_id} />
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
226
|
+
id: pluginID,
|
|
227
|
+
tui,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default plugin
|