@danielbdev/opencode-go-usage 0.1.2 → 0.1.4
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 +14 -5
- package/package.json +1 -1
- package/src/core/resync.test.ts +42 -0
- package/src/core/resync.ts +21 -0
- package/src/core/usage.test.ts +49 -6
- package/src/core/usage.ts +28 -8
- package/src/index.ts +37 -8
package/README.md
CHANGED
|
@@ -18,11 +18,14 @@ A `Go usage` section at the top of the sidebar content region, above the context
|
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
Go usage
|
|
21
|
-
5h
|
|
22
|
-
wk
|
|
23
|
-
mo
|
|
21
|
+
5h 2% · resets in 50m
|
|
22
|
+
wk 0% · resets in 4d 3h
|
|
23
|
+
mo 0% · resets in 25d 20h
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
A window whose provider status is anything other than `ok` renders that status after the countdown, so
|
|
27
|
+
an exhausted or over-limit window is visible instead of showing a bare percentage.
|
|
28
|
+
|
|
26
29
|
`/usage` (or the command palette) opens the same data as a detail dialog and forces a refresh.
|
|
27
30
|
|
|
28
31
|
## How it works
|
|
@@ -31,7 +34,7 @@ Two entrypoints in one package:
|
|
|
31
34
|
|
|
32
35
|
| Entrypoint | Role |
|
|
33
36
|
| ---------- | ---- |
|
|
34
|
-
| `src/index.ts` (`@opencode/plugin`) | Server plugin. Resolves the `opencode-go` credential, reads the usage endpoint with a 5-minute cache, and publishes the snapshot over RPC. |
|
|
37
|
+
| `src/index.ts` (`@opencode/plugin`) | Server plugin. Resolves the `opencode-go` credential, reads the usage endpoint with a 5-minute cache, re-reads once a settled turn has moved usage, and publishes the snapshot over RPC. |
|
|
35
38
|
| `src/tui.tsx` (`@opencode/plugin/tui`) | TUI plugin. Renders the sidebar section and the `/usage` command. |
|
|
36
39
|
|
|
37
40
|
The credential never leaves the server process. The TUI plugin **cannot** read a credential — the
|
|
@@ -60,7 +63,9 @@ This endpoint is **live but undocumented**. Two consequences the code respects:
|
|
|
60
63
|
- Because the endpoint can change without notice, every failure degrades to an explicit state
|
|
61
64
|
(`Usage unavailable: ...`) instead of breaking the TUI.
|
|
62
65
|
|
|
63
|
-
The plugin mirrors the endpoint exactly
|
|
66
|
+
The plugin mirrors the endpoint exactly. The 5-minute cache bounds how stale a background reading can
|
|
67
|
+
be; a settled turn re-reads sooner, throttled to at most one read per minute so rapid turns cannot hammer
|
|
68
|
+
the endpoint.
|
|
64
69
|
|
|
65
70
|
## Security
|
|
66
71
|
|
|
@@ -127,6 +132,10 @@ The workflow checks that the tag matches the version in the manifest, so a misty
|
|
|
127
132
|
instead of publishing the wrong thing. It runs the tests and the typecheck before publishing, and npm
|
|
128
133
|
attaches a provenance attestation automatically because the repository and the package are public.
|
|
129
134
|
|
|
135
|
+
After a successful publish the workflow opens a **release page for the tag**, with notes generated from the
|
|
136
|
+
commits since the previous release. That is where "what changed in this version" gets answered; edit the
|
|
137
|
+
notes afterwards when the generated list is broader than the package being released.
|
|
138
|
+
|
|
130
139
|
Versions are **independent per package**: touching one plugin never forces a release of another.
|
|
131
140
|
|
|
132
141
|
The repository root stays at `0.0.0` and is never published; it only holds the workspace configuration.
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test } from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
|
|
4
|
+
import { createResyncGate } from "./resync.ts"
|
|
5
|
+
|
|
6
|
+
const MINUTE = 60_000
|
|
7
|
+
|
|
8
|
+
test("the gate opens on the first call and then holds for the whole window", () => {
|
|
9
|
+
let clock = 1_000_000
|
|
10
|
+
const allow = createResyncGate(MINUTE, () => clock)
|
|
11
|
+
|
|
12
|
+
assert.equal(allow(), true)
|
|
13
|
+
|
|
14
|
+
clock += MINUTE - 1
|
|
15
|
+
assert.equal(allow(), false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("the gate reopens once the window has elapsed", () => {
|
|
19
|
+
let clock = 1_000_000
|
|
20
|
+
const allow = createResyncGate(MINUTE, () => clock)
|
|
21
|
+
|
|
22
|
+
assert.equal(allow(), true)
|
|
23
|
+
|
|
24
|
+
clock += MINUTE
|
|
25
|
+
assert.equal(allow(), true)
|
|
26
|
+
|
|
27
|
+
// ...and closes again immediately after opening
|
|
28
|
+
assert.equal(allow(), false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("a burst of settled turns opens the gate exactly once", () => {
|
|
32
|
+
let clock = 1_000_000
|
|
33
|
+
const allow = createResyncGate(MINUTE, () => clock)
|
|
34
|
+
|
|
35
|
+
const openedAt: number[] = []
|
|
36
|
+
for (let turn = 0; turn < 10; turn++) {
|
|
37
|
+
if (allow()) openedAt.push(turn)
|
|
38
|
+
clock += 100 // turns settling back to back
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(openedAt, [0])
|
|
42
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate for the settled-turn resync.
|
|
3
|
+
*
|
|
4
|
+
* A turn that settled is when usage actually moves, so it is the signal worth
|
|
5
|
+
* re-reading on. The endpoint is undocumented and reports only three windows, so
|
|
6
|
+
* the read is gated rather than free: `allow()` returns true at most once per
|
|
7
|
+
* `minMs` and false for every turn in between.
|
|
8
|
+
*
|
|
9
|
+
* It lives here, next to the model, so the gate is testable without a host. The
|
|
10
|
+
* clock is injectable for the same reason.
|
|
11
|
+
*/
|
|
12
|
+
export function createResyncGate(minMs: number, now: () => number = Date.now): () => boolean {
|
|
13
|
+
let lastAllowed = Number.NEGATIVE_INFINITY
|
|
14
|
+
|
|
15
|
+
return () => {
|
|
16
|
+
const at = now()
|
|
17
|
+
if (at - lastAllowed < minMs) return false
|
|
18
|
+
lastAllowed = at
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/core/usage.test.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { test } from "node:test"
|
|
2
2
|
import assert from "node:assert/strict"
|
|
3
3
|
|
|
4
|
+
import pkg from "../../package.json" with { type: "json" }
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
errorSnapshot,
|
|
6
8
|
formatPercent,
|
|
7
9
|
formatReset,
|
|
8
|
-
|
|
10
|
+
formatStatus,
|
|
9
11
|
formatWindow,
|
|
12
|
+
formatWindowDetail,
|
|
10
13
|
parseSnapshot,
|
|
11
14
|
parseUsage,
|
|
15
|
+
USER_AGENT,
|
|
16
|
+
type UsageWindow,
|
|
12
17
|
} from "./usage.ts"
|
|
13
18
|
|
|
14
19
|
const NOW = Date.parse("2026-10-14T20:35:38.000Z")
|
|
@@ -85,6 +90,15 @@ test("parseUsage reports an error snapshot for a non-object payload", () => {
|
|
|
85
90
|
}
|
|
86
91
|
})
|
|
87
92
|
|
|
93
|
+
test("parseUsage carries the provider status through untouched", () => {
|
|
94
|
+
const snapshot = parseUsage(
|
|
95
|
+
{ usage: { rolling: { status: "overage", percent: 100, resetsAt: "2026-10-14T21:25:45.000Z" } } },
|
|
96
|
+
NOW,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert.equal(snapshot.windows[0]?.status, "overage")
|
|
100
|
+
})
|
|
101
|
+
|
|
88
102
|
test("formatPercent renders an integer percent or an em dash", () => {
|
|
89
103
|
assert.equal(formatPercent(2), "2%")
|
|
90
104
|
assert.equal(formatPercent(0), "0%")
|
|
@@ -109,19 +123,44 @@ test("formatReset handles the last minutes, the expired case and unknown input",
|
|
|
109
123
|
assert.equal(formatReset("not-a-date", now), "unknown")
|
|
110
124
|
})
|
|
111
125
|
|
|
112
|
-
test("formatWindow and
|
|
126
|
+
test("formatWindow and formatWindowDetail compose the rendered line", () => {
|
|
113
127
|
const snapshot = parseUsage(LIVE_PAYLOAD, NOW)
|
|
114
128
|
|
|
115
129
|
assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h 2% · resets in 50m")
|
|
116
130
|
assert.equal(formatWindow(snapshot.windows[1]!, NOW), "wk 0% · resets in 4d 3h")
|
|
117
|
-
assert.equal(
|
|
131
|
+
assert.equal(formatWindowDetail(snapshot.windows[0]!, NOW), "5h window 2% resets in 50m")
|
|
118
132
|
})
|
|
119
133
|
|
|
120
|
-
test("
|
|
134
|
+
test("formatStatus stays silent in the steady state and surfaces anything else", () => {
|
|
135
|
+
// `ok` is the steady state, so it adds nothing to the line.
|
|
136
|
+
assert.equal(formatStatus("ok"), "")
|
|
137
|
+
assert.equal(formatStatus(null), "")
|
|
138
|
+
assert.equal(formatStatus(""), " ()")
|
|
139
|
+
|
|
140
|
+
// Any other status is unknown territory on an undocumented endpoint: show it
|
|
141
|
+
// verbatim rather than guessing a friendlier wording.
|
|
142
|
+
assert.equal(formatStatus("overage"), " (overage)")
|
|
143
|
+
assert.equal(formatStatus("exceeded"), " (exceeded)")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("formatWindow and formatWindowDetail surface a non-ok provider status", () => {
|
|
147
|
+
const window: UsageWindow = {
|
|
148
|
+
key: "rolling",
|
|
149
|
+
short: "5h",
|
|
150
|
+
long: "5h window",
|
|
151
|
+
percent: 100,
|
|
152
|
+
resetsAt: "2026-10-14T21:25:45.000Z",
|
|
153
|
+
status: "overage",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
assert.equal(formatWindow(window, NOW), "5h 100% · resets in 50m (overage)")
|
|
157
|
+
assert.equal(formatWindowDetail(window, NOW), "5h window 100% resets in 50m (overage)")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("an error snapshot renders as an unknown window, not fabricated percentages", () => {
|
|
121
161
|
const snapshot = errorSnapshot("Go integration is not connected", NOW)
|
|
122
162
|
|
|
123
163
|
assert.equal(snapshot.ok, false)
|
|
124
|
-
assert.equal(formatSummary(snapshot, NOW), "GO usage unavailable")
|
|
125
164
|
assert.equal(formatWindow(snapshot.windows[0]!, NOW), "5h — · unknown")
|
|
126
165
|
})
|
|
127
166
|
|
|
@@ -138,7 +177,6 @@ test("parseSnapshot accepts a snapshot that crossed the RPC boundary", () => {
|
|
|
138
177
|
)
|
|
139
178
|
assert.equal(received!.windows[0]!.short, "5h")
|
|
140
179
|
assert.equal(received!.windows[0]!.long, "5h window")
|
|
141
|
-
assert.equal(formatSummary(received!, NOW), "GO 5h 2% · wk 0% · mo 0%")
|
|
142
180
|
})
|
|
143
181
|
|
|
144
182
|
test("parseSnapshot rejects anything that is not a snapshot object", () => {
|
|
@@ -165,3 +203,8 @@ test("parseSnapshot degrades a malformed window entry instead of throwing", () =
|
|
|
165
203
|
assert.equal(received!.windows[2]!.key, "monthly")
|
|
166
204
|
})
|
|
167
205
|
|
|
206
|
+
test("USER_AGENT tracks the published package version", () => {
|
|
207
|
+
// Guards the drift a hardcoded literal invites: bump the manifest without
|
|
208
|
+
// touching the constant and this fails before the package is published.
|
|
209
|
+
assert.equal(USER_AGENT, `opencode-go-usage/${pkg.version}`)
|
|
210
|
+
})
|
package/src/core/usage.ts
CHANGED
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* plain `node --test`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* User agent sent to the usage endpoint.
|
|
22
|
+
*
|
|
23
|
+
* The version travels with the request so a provider-side failure is
|
|
24
|
+
* attributable to a build. It stays a literal deliberately: `usage.test.ts`
|
|
25
|
+
* pins it to `package.json`, so a release bump fails the suite before publish
|
|
26
|
+
* (bump this line alongside the manifest) instead of adding a JSON import to
|
|
27
|
+
* the server plugin's load path.
|
|
28
|
+
*/
|
|
29
|
+
export const USER_AGENT = "opencode-go-usage/0.1.4"
|
|
30
|
+
|
|
20
31
|
export const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const
|
|
21
32
|
|
|
22
33
|
export type WindowKey = (typeof WINDOW_KEYS)[number]
|
|
@@ -173,9 +184,24 @@ export function formatReset(resetsAt: string | null, now: number): string {
|
|
|
173
184
|
return `resets in ${minutes}m`
|
|
174
185
|
}
|
|
175
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Provider status that qualifies the percentage, e.g. ` (overage)`.
|
|
189
|
+
*
|
|
190
|
+
* `ok` is the steady state and renders as nothing, so the common line is
|
|
191
|
+
* unchanged. Any other non-null status is surfaced verbatim: the endpoint is
|
|
192
|
+
* undocumented, so a status is the only signal that a window needs attention and
|
|
193
|
+
* it is not ours to reinterpret.
|
|
194
|
+
*/
|
|
195
|
+
export function formatStatus(status: string | null): string {
|
|
196
|
+
return status === null || status === "ok" ? "" : ` (${status})`
|
|
197
|
+
}
|
|
198
|
+
|
|
176
199
|
/** One window as a single line, e.g. `5h 2% · resets in 50m`. */
|
|
177
200
|
export function formatWindow(window: UsageWindow, now: number): string {
|
|
178
|
-
return `${window.short} ${formatPercent(window.percent)} · ${formatReset(
|
|
201
|
+
return `${window.short} ${formatPercent(window.percent)} · ${formatReset(
|
|
202
|
+
window.resetsAt,
|
|
203
|
+
now,
|
|
204
|
+
)}${formatStatus(window.status)}`
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
/** One window as a detail row, e.g. `5h window 2% resets in 50m`. */
|
|
@@ -183,11 +209,5 @@ export function formatWindowDetail(window: UsageWindow, now: number): string {
|
|
|
183
209
|
return `${window.long.padEnd(10)} ${formatPercent(window.percent).padStart(4)} ${formatReset(
|
|
184
210
|
window.resetsAt,
|
|
185
211
|
now,
|
|
186
|
-
)}`
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Compact status line for the TUI slot, e.g. `GO 5h 2% · wk 0% · mo 0%`. */
|
|
190
|
-
export function formatSummary(snapshot: UsageSnapshot, _now: number): string {
|
|
191
|
-
if (!snapshot.ok) return "GO usage unavailable"
|
|
192
|
-
return `GO ${snapshot.windows.map((w) => `${w.short} ${formatPercent(w.percent)}`).join(" · ")}`
|
|
212
|
+
)}${formatStatus(window.status)}`
|
|
193
213
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createResyncGate } from "./core/resync.ts"
|
|
4
|
+
import { errorSnapshot, parseUsage, USER_AGENT, type UsageSnapshot } from "./core/usage.ts"
|
|
4
5
|
import { UsageRpc } from "./rpc.ts"
|
|
5
6
|
|
|
6
7
|
const INTEGRATION_ID = "opencode-go"
|
|
7
8
|
const USAGE_URL = "https://opencode.ai/inference/go/v1/usage"
|
|
8
|
-
const USER_AGENT = "opencode-go-usage/0.1.0"
|
|
9
9
|
|
|
10
10
|
/** Usage moves slowly; the endpoint is undocumented, so we stay conservative. */
|
|
11
11
|
const REFRESH_MS = 5 * 60_000
|
|
12
12
|
const REQUEST_TIMEOUT_MS = 10_000
|
|
13
13
|
|
|
14
|
+
/** A settled turn moves usage; re-read on that signal, at most once per minute. */
|
|
15
|
+
const RESYNC_MIN_MS = 60_000
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* Server plugin.
|
|
16
19
|
*
|
|
@@ -75,12 +78,20 @@ export default Plugin.define({
|
|
|
75
78
|
inFlight = null
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Publishing must not fail the refresh: the snapshot is already read, and
|
|
82
|
+
// `refresh()` is awaited by setup and fired-and-forgotten by the interval,
|
|
83
|
+
// where a rejection would surface as an unhandled rejection.
|
|
84
|
+
try {
|
|
85
|
+
await registration.events.emit("updated", {
|
|
86
|
+
fetchedAt: snapshot.fetchedAt,
|
|
87
|
+
ok: snapshot.ok,
|
|
88
|
+
error: snapshot.error,
|
|
89
|
+
windows: snapshot.windows.map((window) => ({ ...window })),
|
|
90
|
+
})
|
|
91
|
+
} catch {
|
|
92
|
+
// Subscribers are optional; the caller still gets the fresh snapshot.
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
return snapshot
|
|
85
96
|
}
|
|
86
97
|
|
|
@@ -97,7 +108,25 @@ export default Plugin.define({
|
|
|
97
108
|
void refresh()
|
|
98
109
|
}, REFRESH_MS)
|
|
99
110
|
|
|
111
|
+
// A settled turn is when usage actually moves, and the cache lives in this
|
|
112
|
+
// half, so the trigger belongs here: the refreshed snapshot goes out over
|
|
113
|
+
// `updated` and the TUI keeps working unchanged. The gate keeps quick
|
|
114
|
+
// successive turns from hammering an undocumented endpoint.
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
const allowed = createResyncGate(RESYNC_MIN_MS)
|
|
117
|
+
|
|
118
|
+
void (async () => {
|
|
119
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
120
|
+
if (event.type !== "session.execution.succeeded") continue
|
|
121
|
+
if (!allowed()) continue
|
|
122
|
+
void refresh()
|
|
123
|
+
}
|
|
124
|
+
})().catch(() => {
|
|
125
|
+
// A closed subscription is not a reason to fail the plugin.
|
|
126
|
+
})
|
|
127
|
+
|
|
100
128
|
return () => {
|
|
129
|
+
controller.abort()
|
|
101
130
|
clearInterval(timer)
|
|
102
131
|
}
|
|
103
132
|
},
|