@toninho09/opencode-cache-scope 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/LICENSE +21 -0
- package/README.md +204 -0
- package/index.ts +110 -0
- package/package.json +64 -0
- package/tui-sidebar.tsx +121 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 toninho09
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# opencode-cache-scope
|
|
2
|
+
|
|
3
|
+
An [opencode](https://opencode.ai) plugin that warns you when prompt caching
|
|
4
|
+
breaks mid-session, so you notice a token/cost spike before it surprises you
|
|
5
|
+
in the bill.
|
|
6
|
+
|
|
7
|
+
It ships two independent pieces:
|
|
8
|
+
|
|
9
|
+
- **Server plugin** (`index.ts`) — listens to session events and shows a TUI
|
|
10
|
+
toast the moment the model stops hitting the prompt cache.
|
|
11
|
+
- **TUI sidebar plugin** (`tui-sidebar.tsx`) — an optional always-visible
|
|
12
|
+
widget showing live cache hit rate / read / write stats for the current
|
|
13
|
+
session.
|
|
14
|
+
|
|
15
|
+
You can use either one alone or both together.
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
Providers like Anthropic cache your growing conversation context so you only
|
|
20
|
+
pay full price for the *new* tokens on each turn. If something invalidates
|
|
21
|
+
that cache (a system prompt change, a tool definition change, hitting a TTL,
|
|
22
|
+
provider-side issues, etc.), every subsequent turn re-sends the *entire*
|
|
23
|
+
context at full price — often 10-50x more expensive — without any obvious
|
|
24
|
+
signal in the UI. This plugin makes that moment visible.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
### From npm (recommended)
|
|
29
|
+
|
|
30
|
+
Add the package name to your `opencode.json` (project-level or global
|
|
31
|
+
`~/.config/opencode/opencode.json`). opencode installs it automatically via
|
|
32
|
+
Bun at startup:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"$schema": "https://opencode.ai/config.json",
|
|
37
|
+
"plugin": ["@toninho09/opencode-cache-scope"]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with options (see [Configuration](#configuration) below):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"$schema": "https://opencode.ai/config.json",
|
|
46
|
+
"plugin": [["@toninho09/opencode-cache-scope", { "toast": true, "minTokens": 500 }]]
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
(Optional) Enable the sidebar widget in your `tui.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
55
|
+
"plugin": ["@toninho09/opencode-cache-scope/tui"]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### From a local clone
|
|
60
|
+
|
|
61
|
+
1. Clone or copy this repo somewhere on disk.
|
|
62
|
+
2. Install its dependencies:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bun install
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
3. Reference it from your `opencode.json` by path instead of package name:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"$schema": "https://opencode.ai/config.json",
|
|
73
|
+
"plugin": ["/absolute/path/to/opencode-cache-scope"]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
4. (Optional) Enable the sidebar widget in your `tui.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
82
|
+
"plugin": ["/absolute/path/to/opencode-cache-scope/tui-sidebar.tsx"]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The sidebar has peer dependencies (`@opentui/core`, `@opentui/keymap`,
|
|
87
|
+
`@opentui/solid`) that must be resolvable from the plugin's `node_modules`
|
|
88
|
+
— running `bun install` in this repo covers that.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
The server plugin accepts an options object as the second element of the
|
|
93
|
+
plugin tuple in `opencode.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
"plugin": [["/absolute/path/to/opencode-cache-scope", { "toast": true, "minTokens": 0, "dropThreshold": 0.5 }]]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Option | Type | Default | Description |
|
|
100
|
+
| -------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `toast` | `boolean` | `true` | Whether to show the toast notification when a cache miss is detected. Set to `false` to silence popups while keeping the plugin loaded (e.g. if you only care about the sidebar stats). |
|
|
102
|
+
| `minTokens` | `number` | `0` | Minimum number of input tokens a miss must involve before it's worth alerting. Applies to both total misses and drop-based misses (see below). Misses below this threshold are ignored entirely (no toast, no state change), so a small miss won't suppress a later, larger miss in the same streak. |
|
|
103
|
+
| `dropThreshold`| `number` | `0.5` | Fraction (0-1) of relative drop in hit rate, compared to the previous turn, that counts as a (partial) cache miss even when `cache.read` is still greater than zero. `0.5` means a 50%-or-greater relative drop triggers an alert (e.g. hit rate falling from 90% to 40% is a ~56% relative drop). |
|
|
104
|
+
|
|
105
|
+
If you pass no options (or omit the tuple entirely and use a plain string
|
|
106
|
+
path), both defaults apply: every cache miss triggers a toast.
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
The plugin tracks per-session state from `message.updated` events on
|
|
111
|
+
assistant messages:
|
|
112
|
+
|
|
113
|
+
1. **Baseline detection** — it never alerts until the session has proven it
|
|
114
|
+
supports caching at least once (`cache.read > 0` or `cache.write > 0`).
|
|
115
|
+
This avoids false alarms on models/providers without cache support.
|
|
116
|
+
2. **Two kinds of miss are detected**, once caching is active for the session:
|
|
117
|
+
- **Total miss**: `cache.read === 0`. The cache didn't get reused at all
|
|
118
|
+
this turn.
|
|
119
|
+
- **Partial miss (percentage drop)**: the turn's hit rate
|
|
120
|
+
(`cache.read / (cache.read + cache.write + input)`) drops by `dropThreshold` or more
|
|
121
|
+
*relative to the previous turn's hit rate*, even though `cache.read` is
|
|
122
|
+
still greater than zero. This catches cases where a chunk of the cached
|
|
123
|
+
context was invalidated but not the whole thing — e.g. hit rate falling
|
|
124
|
+
from 90% to 40% between two consecutive turns.
|
|
125
|
+
Both kinds of miss go through the same downstream logic (dedup, `minTokens`
|
|
126
|
+
filter, toast).
|
|
127
|
+
3. **Debounced per streak** — only the *first* miss (of either kind) after a
|
|
128
|
+
normal turn triggers a toast. Consecutive misses don't spam you again. The
|
|
129
|
+
streak resets as soon as a turn is neither a total miss nor a relative drop
|
|
130
|
+
from the turn before it — note this compares strictly turn-to-turn, so a
|
|
131
|
+
sustained-but-stable degraded hit rate (e.g. stuck at 40% after the initial
|
|
132
|
+
drop) won't re-alert unless it drops further or hits zero.
|
|
133
|
+
4. **Threshold filter** — if `minTokens` is set, a miss (of either kind) is
|
|
134
|
+
only considered "alert-worthy" once its `input` token count reaches that
|
|
135
|
+
threshold.
|
|
136
|
+
5. **Streaming-safe** — partial/streaming message updates (no
|
|
137
|
+
`time.completed`) are ignored; only the final, completed message is
|
|
138
|
+
evaluated.
|
|
139
|
+
6. **Session cleanup** — state is cleared on `session.deleted` to avoid
|
|
140
|
+
unbounded memory growth across long-running opencode processes.
|
|
141
|
+
|
|
142
|
+
### Toast examples
|
|
143
|
+
|
|
144
|
+
Total miss:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
Cache miss
|
|
148
|
+
anthropic/claude-sonnet-4-6: cache broke (~3100 tokens with no cache hit)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Partial miss (percentage drop):
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Cache miss
|
|
155
|
+
anthropic/claude-sonnet-4-6: cache broke (hit rate dropped from 99% to 20% (~8000 tokens))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Sidebar widget
|
|
159
|
+
|
|
160
|
+
When `tui-sidebar.tsx` is loaded, it renders a small "Usage" block in the
|
|
161
|
+
session sidebar, accumulated across the session's assistant messages:
|
|
162
|
+
|
|
163
|
+
- `hit rate`
|
|
164
|
+
- cache `read` and cache `write` tokens (with their $ cost)
|
|
165
|
+
- `input` and `output` tokens (with their $ cost)
|
|
166
|
+
- a `total` cost line
|
|
167
|
+
|
|
168
|
+
Cost per token type is computed locally from each message's model pricing
|
|
169
|
+
(`$ per 1M tokens`), since providers don't return a dollar cost in their API
|
|
170
|
+
responses. This is an approximation: it doesn't reproduce opencode's tiered
|
|
171
|
+
pricing for very large contexts, so the sum of the parts may differ slightly
|
|
172
|
+
from the real cost in that edge case.
|
|
173
|
+
|
|
174
|
+
The widget only appears once the session has used caching at least once (same
|
|
175
|
+
baseline logic as the server plugin), and it's read-only/passive — it doesn't
|
|
176
|
+
have its own configuration options.
|
|
177
|
+
|
|
178
|
+

|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
bun install
|
|
184
|
+
bun test # runs index.test.ts
|
|
185
|
+
bunx tsc --noEmit # type-check
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`index.test.ts` covers: no-cache-support models never alert, first
|
|
189
|
+
cache-write doesn't alert, normal reads don't alert, a total miss after a hit
|
|
190
|
+
alerts once, consecutive misses don't repeat, recovery + a new miss alerts
|
|
191
|
+
again, user messages and streaming partials are ignored, session cleanup
|
|
192
|
+
resets state, independent sessions don't interfere, the `toast` / `minTokens`
|
|
193
|
+
/ `dropThreshold` options behave as documented above, a large relative hit
|
|
194
|
+
rate drop alerts even without `cache.read` hitting zero, a small drop below
|
|
195
|
+
the threshold doesn't alert, and a stable (non-worsening) degraded hit rate
|
|
196
|
+
doesn't re-alert on the following turn.
|
|
197
|
+
|
|
198
|
+
## Requirements
|
|
199
|
+
|
|
200
|
+
- opencode with plugin support (`@opencode-ai/plugin`).
|
|
201
|
+
- Bun (used for install/tests; the plugin itself runs under opencode's own
|
|
202
|
+
runtime).
|
|
203
|
+
- For the sidebar: `@opentui/core`, `@opentui/keymap`, `@opentui/solid`
|
|
204
|
+
(`>=0.3.4`) available in the plugin's dependency tree.
|
package/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
type SessionCacheState = {
|
|
4
|
+
// true as soon as the session has had at least one cache.read or cache.write > 0,
|
|
5
|
+
// i.e. it has proven that the model/session supports caching. Until that
|
|
6
|
+
// happens, we never alert (covers models without cache support).
|
|
7
|
+
cacheEverActive: boolean
|
|
8
|
+
// true while we've already alerted for the current cache miss streak.
|
|
9
|
+
// Resets to false as soon as the hit rate returns to normal (no total miss
|
|
10
|
+
// nor large drop), allowing us to alert again if it breaks another time.
|
|
11
|
+
missing: boolean
|
|
12
|
+
// Hit rate (cache.read / (cache.read + cache.write + input)) of the last turn processed
|
|
13
|
+
// after the cache became active. Used as a reference to detect a
|
|
14
|
+
// percentage drop in the following turn. undefined until the first turn
|
|
15
|
+
// after the baseline.
|
|
16
|
+
lastHitRate?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CacheMissAlertOptions = {
|
|
20
|
+
// Show the alert toast. Default: true.
|
|
21
|
+
toast?: boolean
|
|
22
|
+
// Only alert if the miss involves at least this many input tokens.
|
|
23
|
+
// Default: 0 (alert on any miss).
|
|
24
|
+
minTokens?: number
|
|
25
|
+
// Fraction (0-1) of the hit rate drop, compared to the previous turn,
|
|
26
|
+
// that already counts as a miss even with cache.read > 0 (partial miss).
|
|
27
|
+
// Default: 0.5 (a drop of 50% or more). E.g.: hit rate dropping from 90%
|
|
28
|
+
// to 40% is a ~56% drop -> triggers with the default.
|
|
29
|
+
dropThreshold?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const CacheMissAlertPlugin: Plugin = async ({ client }, options) => {
|
|
33
|
+
const opts = options as CacheMissAlertOptions | undefined
|
|
34
|
+
const toastEnabled = opts?.toast ?? true
|
|
35
|
+
const minTokens = opts?.minTokens ?? 0
|
|
36
|
+
const dropThreshold = opts?.dropThreshold ?? 0.5
|
|
37
|
+
|
|
38
|
+
const sessions = new Map<string, SessionCacheState>()
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
event: async ({ event }) => {
|
|
42
|
+
if (event.type === "session.deleted") {
|
|
43
|
+
sessions.delete(event.properties.info.id)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (event.type !== "message.updated") return
|
|
48
|
+
|
|
49
|
+
const info = event.properties.info
|
|
50
|
+
if (info.role !== "assistant") return
|
|
51
|
+
// Ignore partial streaming updates; only process the final message.
|
|
52
|
+
if (!info.time?.completed || !info.tokens) return
|
|
53
|
+
|
|
54
|
+
const cacheRead = info.tokens.cache?.read ?? 0
|
|
55
|
+
const cacheWrite = info.tokens.cache?.write ?? 0
|
|
56
|
+
const input = info.tokens.input ?? 0
|
|
57
|
+
|
|
58
|
+
let state = sessions.get(info.sessionID)
|
|
59
|
+
if (!state) {
|
|
60
|
+
state = { cacheEverActive: false, missing: false }
|
|
61
|
+
sessions.set(info.sessionID, state)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!state.cacheEverActive) {
|
|
65
|
+
// No baseline yet: if the cache shows up now, just record that it
|
|
66
|
+
// exists for this session. Never alert at this point.
|
|
67
|
+
if (cacheRead > 0 || cacheWrite > 0) state.cacheEverActive = true
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const denom = cacheRead + cacheWrite + input
|
|
72
|
+
const hitRate = denom > 0 ? cacheRead / denom : 0
|
|
73
|
+
|
|
74
|
+
const totalMiss = cacheRead === 0
|
|
75
|
+
const previousHitRate = state.lastHitRate
|
|
76
|
+
const dropMiss =
|
|
77
|
+
previousHitRate !== undefined &&
|
|
78
|
+
previousHitRate > 0 &&
|
|
79
|
+
(previousHitRate - hitRate) / previousHitRate >= dropThreshold
|
|
80
|
+
|
|
81
|
+
state.lastHitRate = hitRate
|
|
82
|
+
|
|
83
|
+
if (!totalMiss && !dropMiss) {
|
|
84
|
+
// Cache reading normally, no relevant drop compared to the previous turn.
|
|
85
|
+
state.missing = false
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (input < minTokens) return // miss too small to bother about
|
|
90
|
+
if (state.missing) return // already alerted for this streak
|
|
91
|
+
state.missing = true
|
|
92
|
+
|
|
93
|
+
if (!toastEnabled) return
|
|
94
|
+
|
|
95
|
+
const detail = totalMiss
|
|
96
|
+
? `~${input} tokens with no cache hit`
|
|
97
|
+
: `hit rate dropped from ${Math.round(previousHitRate! * 100)}% to ${Math.round(hitRate * 100)}% (~${input} tokens)`
|
|
98
|
+
|
|
99
|
+
await client.tui.showToast({
|
|
100
|
+
body: {
|
|
101
|
+
title: "Cache miss",
|
|
102
|
+
message: `${info.providerID}/${info.modelID}: cache broke (${detail})`,
|
|
103
|
+
variant: "warning",
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default CacheMissAlertPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toninho09/opencode-cache-scope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "opencode plugin that alerts you when prompt caching breaks mid-session",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.ts",
|
|
12
|
+
"./tui": "./tui-sidebar.tsx"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"tui-sidebar.tsx",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"opencode",
|
|
22
|
+
"opencode-plugin",
|
|
23
|
+
"plugin",
|
|
24
|
+
"cache",
|
|
25
|
+
"prompt-caching",
|
|
26
|
+
"toast",
|
|
27
|
+
"alert"
|
|
28
|
+
],
|
|
29
|
+
"author": "toninho09",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/toninho09/opencode-cache-scope.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/toninho09/opencode-cache-scope#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/toninho09/opencode-cache-scope/issues"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "latest"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opentui/core": ">=0.3.4",
|
|
44
|
+
"@opentui/keymap": ">=0.3.4",
|
|
45
|
+
"@opentui/solid": ">=0.3.4"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@opentui/core": { "optional": true },
|
|
49
|
+
"@opentui/keymap": { "optional": true },
|
|
50
|
+
"@opentui/solid": { "optional": true }
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"@types/node": "^25.2.2",
|
|
55
|
+
"@types/bun": "latest",
|
|
56
|
+
"@opentui/core": "0.3.4",
|
|
57
|
+
"@opentui/keymap": "0.3.4",
|
|
58
|
+
"@opentui/solid": "0.3.4",
|
|
59
|
+
"solid-js": "1.9.12"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"test": "bun test"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/tui-sidebar.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createMemo, Show } from "solid-js"
|
|
4
|
+
|
|
5
|
+
function fmt(n: number): string {
|
|
6
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "m"
|
|
7
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k"
|
|
8
|
+
return String(n)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fmtCost(n: number): string {
|
|
12
|
+
return "$" + n.toFixed(4)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function View(props: { api: TuiPluginApi; session_id: string }) {
|
|
16
|
+
const theme = () => props.api.theme.current
|
|
17
|
+
|
|
18
|
+
// Sums tokens (by type) and cost (by type) of all assistant messages in the
|
|
19
|
+
// session, and keeps track of whether the last message had cache.read > 0
|
|
20
|
+
// (cache ok) or 0 (miss).
|
|
21
|
+
//
|
|
22
|
+
// Per-type cost is computed locally from each message's model pricing
|
|
23
|
+
// (price per 1M tokens), since providers don't return a dollar cost in
|
|
24
|
+
// their API responses. This is an approximation: it doesn't reproduce the
|
|
25
|
+
// tiered pricing opencode applies for very large contexts
|
|
26
|
+
// (`model.cost.experimentalOver200K`), so the sum of these parts may
|
|
27
|
+
// differ slightly from the message's own `cost` field in that edge case.
|
|
28
|
+
const stats = createMemo(() => {
|
|
29
|
+
let input = 0
|
|
30
|
+
let output = 0
|
|
31
|
+
let cacheRead = 0
|
|
32
|
+
let cacheWrite = 0
|
|
33
|
+
let costInput = 0
|
|
34
|
+
let costOutput = 0
|
|
35
|
+
let costCacheRead = 0
|
|
36
|
+
let costCacheWrite = 0
|
|
37
|
+
let lastCacheRead: number | undefined
|
|
38
|
+
|
|
39
|
+
for (const m of props.api.state.session.messages(props.session_id)) {
|
|
40
|
+
if (m.role !== "assistant" || !m.tokens) continue
|
|
41
|
+
input += m.tokens.input
|
|
42
|
+
output += m.tokens.output
|
|
43
|
+
cacheRead += m.tokens.cache.read
|
|
44
|
+
cacheWrite += m.tokens.cache.write
|
|
45
|
+
lastCacheRead = m.tokens.cache.read
|
|
46
|
+
|
|
47
|
+
const cost = props.api.state.provider.find((p) => p.id === m.providerID)?.models[m.modelID]?.cost
|
|
48
|
+
if (cost) {
|
|
49
|
+
costInput += (m.tokens.input * cost.input) / 1_000_000
|
|
50
|
+
costOutput += (m.tokens.output * cost.output) / 1_000_000
|
|
51
|
+
costCacheRead += (m.tokens.cache.read * cost.cache.read) / 1_000_000
|
|
52
|
+
costCacheWrite += (m.tokens.cache.write * cost.cache.write) / 1_000_000
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const active = cacheRead > 0 || cacheWrite > 0
|
|
57
|
+
const miss = active && lastCacheRead === 0
|
|
58
|
+
const denom = cacheRead + cacheWrite + input
|
|
59
|
+
const hitRate = denom > 0 ? Math.round((cacheRead / denom) * 100) : 0
|
|
60
|
+
const totalCost = costInput + costOutput + costCacheRead + costCacheWrite
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
input,
|
|
64
|
+
output,
|
|
65
|
+
cacheRead,
|
|
66
|
+
cacheWrite,
|
|
67
|
+
active,
|
|
68
|
+
miss,
|
|
69
|
+
hitRate,
|
|
70
|
+
costInput,
|
|
71
|
+
costOutput,
|
|
72
|
+
costCacheRead,
|
|
73
|
+
costCacheWrite,
|
|
74
|
+
totalCost,
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Show when={stats().active}>
|
|
80
|
+
<box>
|
|
81
|
+
<box flexDirection="row" gap={1}>
|
|
82
|
+
<text fg={theme().text}>
|
|
83
|
+
<b>Usage</b>
|
|
84
|
+
</text>
|
|
85
|
+
</box>
|
|
86
|
+
<text fg={theme().textMuted}>hit rate: {stats().hitRate}%</text>
|
|
87
|
+
<text fg={theme().textMuted}>
|
|
88
|
+
cache read: {fmt(stats().cacheRead)} ({fmtCost(stats().costCacheRead)})
|
|
89
|
+
</text>
|
|
90
|
+
<text fg={theme().textMuted}>
|
|
91
|
+
cache write: {fmt(stats().cacheWrite)} ({fmtCost(stats().costCacheWrite)})
|
|
92
|
+
</text>
|
|
93
|
+
<text fg={theme().textMuted}>
|
|
94
|
+
input: {fmt(stats().input)} ({fmtCost(stats().costInput)})
|
|
95
|
+
</text>
|
|
96
|
+
<text fg={theme().textMuted}>
|
|
97
|
+
output: {fmt(stats().output)} ({fmtCost(stats().costOutput)})
|
|
98
|
+
</text>
|
|
99
|
+
<text fg={theme().textMuted}>total: {fmtCost(stats().totalCost)}</text>
|
|
100
|
+
</box>
|
|
101
|
+
</Show>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tui: TuiPlugin = async (api) => {
|
|
106
|
+
api.slots.register({
|
|
107
|
+
order: 450,
|
|
108
|
+
slots: {
|
|
109
|
+
sidebar_content(_ctx, props) {
|
|
110
|
+
return <View api={api} session_id={props.session_id} />
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
117
|
+
id: "opencode-cache-scope.sidebar",
|
|
118
|
+
tui,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default plugin
|