opencode-tweaks 0.4.1 → 0.5.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/README.md +39 -22
- package/package.json +3 -2
- package/src/tui.tsx +6 -6
- package/src/tweaks/cost.ts +70 -0
- package/src/tweaks/registry.ts +2 -1
- package/src/tweaks/settings.ts +37 -12
- package/src/tweaks/tokens.ts +3 -39
- package/src/tweaks/types.ts +19 -0
- package/src/tweaks/{token-inject.ts → usage-inject.ts} +8 -8
- package/src/tweaks/{token-slots.tsx → usage-slots.tsx} +69 -59
- package/src/usage/cost.ts +92 -0
- package/src/usage/messages.ts +67 -0
- package/src/usage/tokens.ts +56 -0
- package/src/tweaks/tokens-model.ts +0 -213
package/README.md
CHANGED
|
@@ -95,10 +95,11 @@ Resetting a setting drops the local value, so the option or default applies agai
|
|
|
95
95
|
|
|
96
96
|
## Tweaks
|
|
97
97
|
|
|
98
|
-
| Tweak | What it does
|
|
99
|
-
| -------- |
|
|
100
|
-
| `quota` | Provider quota, reset times, and a daily spending budget.
|
|
101
|
-
| `tokens` | Token
|
|
98
|
+
| Tweak | What it does | Default |
|
|
99
|
+
| -------- | --------------------------------------------------------- | ------- |
|
|
100
|
+
| `quota` | Provider quota, reset times, and a daily spending budget. | On |
|
|
101
|
+
| `tokens` | Token usage per turn, plus session totals at the bottom. | On |
|
|
102
|
+
| `cost` | Cost per turn, plus session totals at the bottom. | On |
|
|
102
103
|
|
|
103
104
|
### Quota
|
|
104
105
|
|
|
@@ -134,7 +135,7 @@ the row. The budget resets at local midnight, and a changed provider reset or wo
|
|
|
134
135
|
|
|
135
136
|
### Tokens
|
|
136
137
|
|
|
137
|
-
Tokens shows two token counts
|
|
138
|
+
Tokens shows two token counts, each with its own toggle.
|
|
138
139
|
|
|
139
140
|
`Turn tokens` adds what the latest finished turn spent to its `tok/s` line: `12.3K tok · 62.2 tok/s`. OpenCode draws
|
|
140
141
|
that line itself and publishes no slot inside it, so the tweak splices the value into the line's text node and repairs
|
|
@@ -148,23 +149,32 @@ receives its figures and the older lines keep theirs.
|
|
|
148
149
|
`First token time` controls the host's own `1.7s ttft` figure at the end of that line: it is off by default, so the
|
|
149
150
|
span is removed and `tok/s` stays alone. Turning it on restores the host's figure.
|
|
150
151
|
|
|
151
|
-
`Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
|
|
152
|
-
|
|
153
152
|
`Session tokens` adds the session's total usage to the bottom status line, before the host's context figure:
|
|
154
|
-
`1 shell · 790.6K tok total ·
|
|
155
|
-
the number stays exact even when the transcript has only
|
|
156
|
-
indicator when one is running and otherwise open the context
|
|
157
|
-
context. OpenCode draws those texts itself and publishes no slot
|
|
158
|
-
repaired while the line changes; when the splice is not available the
|
|
159
|
-
moving elsewhere.
|
|
160
|
-
|
|
161
|
-
`Token count` picks what the
|
|
153
|
+
`1 shell · 790.6K tok total · 115K (29%)`. When the cost tweak is on, its figure sits between the total and the
|
|
154
|
+
context. It reads the counters the session itself keeps, so the number stays exact even when the transcript has only
|
|
155
|
+
loaded its latest messages. The figures follow the shell indicator when one is running and otherwise open the context
|
|
156
|
+
text, so the order stays shell, total, session cost, context. OpenCode draws those texts itself and publishes no slot
|
|
157
|
+
inside them, so the figures are spliced in and repaired while the line changes; when the splice is not available the
|
|
158
|
+
figures stay out of the line rather than moving elsewhere.
|
|
159
|
+
|
|
160
|
+
`Token count` picks what the numbers add up. `Generated only` is the default: it keeps just what the model wrote.
|
|
162
161
|
`New tokens` adds prompt, completion, reasoning, and cache writes, without cache reads, because a turn re-reads the
|
|
163
162
|
whole context at every step and those reads would dwarf everything else. `Everything` matches the counters `/stats`
|
|
164
|
-
reports, cache reads included. `Include subagents` is on by default, so
|
|
165
|
-
instead of only the session on screen; the per-turn
|
|
163
|
+
reports, cache reads included. `Include subagents` is on by default, so both session totals cover the whole session tree
|
|
164
|
+
instead of only the session on screen; the per-turn figures always stay on the session on screen, because a prompt
|
|
166
165
|
belongs to it. `Token format` switches between compact counts such as `12.4K` and every digit such as `12,431`.
|
|
167
166
|
|
|
167
|
+
### Cost
|
|
168
|
+
|
|
169
|
+
Cost shows what a turn and the session spent, each with its own toggle. It reads the same counters as Tokens, and
|
|
170
|
+
either tweak can be off without hiding the other.
|
|
171
|
+
|
|
172
|
+
`Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
|
|
173
|
+
|
|
174
|
+
`Session cost` adds the session's total cost to the bottom status line, before the host's context figure:
|
|
175
|
+
`1 shell · 790.6K tok total · $0.91 · 115K (29%)`. It follows the same session tree as the token total, including
|
|
176
|
+
`Include subagents`.
|
|
177
|
+
|
|
168
178
|
Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost decimals` controls the decimals:
|
|
169
179
|
`Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
|
|
170
180
|
keeps that many (`$0.0042`).
|
|
@@ -175,6 +185,8 @@ provider that reports a price always wins, and a session that changed model is e
|
|
|
175
185
|
treat the figure as an estimate. This is what makes the OpenCode Go monthly credit readable: the plan reports `$0.00`
|
|
176
186
|
per turn, and the estimate follows the API rates instead. Costs use the raw counters whatever `Token count` says.
|
|
177
187
|
|
|
188
|
+
The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
|
|
189
|
+
|
|
178
190
|
## Development
|
|
179
191
|
|
|
180
192
|
```sh
|
|
@@ -194,8 +206,13 @@ src/
|
|
|
194
206
|
quota/
|
|
195
207
|
service.ts credential lookup and quota fetch
|
|
196
208
|
parse.ts provider response parsing
|
|
209
|
+
pace.ts daily budget pacing
|
|
197
210
|
format.ts percents, reset times, and bars
|
|
198
|
-
|
|
211
|
+
usage/
|
|
212
|
+
messages.ts message shape and turn selection
|
|
213
|
+
tokens.ts token sums and formatting
|
|
214
|
+
cost.ts list prices, estimates, and cost formatting
|
|
215
|
+
tui.tsx terminal entry: commands and slots
|
|
199
216
|
tweaks/
|
|
200
217
|
types.ts settings schema and validation
|
|
201
218
|
registry.ts list of tweaks
|
|
@@ -205,10 +222,10 @@ src/
|
|
|
205
222
|
menu-model.ts filtering and selection helpers
|
|
206
223
|
quota.ts quota tweak definition
|
|
207
224
|
quota-panel.tsx sidebar quota panel
|
|
208
|
-
tokens.ts
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
225
|
+
tokens.ts token tweak definition
|
|
226
|
+
cost.ts cost tweak definition
|
|
227
|
+
usage-slots.tsx inline and status line figures
|
|
228
|
+
usage-inject.ts splice into the host's lines
|
|
212
229
|
```
|
|
213
230
|
|
|
214
231
|
## Contributing
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-tweaks",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Opt-in settings and small interface improvements for OpenCode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"tui",
|
|
24
24
|
"settings",
|
|
25
25
|
"quota",
|
|
26
|
-
"tokens"
|
|
26
|
+
"tokens",
|
|
27
|
+
"cost"
|
|
27
28
|
],
|
|
28
29
|
"exports": {
|
|
29
30
|
".": "./src/index.ts",
|
package/src/tui.tsx
CHANGED
|
@@ -2,14 +2,14 @@ import { Plugin } from "@opencode/plugin/tui"
|
|
|
2
2
|
import { openTweaksMenu } from "./tweaks/menu"
|
|
3
3
|
import { QuotaPanel } from "./tweaks/quota-panel"
|
|
4
4
|
import { createSettingsStore } from "./tweaks/settings"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createUsageInjector } from "./tweaks/usage-inject"
|
|
6
|
+
import { UsageInline, UsageStatus } from "./tweaks/usage-slots"
|
|
7
7
|
|
|
8
8
|
export default Plugin.define({
|
|
9
9
|
id: "opencode-tweaks.tui",
|
|
10
10
|
setup(context) {
|
|
11
11
|
const store = createSettingsStore(context)
|
|
12
|
-
const
|
|
12
|
+
const usage = createUsageInjector(context)
|
|
13
13
|
let menuOpen = false
|
|
14
14
|
|
|
15
15
|
context.ui.slot({
|
|
@@ -20,14 +20,14 @@ export default Plugin.define({
|
|
|
20
20
|
context.ui.slot({
|
|
21
21
|
append: "session.composer.top",
|
|
22
22
|
render: (input) => (
|
|
23
|
-
<
|
|
23
|
+
<UsageInline context={context} input={input} store={store} injector={usage} />
|
|
24
24
|
),
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
context.ui.slot({
|
|
28
28
|
after: "prompt.footer.status",
|
|
29
29
|
render: (input) => (
|
|
30
|
-
<
|
|
30
|
+
<UsageStatus context={context} input={input} store={store} injector={usage} />
|
|
31
31
|
),
|
|
32
32
|
})
|
|
33
33
|
|
|
@@ -61,6 +61,6 @@ export default Plugin.define({
|
|
|
61
61
|
],
|
|
62
62
|
}))
|
|
63
63
|
|
|
64
|
-
return () =>
|
|
64
|
+
return () => usage.dispose()
|
|
65
65
|
},
|
|
66
66
|
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineTweak } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cost reads the same counters as token usage but stays its own tweak: a
|
|
5
|
+
* provider that reports nothing still benefits from an estimate, while a user
|
|
6
|
+
* who only wants the counts can leave the prices off. The figures share the
|
|
7
|
+
* turn's throughput line and the session status line with the token counts.
|
|
8
|
+
*/
|
|
9
|
+
export const costTweak = defineTweak({
|
|
10
|
+
id: "cost",
|
|
11
|
+
title: "Cost",
|
|
12
|
+
description: "Show what a turn and the whole session cost.",
|
|
13
|
+
category: "Session",
|
|
14
|
+
/** Cost used to live in the token tweak; keep those choices readable. */
|
|
15
|
+
legacy: {
|
|
16
|
+
id: "tokens",
|
|
17
|
+
settings: {
|
|
18
|
+
prompt: "promptCost",
|
|
19
|
+
total: "totalCost",
|
|
20
|
+
digits: "costDigits",
|
|
21
|
+
estimate: "estimateCost",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
settings: [
|
|
25
|
+
{
|
|
26
|
+
id: "enabled",
|
|
27
|
+
title: "Enabled",
|
|
28
|
+
description: "Turn the cost figures on.",
|
|
29
|
+
category: "General",
|
|
30
|
+
kind: "boolean",
|
|
31
|
+
default: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "prompt",
|
|
35
|
+
title: "Turn cost",
|
|
36
|
+
description: "Show that turn's cost after the tok/s figure.",
|
|
37
|
+
category: "Display",
|
|
38
|
+
kind: "boolean",
|
|
39
|
+
default: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "total",
|
|
43
|
+
title: "Session cost",
|
|
44
|
+
description: "Show the session's total cost on the bottom status line.",
|
|
45
|
+
category: "Display",
|
|
46
|
+
kind: "boolean",
|
|
47
|
+
default: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "digits",
|
|
51
|
+
title: "Cost decimals",
|
|
52
|
+
description:
|
|
53
|
+
"Decimals shown for costs, or auto to keep going until the first significant digit.",
|
|
54
|
+
category: "Display",
|
|
55
|
+
kind: "select",
|
|
56
|
+
default: "auto",
|
|
57
|
+
values: ["auto", 2, 3, 4, 5, 6],
|
|
58
|
+
labels: ["Auto", "2 digits", "3 digits", "4 digits", "5 digits", "6 digits"],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "estimate",
|
|
62
|
+
title: "Estimate cost",
|
|
63
|
+
description:
|
|
64
|
+
"Guess the cost from the model's list price when the provider reports none. A session that changed model is estimated with the model it ended on.",
|
|
65
|
+
category: "Scope",
|
|
66
|
+
kind: "boolean",
|
|
67
|
+
default: true,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
})
|
package/src/tweaks/registry.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { costTweak } from "./cost"
|
|
1
2
|
import { quotaTweak } from "./quota"
|
|
2
3
|
import { tokensTweak } from "./tokens"
|
|
3
4
|
import type { Tweak } from "./types"
|
|
4
5
|
|
|
5
6
|
/** Every tweak shown by `/tweaks`. Add new definitions here. */
|
|
6
|
-
export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak]
|
|
7
|
+
export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak]
|
|
7
8
|
|
|
8
9
|
export function findTweak(id: string): Tweak | undefined {
|
|
9
10
|
return tweaks.find((tweak) => tweak.id === id)
|
package/src/tweaks/settings.ts
CHANGED
|
@@ -18,10 +18,28 @@ interface OptionsTree {
|
|
|
18
18
|
readonly [key: string]: unknown
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** One setting from a group in `opencode.json`, if the group holds one. */
|
|
22
|
+
function configValue(group: unknown, settingID: string): unknown {
|
|
23
|
+
if (!group || typeof group !== "object") return undefined
|
|
24
|
+
return (group as OptionsTree)[settingID]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drops one stored value, and the empty group it leaves behind. */
|
|
28
|
+
function forget(stored: StoredSettings, tweakID: string, settingID: string) {
|
|
29
|
+
const group = stored[tweakID]
|
|
30
|
+
if (!group) return
|
|
31
|
+
|
|
32
|
+
delete group[settingID]
|
|
33
|
+
if (Object.keys(group).length === 0) delete stored[tweakID]
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* Reads settings in priority order: value chosen in `/tweaks`, then the plugin
|
|
23
38
|
* options in `opencode.json`, then the definition default. Writes only touch
|
|
24
39
|
* the local layer, so the config file stays the user's to edit.
|
|
40
|
+
*
|
|
41
|
+
* A split tweak also checks the group its settings came from, so a value chosen
|
|
42
|
+
* before the split keeps applying until the user changes or resets it.
|
|
25
43
|
*/
|
|
26
44
|
export function createSettingsStore(context: Context): SettingsStore {
|
|
27
45
|
const [stored, update] = context.storage.store<StoredSettings>("settings", { initial: {} })
|
|
@@ -33,14 +51,22 @@ export function createSettingsStore(context: Context): SettingsStore {
|
|
|
33
51
|
return { value: userValue, source: "user" }
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
const tweakConfig = configValue(options[tweak.id], setting.id)
|
|
55
|
+
if (isSettingValue(setting, tweakConfig)) {
|
|
56
|
+
return { value: tweakConfig, source: "config" }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const legacyID = tweak.legacy?.settings[setting.id]
|
|
60
|
+
if (tweak.legacy && legacyID) {
|
|
61
|
+
const legacyUser = stored[tweak.legacy.id]?.[legacyID]
|
|
62
|
+
if (isSettingValue(setting, legacyUser)) {
|
|
63
|
+
return { value: legacyUser, source: "user" }
|
|
64
|
+
}
|
|
41
65
|
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
const legacyConfig = configValue(options[tweak.legacy.id], legacyID)
|
|
67
|
+
if (isSettingValue(setting, legacyConfig)) {
|
|
68
|
+
return { value: legacyConfig, source: "config" }
|
|
69
|
+
}
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
return { value: setting.default, source: "default" }
|
|
@@ -60,12 +86,11 @@ export function createSettingsStore(context: Context): SettingsStore {
|
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
async function reset(tweak: Tweak, setting: TweakSetting): Promise<void> {
|
|
63
|
-
|
|
64
|
-
const group = draft[tweak.id]
|
|
65
|
-
if (!group) return
|
|
89
|
+
const legacyID = tweak.legacy?.settings[setting.id]
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
await update((draft) => {
|
|
92
|
+
forget(draft, tweak.id, setting.id)
|
|
93
|
+
if (tweak.legacy && legacyID) forget(draft, tweak.legacy.id, legacyID)
|
|
69
94
|
})
|
|
70
95
|
}
|
|
71
96
|
|
package/src/tweaks/tokens.ts
CHANGED
|
@@ -8,13 +8,13 @@ import { defineTweak } from "./types"
|
|
|
8
8
|
export const tokensTweak = defineTweak({
|
|
9
9
|
id: "tokens",
|
|
10
10
|
title: "Tokens",
|
|
11
|
-
description: "Show token
|
|
11
|
+
description: "Show token usage for a turn and for the whole session.",
|
|
12
12
|
category: "Session",
|
|
13
13
|
settings: [
|
|
14
14
|
{
|
|
15
15
|
id: "enabled",
|
|
16
16
|
title: "Enabled",
|
|
17
|
-
description: "Turn the token
|
|
17
|
+
description: "Turn the token counts on.",
|
|
18
18
|
category: "General",
|
|
19
19
|
kind: "boolean",
|
|
20
20
|
default: true,
|
|
@@ -28,14 +28,6 @@ export const tokensTweak = defineTweak({
|
|
|
28
28
|
kind: "boolean",
|
|
29
29
|
default: true,
|
|
30
30
|
},
|
|
31
|
-
{
|
|
32
|
-
id: "promptCost",
|
|
33
|
-
title: "Turn cost",
|
|
34
|
-
description: "Show that turn's cost after the tok/s figure.",
|
|
35
|
-
category: "Display",
|
|
36
|
-
kind: "boolean",
|
|
37
|
-
default: true,
|
|
38
|
-
},
|
|
39
31
|
{
|
|
40
32
|
id: "total",
|
|
41
33
|
title: "Session tokens",
|
|
@@ -44,14 +36,6 @@ export const tokensTweak = defineTweak({
|
|
|
44
36
|
kind: "boolean",
|
|
45
37
|
default: true,
|
|
46
38
|
},
|
|
47
|
-
{
|
|
48
|
-
id: "totalCost",
|
|
49
|
-
title: "Session cost",
|
|
50
|
-
description: "Show the session's total cost on the bottom status line.",
|
|
51
|
-
category: "Display",
|
|
52
|
-
kind: "boolean",
|
|
53
|
-
default: true,
|
|
54
|
-
},
|
|
55
39
|
{
|
|
56
40
|
id: "format",
|
|
57
41
|
title: "Token format",
|
|
@@ -61,17 +45,6 @@ export const tokensTweak = defineTweak({
|
|
|
61
45
|
default: "compact",
|
|
62
46
|
values: ["compact", "exact"],
|
|
63
47
|
},
|
|
64
|
-
{
|
|
65
|
-
id: "costDigits",
|
|
66
|
-
title: "Cost decimals",
|
|
67
|
-
description:
|
|
68
|
-
"Decimals shown for costs, or auto to keep going until the first significant digit.",
|
|
69
|
-
category: "Display",
|
|
70
|
-
kind: "select",
|
|
71
|
-
default: "auto",
|
|
72
|
-
values: ["auto", 2, 3, 4, 5, 6],
|
|
73
|
-
labels: ["Auto", "2 digits", "3 digits", "4 digits", "5 digits", "6 digits"],
|
|
74
|
-
},
|
|
75
48
|
{
|
|
76
49
|
id: "ttft",
|
|
77
50
|
title: "First token time",
|
|
@@ -95,16 +68,7 @@ export const tokensTweak = defineTweak({
|
|
|
95
68
|
{
|
|
96
69
|
id: "subagents",
|
|
97
70
|
title: "Include subagents",
|
|
98
|
-
description: "Add the
|
|
99
|
-
category: "Scope",
|
|
100
|
-
kind: "boolean",
|
|
101
|
-
default: true,
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
id: "estimateCost",
|
|
105
|
-
title: "Estimate cost",
|
|
106
|
-
description:
|
|
107
|
-
"Guess the cost from the model's list price when the provider reports none. A session that changed model is estimated with the model it ended on.",
|
|
71
|
+
description: "Add the usage of subagents to the session totals. Costs follow the same scope.",
|
|
108
72
|
category: "Scope",
|
|
109
73
|
kind: "boolean",
|
|
110
74
|
default: true,
|
package/src/tweaks/types.ts
CHANGED
|
@@ -30,6 +30,18 @@ export interface TweakSetting {
|
|
|
30
30
|
readonly format?: (value: SettingValue) => string
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Where a tweak's settings lived before it was split out. The store reads the
|
|
35
|
+
* old group when the new one holds no value, so a rename keeps the choice the
|
|
36
|
+
* user already made instead of silently resetting it.
|
|
37
|
+
*/
|
|
38
|
+
export interface TweakLegacy {
|
|
39
|
+
/** The tweak id the settings used to be stored under. */
|
|
40
|
+
readonly id: string
|
|
41
|
+
/** Current setting id to the id it used to have. */
|
|
42
|
+
readonly settings: Readonly<Record<string, string>>
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
export interface Tweak {
|
|
34
46
|
readonly id: string
|
|
35
47
|
readonly title: string
|
|
@@ -37,6 +49,7 @@ export interface Tweak {
|
|
|
37
49
|
/** Group shown in the `/tweaks` list. */
|
|
38
50
|
readonly category: string
|
|
39
51
|
readonly settings: readonly TweakSetting[]
|
|
52
|
+
readonly legacy?: TweakLegacy
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/** Settings changed from the menu, keyed by tweak and setting id. */
|
|
@@ -105,6 +118,12 @@ export function defineTweak(tweak: Tweak): Tweak {
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
for (const id of Object.keys(tweak.legacy?.settings ?? {})) {
|
|
122
|
+
if (!ids.has(id)) {
|
|
123
|
+
throw new Error(`Legacy setting "${tweak.id}.${id}" is not defined`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
return tweak
|
|
109
128
|
}
|
|
110
129
|
|
|
@@ -52,9 +52,9 @@ const THROUGHPUT = / \d[\d.]* tok\/s/
|
|
|
52
52
|
const FOOTER_USAGE = /^(?: · )?\d[\d.,]*\s?[KMG]?\s?\(\d+%\)(?:\s·\s\$[\d.,]+){0,2}$/
|
|
53
53
|
/** The line is rebuilt on every token, so a slow repair would lag behind it. */
|
|
54
54
|
const REPAIR_MS = 400
|
|
55
|
-
const BEFORE_ID = "opencode-tweaks.
|
|
56
|
-
const AFTER_ID = "opencode-tweaks.
|
|
57
|
-
const FOOTER_ID = "opencode-tweaks.
|
|
55
|
+
const BEFORE_ID = "opencode-tweaks.usage.before"
|
|
56
|
+
const AFTER_ID = "opencode-tweaks.usage.after"
|
|
57
|
+
const FOOTER_ID = "opencode-tweaks.usage.footer"
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Hot reload evaluates this module again while the old repair timers keep
|
|
@@ -62,10 +62,10 @@ const FOOTER_ID = "opencode-tweaks.tokens.footer"
|
|
|
62
62
|
* never reached its own dispose.
|
|
63
63
|
*/
|
|
64
64
|
const timers = new Set<ReturnType<typeof setInterval>>()
|
|
65
|
-
const previousTimers = (globalThis as {
|
|
66
|
-
.
|
|
65
|
+
const previousTimers = (globalThis as { usageInjectorTimers?: Set<ReturnType<typeof setInterval>> })
|
|
66
|
+
.usageInjectorTimers
|
|
67
67
|
for (const timer of previousTimers ?? []) clearInterval(timer)
|
|
68
|
-
;(globalThis as {
|
|
68
|
+
;(globalThis as { usageInjectorTimers?: Set<ReturnType<typeof setInterval>> }).usageInjectorTimers =
|
|
69
69
|
timers
|
|
70
70
|
|
|
71
71
|
/**
|
|
@@ -229,7 +229,7 @@ export interface InjectorLabels {
|
|
|
229
229
|
readonly after?: string
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
export interface
|
|
232
|
+
export interface UsageInjector {
|
|
233
233
|
/**
|
|
234
234
|
* The figures for the newest footer, tagged with the turn they belong to.
|
|
235
235
|
* Empty fields hide them there.
|
|
@@ -257,7 +257,7 @@ interface Placed {
|
|
|
257
257
|
afterNode?: HostNode
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
export function
|
|
260
|
+
export function createUsageInjector(context: Context): UsageInjector {
|
|
261
261
|
let labels: InjectorLabels = {}
|
|
262
262
|
let key: string | undefined
|
|
263
263
|
let active: HostText | undefined
|
|
@@ -1,63 +1,64 @@
|
|
|
1
1
|
import type { Context, SlotMap } from "@opencode/plugin/tui/context"
|
|
2
2
|
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js"
|
|
3
|
-
import type { SettingsStore } from "./settings"
|
|
4
|
-
import type { TokenInjector } from "./token-inject"
|
|
5
|
-
import { tokensTweak } from "./tokens"
|
|
6
3
|
import {
|
|
7
4
|
type CostDigits,
|
|
8
5
|
estimateCost,
|
|
9
|
-
footerMessage,
|
|
10
6
|
formatCost,
|
|
11
|
-
formatTokens,
|
|
12
7
|
isCostDigits,
|
|
13
8
|
type ModelPrice,
|
|
14
9
|
messagesCost,
|
|
15
10
|
priceFor,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} from "./tokens
|
|
22
|
-
import type { TweakSetting } from "./types"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
11
|
+
} from "../usage/cost"
|
|
12
|
+
import { footerMessage, replyTurnMessages, type UsageMessage } from "../usage/messages"
|
|
13
|
+
import { formatTokens, sessionTokens, type TokenCount, usageTokens } from "../usage/tokens"
|
|
14
|
+
import { costTweak } from "./cost"
|
|
15
|
+
import type { SettingsStore } from "./settings"
|
|
16
|
+
import { tokensTweak } from "./tokens"
|
|
17
|
+
import type { Tweak, TweakSetting } from "./types"
|
|
18
|
+
import type { UsageInjector } from "./usage-inject"
|
|
19
|
+
|
|
20
|
+
function setting(tweak: Tweak, id: string): TweakSetting {
|
|
21
|
+
const found = tweak.settings.find((item) => item.id === id)
|
|
22
|
+
if (!found) throw new Error(`${tweak.title} setting "${id}" is missing`)
|
|
27
23
|
return found
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
const
|
|
31
|
-
const PROMPT = setting("prompt")
|
|
32
|
-
const TOTAL = setting("total")
|
|
33
|
-
const SUBAGENTS = setting("subagents")
|
|
34
|
-
const COUNT = setting("count")
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
26
|
+
const TOKENS_ON = setting(tokensTweak, "enabled")
|
|
27
|
+
const PROMPT = setting(tokensTweak, "prompt")
|
|
28
|
+
const TOTAL = setting(tokensTweak, "total")
|
|
29
|
+
const SUBAGENTS = setting(tokensTweak, "subagents")
|
|
30
|
+
const COUNT = setting(tokensTweak, "count")
|
|
31
|
+
const FORMAT = setting(tokensTweak, "format")
|
|
32
|
+
const TTFT = setting(tokensTweak, "ttft")
|
|
33
|
+
|
|
34
|
+
const COST_ON = setting(costTweak, "enabled")
|
|
35
|
+
const PROMPT_COST = setting(costTweak, "prompt")
|
|
36
|
+
const TOTAL_COST = setting(costTweak, "total")
|
|
37
|
+
const COST_DIGITS = setting(costTweak, "digits")
|
|
38
|
+
const ESTIMATE = setting(costTweak, "estimate")
|
|
41
39
|
|
|
42
40
|
/**
|
|
43
|
-
* The
|
|
44
|
-
* session because a prompt belongs to it. The
|
|
45
|
-
* session tree, since a subagent's
|
|
41
|
+
* The figures, shared by both placements. The prompt figures follow the open
|
|
42
|
+
* session because a prompt belongs to it. The totals optionally follow the
|
|
43
|
+
* session tree, since a subagent's usage belongs to the turn its parent
|
|
44
|
+
* started. Tokens and cost can be switched off independently.
|
|
46
45
|
*/
|
|
47
|
-
function
|
|
46
|
+
function useUsage(props: {
|
|
48
47
|
readonly context: Context
|
|
49
48
|
readonly store: SettingsStore
|
|
50
49
|
readonly sessionID: () => string | undefined
|
|
51
50
|
}) {
|
|
52
|
-
const
|
|
51
|
+
const tokensOn = () => props.store.read(tokensTweak, TOKENS_ON).value === true
|
|
52
|
+
const costOn = () => props.store.read(costTweak, COST_ON).value === true
|
|
53
53
|
const perPrompt = () => props.store.read(tokensTweak, PROMPT).value === true
|
|
54
54
|
const sessionTotal = () => props.store.read(tokensTweak, TOTAL).value === true
|
|
55
55
|
const subagents = () => props.store.read(tokensTweak, SUBAGENTS).value === true
|
|
56
|
-
const promptCost = () => props.store.read(
|
|
57
|
-
const totalCost = () => props.store.read(
|
|
58
|
-
const estimate = () => props.store.read(
|
|
56
|
+
const promptCost = () => props.store.read(costTweak, PROMPT_COST).value === true
|
|
57
|
+
const totalCost = () => props.store.read(costTweak, TOTAL_COST).value === true
|
|
58
|
+
const estimate = () => props.store.read(costTweak, ESTIMATE).value === true
|
|
59
|
+
const ttft = () => props.store.read(tokensTweak, TTFT).value !== false
|
|
59
60
|
const costDigits = (): CostDigits => {
|
|
60
|
-
const value = props.store.read(
|
|
61
|
+
const value = props.store.read(costTweak, COST_DIGITS).value
|
|
61
62
|
return isCostDigits(value) ? value : "auto"
|
|
62
63
|
}
|
|
63
64
|
const count = (): TokenCount => {
|
|
@@ -157,7 +158,7 @@ function useCounts(props: {
|
|
|
157
158
|
if (!replied()) return undefined
|
|
158
159
|
|
|
159
160
|
const lookup = estimate()
|
|
160
|
-
? (message:
|
|
161
|
+
? (message: UsageMessage) => {
|
|
161
162
|
const tokens = message.tokens
|
|
162
163
|
if (!tokens) return undefined
|
|
163
164
|
return priceOf(message.model, tokens.input + tokens.cache.read)
|
|
@@ -170,54 +171,63 @@ function useCounts(props: {
|
|
|
170
171
|
/** The formatted count, or `undefined` while there is nothing worth showing. */
|
|
171
172
|
const format = (value: number) => (value > 0 ? formatTokens(value, style()) : undefined)
|
|
172
173
|
|
|
173
|
-
const promptCount = () => (
|
|
174
|
-
const totalCount = () => (
|
|
174
|
+
const promptCount = () => (tokensOn() && perPrompt() ? format(prompt()) : undefined)
|
|
175
|
+
const totalCount = () => (tokensOn() && sessionTotal() ? format(total()) : undefined)
|
|
175
176
|
const promptCostLabel = () => {
|
|
176
177
|
const value = cost()
|
|
177
|
-
return
|
|
178
|
+
return costOn() && promptCost() && value !== undefined
|
|
178
179
|
? formatCost(value, costDigits())
|
|
179
180
|
: undefined
|
|
180
181
|
}
|
|
181
182
|
const totalCostLabel = () =>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return {
|
|
183
|
+
costOn() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
enabled: () => tokensOn() || costOn(),
|
|
187
|
+
// The host keeps its own ttft figure whenever the token tweak is off.
|
|
188
|
+
ttft: () => !tokensOn() || ttft(),
|
|
189
|
+
promptKey,
|
|
190
|
+
promptCount,
|
|
191
|
+
totalCount,
|
|
192
|
+
promptCostLabel,
|
|
193
|
+
totalCostLabel,
|
|
194
|
+
}
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/**
|
|
188
|
-
* Inline placement has no row of its own: it hands the prompt
|
|
189
|
-
* injector, which splices
|
|
198
|
+
* Inline placement has no row of its own: it hands the prompt figures to the
|
|
199
|
+
* injector, which splices them into the host's tok/s line. The composer slot is
|
|
190
200
|
* claimed anyway because it is the published place that gives the plugin a
|
|
191
201
|
* reactive session context above the prompt; this component renders nothing.
|
|
192
202
|
*/
|
|
193
|
-
export function
|
|
203
|
+
export function UsageInline(props: {
|
|
194
204
|
readonly context: Context
|
|
195
205
|
readonly input: SlotMap["session.composer.top"]
|
|
196
206
|
readonly store: SettingsStore
|
|
197
|
-
readonly injector:
|
|
207
|
+
readonly injector: UsageInjector
|
|
198
208
|
}) {
|
|
199
|
-
const
|
|
209
|
+
const usage = useUsage({
|
|
200
210
|
context: props.context,
|
|
201
211
|
store: props.store,
|
|
202
212
|
sessionID: () => props.input.sessionID,
|
|
203
213
|
})
|
|
204
214
|
|
|
205
215
|
createEffect(() => {
|
|
206
|
-
if (!
|
|
216
|
+
if (!usage.enabled()) {
|
|
207
217
|
props.injector.clear()
|
|
208
218
|
return
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
props.injector.setTtft(
|
|
221
|
+
props.injector.setTtft(usage.ttft())
|
|
212
222
|
|
|
213
|
-
const count =
|
|
214
|
-
const cost =
|
|
223
|
+
const count = usage.promptCount()
|
|
224
|
+
const cost = usage.promptCostLabel()
|
|
215
225
|
props.injector.setLabels(
|
|
216
226
|
{
|
|
217
227
|
before: count ? ` · ${count} tok` : undefined,
|
|
218
228
|
after: cost ? ` · ${cost}` : undefined,
|
|
219
229
|
},
|
|
220
|
-
|
|
230
|
+
usage.promptKey(),
|
|
221
231
|
)
|
|
222
232
|
})
|
|
223
233
|
|
|
@@ -225,13 +235,13 @@ export function TokenInline(props: {
|
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
/** The bottom status line, next to the working directory and the other details. */
|
|
228
|
-
export function
|
|
238
|
+
export function UsageStatus(props: {
|
|
229
239
|
readonly context: Context
|
|
230
240
|
readonly input: SlotMap["prompt.footer.status"]
|
|
231
241
|
readonly store: SettingsStore
|
|
232
|
-
readonly injector:
|
|
242
|
+
readonly injector: UsageInjector
|
|
233
243
|
}) {
|
|
234
|
-
const
|
|
244
|
+
const usage = useUsage({
|
|
235
245
|
context: props.context,
|
|
236
246
|
store: props.store,
|
|
237
247
|
sessionID: () => props.input.sessionID,
|
|
@@ -264,9 +274,9 @@ export function TokenStatus(props: {
|
|
|
264
274
|
|
|
265
275
|
const figures = () => {
|
|
266
276
|
const parts: string[] = []
|
|
267
|
-
const tokens =
|
|
277
|
+
const tokens = usage.totalCount()
|
|
268
278
|
if (tokens) parts.push(`${tokens} tok total`)
|
|
269
|
-
const cost =
|
|
279
|
+
const cost = usage.totalCostLabel()
|
|
270
280
|
if (cost) parts.push(cost)
|
|
271
281
|
return parts.length > 0 ? parts.join(" · ") : undefined
|
|
272
282
|
}
|
|
@@ -291,7 +301,7 @@ export function TokenStatus(props: {
|
|
|
291
301
|
const present = children()
|
|
292
302
|
if (present === lastPresence) return
|
|
293
303
|
lastPresence = present
|
|
294
|
-
// The
|
|
304
|
+
// The usage counters must not re-run this effect: it would cancel the
|
|
295
305
|
// follow-up passes every time a figure changes.
|
|
296
306
|
untrack(repaint)
|
|
297
307
|
const passes = [16, 50, 150].map((delay) => setTimeout(() => untrack(repaint), delay))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { MessageTokens, UsageMessage } from "./messages"
|
|
2
|
+
|
|
3
|
+
/** A model's list price, in dollars per million tokens. */
|
|
4
|
+
export interface ModelPrice {
|
|
5
|
+
readonly input: number
|
|
6
|
+
readonly output: number
|
|
7
|
+
readonly cache: { readonly read: number; readonly write: number }
|
|
8
|
+
readonly tier?: { readonly size: number }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The price that applies at a context size: the base price, raised by a tier
|
|
13
|
+
* once the request is large enough. Long-context pricing works this way.
|
|
14
|
+
*/
|
|
15
|
+
export function priceFor(prices: readonly ModelPrice[], context: number): ModelPrice | undefined {
|
|
16
|
+
let base: ModelPrice | undefined
|
|
17
|
+
let tiered: ModelPrice | undefined
|
|
18
|
+
|
|
19
|
+
for (const price of prices) {
|
|
20
|
+
const tier = price.tier
|
|
21
|
+
if (!tier) {
|
|
22
|
+
base ??= price
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (context < tier.size) continue
|
|
26
|
+
if (!tiered?.tier || tier.size > tiered.tier.size) tiered = price
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return tiered ?? base ?? prices[0]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The list-price cost of one message's counters. Reasoning is billed at the
|
|
34
|
+
* output price, as providers do.
|
|
35
|
+
*/
|
|
36
|
+
export function estimateCost(tokens: MessageTokens, price: ModelPrice): number {
|
|
37
|
+
const { input, output, reasoning, cache } = tokens
|
|
38
|
+
const total =
|
|
39
|
+
input * price.input +
|
|
40
|
+
(output + reasoning) * price.output +
|
|
41
|
+
cache.read * price.cache.read +
|
|
42
|
+
cache.write * price.cache.write
|
|
43
|
+
|
|
44
|
+
return total / 1_000_000
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves the list price of one message, if the model is known. */
|
|
48
|
+
export type PriceLookup = (message: UsageMessage) => ModelPrice | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The cost of a list of messages: what the provider reported, or the list-price
|
|
52
|
+
* estimate when it reported nothing and a price is available.
|
|
53
|
+
*/
|
|
54
|
+
export function messagesCost(messages: readonly UsageMessage[], price?: PriceLookup): number {
|
|
55
|
+
return messages.reduce((total, message) => {
|
|
56
|
+
const reported = message.cost ?? 0
|
|
57
|
+
if (reported > 0) return total + reported
|
|
58
|
+
if (!price || !message.tokens) return total + reported
|
|
59
|
+
|
|
60
|
+
const value = price(message)
|
|
61
|
+
return total + (value ? estimateCost(message.tokens, value) : reported)
|
|
62
|
+
}, 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CostDigits = "auto" | 2 | 3 | 4 | 5 | 6
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Dollars with two decimals, or more under a cent so the first significant
|
|
69
|
+
* digit shows. `auto` picks the decimals; a number fixes them.
|
|
70
|
+
*/
|
|
71
|
+
export function formatCost(value: number, digits: CostDigits = "auto"): string {
|
|
72
|
+
const cost = Math.max(0, value)
|
|
73
|
+
const places = digits === "auto" ? autoDigits(cost) : digits
|
|
74
|
+
|
|
75
|
+
return new Intl.NumberFormat("en-US", {
|
|
76
|
+
style: "currency",
|
|
77
|
+
currency: "USD",
|
|
78
|
+
minimumFractionDigits: places,
|
|
79
|
+
maximumFractionDigits: places,
|
|
80
|
+
}).format(cost)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a stored or configured value is a cost digit choice. */
|
|
84
|
+
export function isCostDigits(value: unknown): value is CostDigits {
|
|
85
|
+
return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
|
|
89
|
+
function autoDigits(cost: number): number {
|
|
90
|
+
if (cost === 0 || cost >= 0.01) return 2
|
|
91
|
+
return Math.min(6, Math.ceil(-Math.log10(cost)))
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** The token counters OpenCode stores on an assistant message. */
|
|
2
|
+
export interface MessageTokens {
|
|
3
|
+
readonly input: number
|
|
4
|
+
readonly output: number
|
|
5
|
+
readonly reasoning: number
|
|
6
|
+
readonly cache: { readonly read: number; readonly write: number }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Only what usage sums need, so any session message list fits. */
|
|
10
|
+
export interface UsageMessage {
|
|
11
|
+
readonly id?: string
|
|
12
|
+
readonly type: string
|
|
13
|
+
readonly tokens?: MessageTokens
|
|
14
|
+
readonly cost?: number
|
|
15
|
+
readonly model?: { readonly providerID: string; readonly id: string }
|
|
16
|
+
/** How the step ended; the host only draws a footer for a finished one. */
|
|
17
|
+
readonly finish?: string | null
|
|
18
|
+
readonly error?: unknown
|
|
19
|
+
readonly retry?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The index just after the last user prompt before `end`. */
|
|
23
|
+
function turnStart(messages: readonly UsageMessage[], end: number): number {
|
|
24
|
+
for (let index = end - 1; index >= 0; index--) {
|
|
25
|
+
if (messages[index]?.type === "user") return index + 1
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where the turn that starts at `start` ends: at the next prompt, or the end. */
|
|
31
|
+
function turnEnd(messages: readonly UsageMessage[], start: number): number {
|
|
32
|
+
for (let index = start; index < messages.length; index++) {
|
|
33
|
+
if (messages[index]?.type === "user") return index
|
|
34
|
+
}
|
|
35
|
+
return messages.length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The newest message the host draws a footer under. A footer belongs to the
|
|
40
|
+
* last step of a turn: one that stopped for any reason other than another tool
|
|
41
|
+
* call, or one that failed or is being retried.
|
|
42
|
+
*/
|
|
43
|
+
export function footerMessage(messages: readonly UsageMessage[]): UsageMessage | undefined {
|
|
44
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
45
|
+
const message = messages[index]
|
|
46
|
+
if (message?.type !== "assistant") continue
|
|
47
|
+
if (message.error || message.retry) return message
|
|
48
|
+
if (message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") {
|
|
49
|
+
return message
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The messages of the turn whose footer is on screen. A prompt with no finished
|
|
57
|
+
* reply yet leaves the previous turn's line there, so that line keeps showing
|
|
58
|
+
* the previous turn's numbers instead of the running one's.
|
|
59
|
+
*/
|
|
60
|
+
export function replyTurnMessages(messages: readonly UsageMessage[]): readonly UsageMessage[] {
|
|
61
|
+
const footer = footerMessage(messages)
|
|
62
|
+
if (!footer) return []
|
|
63
|
+
|
|
64
|
+
const last = messages.lastIndexOf(footer)
|
|
65
|
+
const start = turnStart(messages, last + 1)
|
|
66
|
+
return messages.slice(start, turnEnd(messages, start))
|
|
67
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type MessageTokens, replyTurnMessages, type UsageMessage } from "./messages"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which counters a count adds up.
|
|
5
|
+
*
|
|
6
|
+
* - `new` is what the model actually processed: prompt, completion, reasoning,
|
|
7
|
+
* and cache writes, without cache reads. A turn re-reads the whole context at
|
|
8
|
+
* every step, so cache reads would dwarf every other counter.
|
|
9
|
+
* - `generated` is only what the model wrote.
|
|
10
|
+
* - `all` matches the counters `/stats` reports, cache reads included.
|
|
11
|
+
*/
|
|
12
|
+
export type TokenCount = "new" | "generated" | "all"
|
|
13
|
+
|
|
14
|
+
/** The tokens in one assistant message's counters under the chosen definition. */
|
|
15
|
+
export function usageTokens(tokens: MessageTokens, mode: TokenCount): number {
|
|
16
|
+
const { input, output, reasoning, cache } = tokens
|
|
17
|
+
|
|
18
|
+
switch (mode) {
|
|
19
|
+
case "generated":
|
|
20
|
+
return output + reasoning
|
|
21
|
+
|
|
22
|
+
case "new":
|
|
23
|
+
return input + output + reasoning + cache.write
|
|
24
|
+
|
|
25
|
+
case "all":
|
|
26
|
+
return input + output + reasoning + cache.read + cache.write
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tokens one assistant message spent under the chosen definition. */
|
|
31
|
+
export function messageTokens(message: UsageMessage, mode: TokenCount): number {
|
|
32
|
+
if (message.type !== "assistant" || !message.tokens) return 0
|
|
33
|
+
return usageTokens(message.tokens, mode)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every token a session spent under the chosen definition. */
|
|
37
|
+
export function sessionTokens(messages: readonly UsageMessage[], mode: TokenCount): number {
|
|
38
|
+
return messages.reduce((total, message) => total + messageTokens(message, mode), 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The tokens that turn spent under the chosen definition. */
|
|
42
|
+
export function replyTurnTokens(messages: readonly UsageMessage[], mode: TokenCount): number {
|
|
43
|
+
return sessionTokens(replyTurnMessages(messages), mode)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 })
|
|
47
|
+
const exact = new Intl.NumberFormat("en-US")
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `12.4K` in compact style, matching the numbers in `/stats`, or `12,431` for
|
|
51
|
+
* every digit. Both stay short enough for the prompt footer.
|
|
52
|
+
*/
|
|
53
|
+
export function formatTokens(value: number, style: "compact" | "exact"): string {
|
|
54
|
+
const total = Math.max(0, Math.round(value))
|
|
55
|
+
return style === "exact" ? exact.format(total) : compact.format(total)
|
|
56
|
+
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
/** The token counters OpenCode stores on an assistant message. */
|
|
2
|
-
export interface MessageTokens {
|
|
3
|
-
readonly input: number
|
|
4
|
-
readonly output: number
|
|
5
|
-
readonly reasoning: number
|
|
6
|
-
readonly cache: { readonly read: number; readonly write: number }
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Only what the sum needs, so any session message list fits. */
|
|
10
|
-
export interface TokenMessage {
|
|
11
|
-
readonly id?: string
|
|
12
|
-
readonly type: string
|
|
13
|
-
readonly tokens?: MessageTokens
|
|
14
|
-
readonly cost?: number
|
|
15
|
-
readonly model?: { readonly providerID: string; readonly id: string }
|
|
16
|
-
/** How the step ended; the host only draws a footer for a finished one. */
|
|
17
|
-
readonly finish?: string | null
|
|
18
|
-
readonly error?: unknown
|
|
19
|
-
readonly retry?: unknown
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Which counters a count adds up.
|
|
24
|
-
*
|
|
25
|
-
* - `new` is what the model actually processed: prompt, completion, reasoning,
|
|
26
|
-
* and cache writes, without cache reads. A turn re-reads the whole context at
|
|
27
|
-
* every step, so cache reads would dwarf every other counter.
|
|
28
|
-
* - `generated` is only what the model wrote.
|
|
29
|
-
* - `all` matches the counters `/stats` reports, cache reads included.
|
|
30
|
-
*/
|
|
31
|
-
export type TokenCount = "new" | "generated" | "all"
|
|
32
|
-
|
|
33
|
-
/** The tokens in one assistant message's counters under the chosen definition. */
|
|
34
|
-
export function usageTokens(tokens: MessageTokens, mode: TokenCount): number {
|
|
35
|
-
const { input, output, reasoning, cache } = tokens
|
|
36
|
-
|
|
37
|
-
switch (mode) {
|
|
38
|
-
case "generated":
|
|
39
|
-
return output + reasoning
|
|
40
|
-
|
|
41
|
-
case "new":
|
|
42
|
-
return input + output + reasoning + cache.write
|
|
43
|
-
|
|
44
|
-
case "all":
|
|
45
|
-
return input + output + reasoning + cache.read + cache.write
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The tokens one assistant message spent under the chosen definition. */
|
|
50
|
-
export function messageTokens(message: TokenMessage, mode: TokenCount): number {
|
|
51
|
-
if (message.type !== "assistant" || !message.tokens) return 0
|
|
52
|
-
return usageTokens(message.tokens, mode)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Every token a session spent under the chosen definition. */
|
|
56
|
-
export function sessionTokens(messages: readonly TokenMessage[], mode: TokenCount): number {
|
|
57
|
-
return messages.reduce((total, message) => total + messageTokens(message, mode), 0)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** The index just after the last user prompt before `end`. */
|
|
61
|
-
function turnStart(messages: readonly TokenMessage[], end: number): number {
|
|
62
|
-
for (let index = end - 1; index >= 0; index--) {
|
|
63
|
-
if (messages[index]?.type === "user") return index + 1
|
|
64
|
-
}
|
|
65
|
-
return 0
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Where the turn that starts at `start` ends: at the next prompt, or the end. */
|
|
69
|
-
function turnEnd(messages: readonly TokenMessage[], start: number): number {
|
|
70
|
-
for (let index = start; index < messages.length; index++) {
|
|
71
|
-
if (messages[index]?.type === "user") return index
|
|
72
|
-
}
|
|
73
|
-
return messages.length
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The newest message the host draws a footer under. A footer belongs to the
|
|
78
|
-
* last step of a turn: one that stopped for any reason other than another tool
|
|
79
|
-
* call, or one that failed or is being retried.
|
|
80
|
-
*/
|
|
81
|
-
export function footerMessage(messages: readonly TokenMessage[]): TokenMessage | undefined {
|
|
82
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
83
|
-
const message = messages[index]
|
|
84
|
-
if (message?.type !== "assistant") continue
|
|
85
|
-
if (message.error || message.retry) return message
|
|
86
|
-
if (message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") {
|
|
87
|
-
return message
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return undefined
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The messages of the turn whose footer is on screen. A prompt with no finished
|
|
95
|
-
* reply yet leaves the previous turn's line there, so that line keeps showing
|
|
96
|
-
* the previous turn's numbers instead of the running one's.
|
|
97
|
-
*/
|
|
98
|
-
export function replyTurnMessages(messages: readonly TokenMessage[]): readonly TokenMessage[] {
|
|
99
|
-
const footer = footerMessage(messages)
|
|
100
|
-
if (!footer) return []
|
|
101
|
-
|
|
102
|
-
const last = messages.lastIndexOf(footer)
|
|
103
|
-
const start = turnStart(messages, last + 1)
|
|
104
|
-
return messages.slice(start, turnEnd(messages, start))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The tokens that turn spent under the chosen definition. */
|
|
108
|
-
export function replyTurnTokens(messages: readonly TokenMessage[], mode: TokenCount): number {
|
|
109
|
-
return sessionTokens(replyTurnMessages(messages), mode)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** A model's list price, in dollars per million tokens. */
|
|
113
|
-
export interface ModelPrice {
|
|
114
|
-
readonly input: number
|
|
115
|
-
readonly output: number
|
|
116
|
-
readonly cache: { readonly read: number; readonly write: number }
|
|
117
|
-
readonly tier?: { readonly size: number }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The price that applies at a context size: the base price, raised by a tier
|
|
122
|
-
* once the request is large enough. Long-context pricing works this way.
|
|
123
|
-
*/
|
|
124
|
-
export function priceFor(prices: readonly ModelPrice[], context: number): ModelPrice | undefined {
|
|
125
|
-
let base: ModelPrice | undefined
|
|
126
|
-
let tiered: ModelPrice | undefined
|
|
127
|
-
|
|
128
|
-
for (const price of prices) {
|
|
129
|
-
const tier = price.tier
|
|
130
|
-
if (!tier) {
|
|
131
|
-
base ??= price
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
134
|
-
if (context < tier.size) continue
|
|
135
|
-
if (!tiered?.tier || tier.size > tiered.tier.size) tiered = price
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return tiered ?? base ?? prices[0]
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The list-price cost of one message's counters. Reasoning is billed at the
|
|
143
|
-
* output price, as providers do.
|
|
144
|
-
*/
|
|
145
|
-
export function estimateCost(tokens: MessageTokens, price: ModelPrice): number {
|
|
146
|
-
const { input, output, reasoning, cache } = tokens
|
|
147
|
-
const total =
|
|
148
|
-
input * price.input +
|
|
149
|
-
(output + reasoning) * price.output +
|
|
150
|
-
cache.read * price.cache.read +
|
|
151
|
-
cache.write * price.cache.write
|
|
152
|
-
|
|
153
|
-
return total / 1_000_000
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Resolves the list price of one message, if the model is known. */
|
|
157
|
-
export type PriceLookup = (message: TokenMessage) => ModelPrice | undefined
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The cost of a list of messages: what the provider reported, or the list-price
|
|
161
|
-
* estimate when it reported nothing and a price is available.
|
|
162
|
-
*/
|
|
163
|
-
export function messagesCost(messages: readonly TokenMessage[], price?: PriceLookup): number {
|
|
164
|
-
return messages.reduce((total, message) => {
|
|
165
|
-
const reported = message.cost ?? 0
|
|
166
|
-
if (reported > 0) return total + reported
|
|
167
|
-
if (!price || !message.tokens) return total + reported
|
|
168
|
-
|
|
169
|
-
const value = price(message)
|
|
170
|
-
return total + (value ? estimateCost(message.tokens, value) : reported)
|
|
171
|
-
}, 0)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 })
|
|
175
|
-
const exact = new Intl.NumberFormat("en-US")
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* `12.4K` in compact style, matching the numbers in `/stats`, or `12,431` for
|
|
179
|
-
* every digit. Both stay short enough for the prompt footer.
|
|
180
|
-
*/
|
|
181
|
-
export function formatTokens(value: number, style: "compact" | "exact"): string {
|
|
182
|
-
const total = Math.max(0, Math.round(value))
|
|
183
|
-
return style === "exact" ? exact.format(total) : compact.format(total)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Dollars with two decimals, or more under a cent so the first significant
|
|
188
|
-
* digit shows. `auto` picks the decimals; a number fixes them.
|
|
189
|
-
*/
|
|
190
|
-
export function formatCost(value: number, digits: CostDigits = "auto"): string {
|
|
191
|
-
const cost = Math.max(0, value)
|
|
192
|
-
const places = digits === "auto" ? autoDigits(cost) : digits
|
|
193
|
-
|
|
194
|
-
return new Intl.NumberFormat("en-US", {
|
|
195
|
-
style: "currency",
|
|
196
|
-
currency: "USD",
|
|
197
|
-
minimumFractionDigits: places,
|
|
198
|
-
maximumFractionDigits: places,
|
|
199
|
-
}).format(cost)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export type CostDigits = "auto" | 2 | 3 | 4 | 5 | 6
|
|
203
|
-
|
|
204
|
-
/** Whether a stored or configured value is a cost digit choice. */
|
|
205
|
-
export function isCostDigits(value: unknown): value is CostDigits {
|
|
206
|
-
return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
|
|
210
|
-
function autoDigits(cost: number): number {
|
|
211
|
-
if (cost === 0 || cost >= 0.01) return 2
|
|
212
|
-
return Math.min(6, Math.ceil(-Math.log10(cost)))
|
|
213
|
-
}
|