@danielbdev/opencode-go-usage 0.1.3 → 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 +4 -2
- package/package.json +1 -1
- package/src/core/resync.test.ts +42 -0
- package/src/core/resync.ts +21 -0
- package/src/core/usage.ts +1 -1
- package/src/index.ts +22 -0
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Two entrypoints in one package:
|
|
|
34
34
|
|
|
35
35
|
| Entrypoint | Role |
|
|
36
36
|
| ---------- | ---- |
|
|
37
|
-
| `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. |
|
|
38
38
|
| `src/tui.tsx` (`@opencode/plugin/tui`) | TUI plugin. Renders the sidebar section and the `/usage` command. |
|
|
39
39
|
|
|
40
40
|
The credential never leaves the server process. The TUI plugin **cannot** read a credential — the
|
|
@@ -63,7 +63,9 @@ This endpoint is **live but undocumented**. Two consequences the code respects:
|
|
|
63
63
|
- Because the endpoint can change without notice, every failure degrades to an explicit state
|
|
64
64
|
(`Usage unavailable: ...`) instead of breaking the TUI.
|
|
65
65
|
|
|
66
|
-
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.
|
|
67
69
|
|
|
68
70
|
## Security
|
|
69
71
|
|
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.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* (bump this line alongside the manifest) instead of adding a JSON import to
|
|
27
27
|
* the server plugin's load path.
|
|
28
28
|
*/
|
|
29
|
-
export const USER_AGENT = "opencode-go-usage/0.1.
|
|
29
|
+
export const USER_AGENT = "opencode-go-usage/0.1.4"
|
|
30
30
|
|
|
31
31
|
export const WINDOW_KEYS = ["rolling", "weekly", "monthly"] as const
|
|
32
32
|
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
2
|
|
|
3
|
+
import { createResyncGate } from "./core/resync.ts"
|
|
3
4
|
import { errorSnapshot, parseUsage, USER_AGENT, type UsageSnapshot } from "./core/usage.ts"
|
|
4
5
|
import { UsageRpc } from "./rpc.ts"
|
|
5
6
|
|
|
@@ -10,6 +11,9 @@ const USAGE_URL = "https://opencode.ai/inference/go/v1/usage"
|
|
|
10
11
|
const REFRESH_MS = 5 * 60_000
|
|
11
12
|
const REQUEST_TIMEOUT_MS = 10_000
|
|
12
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
|
+
|
|
13
17
|
/**
|
|
14
18
|
* Server plugin.
|
|
15
19
|
*
|
|
@@ -104,7 +108,25 @@ export default Plugin.define({
|
|
|
104
108
|
void refresh()
|
|
105
109
|
}, REFRESH_MS)
|
|
106
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
|
+
|
|
107
128
|
return () => {
|
|
129
|
+
controller.abort()
|
|
108
130
|
clearInterval(timer)
|
|
109
131
|
}
|
|
110
132
|
},
|