@rizal72/oc-budget 0.0.0-stage → 0.0.1

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/index.ts ADDED
@@ -0,0 +1,366 @@
1
+ // oc-budget — server half.
2
+ //
3
+ // Owns the one thing the TUI cannot do: resolving a provider's credential out of
4
+ // OpenCode's own integration store (`ctx.integration.connection.resolve`), never
5
+ // from a file of ours. With a key (or the Console account token) in hand it reads
6
+ // the four wallets and caches the result, so the TUI's slots and its `/budget`
7
+ // command are pure rendering.
8
+ //
9
+ // opencode-go -> GET console/api/go/status (5h / weekly / monthly meters)
10
+ // GET opencode.ai/zen/go/v1/usage (fallback: a machine with no console session)
11
+ // deepseek -> GET api.deepseek.com/user/balance (native wallet)
12
+ // openrouter -> GET openrouter.ai/api/v1/credits (+ /key for the month)
13
+ // opencode -> GET console/api/orgs -> /console/api/billing/status (Zen balance)
14
+ //
15
+ // The Zen balance is the reason the Console integration exists: no API key opens
16
+ // the console (it answers 401 to an `oc_sk_` key), only the account session does.
17
+ // That is exactly what `opencode auth login opencode` stores, so there is no token
18
+ // file here and no device flow to reimplement.
19
+ //
20
+ // Nothing rejects outward, and the two are not the same thing: a wallet with **no connection** is
21
+ // left out of the snapshot, so both halves simply do not draw it — Zen included; a connected wallet
22
+ // whose read failed keeps its branch and its `error`, because a fault must be visible where the number
23
+ // would be.
24
+ import { Budget } from "./rpc.ts"
25
+ import type { BudgetSnapshot, UsageWindow } from "./rpc.ts"
26
+
27
+ /** How long a collected snapshot is reused; the TUI also polls on this cadence. */
28
+ const TTL_MS = 5 * 60 * 1000
29
+ const TIMEOUT_MS = 10_000
30
+ const USER_AGENT = "oc-budget/0.1"
31
+
32
+ /** A test switch, inert unless the variable is set: `scripts/oc-dev-env.sh on OC_BUDGET_DEV=empty`.
33
+ * `empty` fakes the one state a real machine cannot produce on demand — an account with nothing
34
+ * connected. It answers **before** the cache and is never cached, so a real reading cannot be served
35
+ * while the switch is on, and the fake cannot outlive the variable it came from. */
36
+ const DEV = process.env.OC_BUDGET_DEV ?? ""
37
+
38
+ const GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"
39
+ const GO_STATUS_URL = "https://opencode.ai/console/api/go/status"
40
+ const DS_BALANCE_URL = "https://api.deepseek.com/user/balance"
41
+ const OR_CREDITS_URL = "https://openrouter.ai/api/v1/credits"
42
+ const OR_KEY_URL = "https://openrouter.ai/api/v1/key"
43
+ const ZEN_CURRENT_URL = "https://opencode.ai/console/api/orgs/current"
44
+ const ZEN_STATUS_URL = "https://opencode.ai/console/api/billing/status"
45
+
46
+ function message(error: unknown): string {
47
+ return error instanceof Error ? error.message : String(error)
48
+ }
49
+
50
+ interface ResolvedKey {
51
+ key?: string
52
+ error?: string
53
+ /** No connection exists at all: the caller omits the wallet instead of reporting a failure. */
54
+ missing?: boolean
55
+ }
56
+
57
+ /** The API key of an integration, out of OpenCode's own credential store. */
58
+ async function resolveKey(ctx: any, integrationID: string): Promise<ResolvedKey> {
59
+ try {
60
+ const connection = await ctx.integration.connection.active(integrationID)
61
+ if (!connection) {
62
+ return { missing: true, error: `${integrationID} is not connected (see: opencode auth list)` }
63
+ }
64
+ const value = await ctx.integration.connection.resolve(connection)
65
+ if (!value) return { error: `${integrationID} credential could not be resolved` }
66
+ if (value.type === "key") return { key: value.key }
67
+ return { error: `${integrationID} uses an unexpected credential type (${value.type})` }
68
+ } catch (error) {
69
+ return { error: message(error) }
70
+ }
71
+ }
72
+
73
+ /** The Console account token, which is what opens the Zen balance. */
74
+ async function resolveConsoleToken(ctx: any): Promise<ResolvedKey> {
75
+ try {
76
+ const connection = await ctx.integration.connection.active("opencode")
77
+ if (!connection) {
78
+ return { missing: true, error: "OpenCode Console not connected (run: opencode auth login opencode)" }
79
+ }
80
+ const value = await ctx.integration.connection.resolve(connection)
81
+ if (!value) return { error: "OpenCode Console credential could not be resolved" }
82
+ if (value.type === "oauth") return { key: value.access }
83
+ if (value.type === "key") return { key: value.key }
84
+ return { error: `OpenCode Console uses an unexpected credential type (${value.type})` }
85
+ } catch (error) {
86
+ return { error: message(error) }
87
+ }
88
+ }
89
+
90
+ async function getJson(url: string, headers: Record<string, string>): Promise<any> {
91
+ const response = await fetch(url, {
92
+ headers: { ...headers, "User-Agent": USER_AGENT },
93
+ signal: AbortSignal.timeout(TIMEOUT_MS),
94
+ })
95
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
96
+ return (await response.json()) as any
97
+ }
98
+
99
+ /** Micro-cents (1e-8 USD) to a dollar amount, rounded on the integer: the console's
100
+ * own scale, fixed by its invoices. `(micro / 1e8).toFixed(2)` is a cent low on
101
+ * values like 100500000, so cents are rounded before the division. */
102
+ function microCentsToDollars(micro: number): number {
103
+ return Math.round(micro / 1e6) / 100
104
+ }
105
+
106
+ /** How many console connections exist: with one, naming the workspace is noise. */
107
+ async function consolePeers(ctx: any): Promise<number | undefined> {
108
+ try {
109
+ const listed: any = await ctx.integration.list()
110
+ const list: any[] = Array.isArray(listed) ? listed : listed?.data ?? []
111
+ const connections: any[] = list.find((item: any) => item?.id === "opencode")?.connections ?? []
112
+ return connections.length || undefined
113
+ } catch {
114
+ // A name is a convenience: never let it cost the balance.
115
+ return undefined
116
+ }
117
+ }
118
+
119
+ /** One Go window, from the console's own meter. The rule is the console's, copied from its bundle
120
+ * (`(used*200 + limit) / (limit*2)`): round the percent, and cap it at 99 until the window is truly
121
+ * spent, because the console says `>99% used` there and the wallet must not fall back to ZEN before
122
+ * the limit is reached. The public endpoint rounds **down** instead: 0.98% reads 0 there and 1 here. */
123
+ function goMeter(meter: any): UsageWindow | undefined {
124
+ const used = Number(meter?.usedMicroCents)
125
+ const limit = Number(meter?.limitMicroCents)
126
+ if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return undefined
127
+ const spent = used >= limit
128
+ return {
129
+ status: spent ? "rate-limited" : "ok",
130
+ percent: spent ? 100 : Math.min(99, Math.round((used * 100) / limit)),
131
+ resetsAt: typeof meter?.resetsAt === "string" ? meter.resetsAt : undefined,
132
+ }
133
+ }
134
+
135
+ /** The fallback endpoint's window, clamped to the three fields the contract allows: an extra field
136
+ * fails the strict schema and takes the whole snapshot down with it, and its shape is documented
137
+ * nowhere. `status` is derived from the percent when the endpoint does not name it, because an
138
+ * exhausted window must still flip the paying wallet. */
139
+ function goWindow(raw: any): UsageWindow | undefined {
140
+ const percent = typeof raw?.percent === "number" && Number.isFinite(raw.percent) ? raw.percent : undefined
141
+ if (percent === undefined) return undefined
142
+ const reported = typeof raw?.status === "string" ? raw.status : undefined
143
+ return {
144
+ status: percent >= 100 || reported === "rate-limited" ? "rate-limited" : (reported ?? "ok"),
145
+ percent,
146
+ resetsAt: typeof raw?.resetsAt === "string" ? raw.resetsAt : undefined,
147
+ }
148
+ }
149
+
150
+ async function collectGo(ctx: any): Promise<BudgetSnapshot["go"]> {
151
+ // Two ways in, one answer. A console session is preferred: it reports the spend in micro-cents and
152
+ // it is the credential the provider actually bills, so the counters and the payer cannot disagree.
153
+ // A Go API key alone still works, through the public endpoint. Both are enough on their own, and
154
+ // neither is required if the other is there.
155
+ const console = await resolveConsoleToken(ctx)
156
+ let consoleError: string | undefined
157
+ if (console.key) {
158
+ try {
159
+ const body = await getJson(GO_STATUS_URL, { Authorization: `Bearer ${console.key}` })
160
+ const meters = body?.access?.meters
161
+ if (!meters) throw new Error("unexpected go status shape")
162
+ const reading = {
163
+ rolling: goMeter(meters.fiveHour),
164
+ weekly: goMeter(meters.week),
165
+ monthly: goMeter(meters.month),
166
+ }
167
+ if (!reading.rolling && !reading.weekly && !reading.monthly) {
168
+ throw new Error("unexpected go status shape")
169
+ }
170
+ return reading
171
+ } catch (error) {
172
+ // A session that cannot read the meters — a workspace without the subscription — must not
173
+ // hide a key that can. Fall through, and report both failures if it also fails.
174
+ consoleError = message(error)
175
+ }
176
+ }
177
+ // A Console connection that exists but cannot produce a token is a fault, not an absence, and it is
178
+ // carried so that a machine with no Go key does not lose the reason.
179
+ if (!console.missing && console.error) consoleError ??= console.error
180
+ const credential = await resolveKey(ctx, "opencode-go")
181
+ // No Go key at all: the Console session is the only witness, and a workspace without the
182
+ // subscription answers exactly like a broken read. So GO is left out of the snapshot rather than
183
+ // reported — a free account must not carry a permanent error line. With a key, a failed read is
184
+ // a fault and keeps its branch.
185
+ if (credential.missing) {
186
+ // No Go key: the Console was the only witness. Its own fault is reported; its absence — or a
187
+ // read that failed without a key — means there is no plan to show.
188
+ return consoleError && !console.missing ? { error: consoleError } : undefined
189
+ }
190
+ if (credential.error) return { error: consoleError ? `${consoleError}; ${credential.error}` : credential.error }
191
+ try {
192
+ const body = await getJson(GO_USAGE_URL, { Authorization: `Bearer ${credential.key}` })
193
+ const usage = body?.usage
194
+ if (!usage) return { error: "unexpected usage response shape" }
195
+ const reading = {
196
+ rolling: goWindow(usage.rolling),
197
+ weekly: goWindow(usage.weekly),
198
+ monthly: goWindow(usage.monthly),
199
+ }
200
+ if (!reading.rolling && !reading.weekly && !reading.monthly) {
201
+ return { error: "unexpected usage response shape" }
202
+ }
203
+ return reading
204
+ } catch (error) {
205
+ return { error: consoleError ? `${consoleError}; ${message(error)}` : message(error) }
206
+ }
207
+ }
208
+
209
+ async function collectDeepSeek(ctx: any): Promise<BudgetSnapshot["ds"]> {
210
+ const credential = await resolveKey(ctx, "deepseek")
211
+ if (credential.missing) return undefined
212
+ if (credential.error) return { error: credential.error }
213
+ try {
214
+ const body = await getJson(DS_BALANCE_URL, { Authorization: `Bearer ${credential.key}` })
215
+ const info = body?.balance_infos?.[0]
216
+ const balance = info?.total_balance === undefined ? Number.NaN : Number(info.total_balance)
217
+ if (!Number.isFinite(balance)) return { error: "unexpected balance response shape" }
218
+ return { balance, currency: info?.currency ?? "USD" }
219
+ } catch (error) {
220
+ return { error: message(error) }
221
+ }
222
+ }
223
+
224
+ async function collectOpenRouter(ctx: any): Promise<BudgetSnapshot["or"]> {
225
+ const credential = await resolveKey(ctx, "openrouter")
226
+ if (credential.missing) return undefined
227
+ if (credential.error) return { error: credential.error }
228
+ const headers = { Authorization: `Bearer ${credential.key}` }
229
+ try {
230
+ const body = await getJson(OR_CREDITS_URL, headers)
231
+ const totalCredits = body?.data?.total_credits
232
+ const totalUsage = body?.data?.total_usage
233
+ if (typeof totalCredits !== "number" || typeof totalUsage !== "number") {
234
+ return { error: "unexpected credits response shape" }
235
+ }
236
+ const result: NonNullable<BudgetSnapshot["or"]> = {
237
+ totalCredits,
238
+ totalUsage,
239
+ balance: totalCredits - totalUsage,
240
+ }
241
+ // Best effort: /key enriches the report (month spend) and
242
+ // must never blank a balance already read.
243
+ try {
244
+ const keyBody = await getJson(OR_KEY_URL, headers)
245
+ const monthly = keyBody?.data?.usage_monthly
246
+ if (typeof monthly === "number") result.usageMonthly = monthly
247
+ } catch {
248
+ // keep the balance
249
+ }
250
+ return result
251
+ } catch (error) {
252
+ return { error: message(error) }
253
+ }
254
+ }
255
+
256
+ async function collectZen(ctx: any): Promise<BudgetSnapshot["zen"]> {
257
+ const token = await resolveConsoleToken(ctx)
258
+ // No Console connection at all is an absence, exactly like the other three: the account has no Zen
259
+ // wallet, and a line explaining a connection never made is the plugin teaching its own setup in
260
+ // someone else's sidebar. A Console that exists and cannot read keeps its error, because that is a
261
+ // fault and must be visible where the number would be.
262
+ if (token.missing) return undefined
263
+ if (token.error) return { error: token.error }
264
+ const headers = { Authorization: `Bearer ${token.key}` }
265
+ try {
266
+ // The Console session is bound to exactly one workspace — the one selected when the
267
+ // account was connected — and billing answers 403 for every other one (verified: a
268
+ // `go` login reads `go` and is refused `go-2` and `Personal`). So the workspace is
269
+ // read from the session, never guessed from a name: switching the Console account is
270
+ // the only way to switch which wallet the balance describes.
271
+ const current = await getJson(ZEN_CURRENT_URL, headers)
272
+ const org = current?.org
273
+ if (!org?.id) return { error: "Console session reports no current workspace" }
274
+ const body = await getJson(ZEN_STATUS_URL, { ...headers, "x-org-id": org.id })
275
+ const microOf = (value: unknown) => {
276
+ const n = typeof value === "string" ? Number(value) : value
277
+ return typeof n === "number" && Number.isFinite(n) ? n : undefined
278
+ }
279
+ // Two numbers, and the difference is the point. `balanceMicroCents` is the ledger: prepaid,
280
+ // so it goes a few cents under when the request that crosses the end costs more than what is
281
+ // left (measured 2026-10-26: -5076149, i.e. -$0.05). `availableMicroCents` is what the console
282
+ // prints as *available credit*: the balance clamped at 0, and raised by a credit limit when the
283
+ // account has one. Clamping the ledger in our code would lose the debt; reading this field
284
+ // keeps both, and stays right the day a limit is set.
285
+ const balance = microOf(body?.balanceMicroCents)
286
+ if (balance === undefined) return { error: "unexpected billing status shape", org: org.name }
287
+ const available = microOf(body?.availableMicroCents) ?? Math.max(0, balance)
288
+ return {
289
+ balance: microCentsToDollars(balance),
290
+ available: microCentsToDollars(available),
291
+ ...(typeof org.name === "string" ? { org: org.name } : {}),
292
+ ...(await consolePeers(ctx).then((peers) => (peers === undefined ? {} : { peers }))),
293
+ }
294
+ } catch (error) {
295
+ return { error: message(error) }
296
+ }
297
+ }
298
+
299
+ /** A snapshot that says why the read failed, in every wallet. A rejected handler answered HTTP 500 and
300
+ * left the panel blank with nothing to read; this one is visible where the numbers would be. */
301
+ function failedSnapshot(error: unknown): BudgetSnapshot {
302
+ // A last resort for a rejection no collector explains: it names the fault on every wallet, so an
303
+ // account with one connection does not lose the only line it had. Nothing reaches it today — each
304
+ // collector swallows its own failure — and a new one must keep that true.
305
+ const text = message(error)
306
+ return {
307
+ at: Date.now(),
308
+ go: { error: text },
309
+ ds: { error: text },
310
+ or: { error: text },
311
+ zen: { error: text },
312
+ }
313
+ }
314
+
315
+ /** The shape that crosses the wire, which is JSON: `goMeter` and `goWindow` leave fields `undefined` when
316
+ * the endpoint omits them, and the output schema is strict — a present `undefined` is not a `string` and
317
+ * not a missing key, so the whole answer is rejected **after** the handler ran (a 500 with `http.span=1`,
318
+ * measured 2026-10-28: every poll failed from 07:00 to 12:26 and the pill vanished). `JSON.stringify`
319
+ * drops them, which is exactly what the transport would do, only before validation instead of after. */
320
+ const wire = (snapshot: BudgetSnapshot): BudgetSnapshot => JSON.parse(JSON.stringify(snapshot))
321
+
322
+ export default {
323
+ id: "oc-budget",
324
+ async setup(ctx: any) {
325
+ let cache: BudgetSnapshot | null = null
326
+ let cacheAt = 0
327
+ /** One collection at a time: several TUIs, or a model switch racing the poll,
328
+ * join the same promise instead of issuing four redundant request sets. */
329
+ let inFlight: Promise<BudgetSnapshot> | null = null
330
+
331
+ function collect(): Promise<BudgetSnapshot> {
332
+ return (async () => {
333
+ const [go, ds, or, zen] = await Promise.all([
334
+ collectGo(ctx),
335
+ collectDeepSeek(ctx),
336
+ collectOpenRouter(ctx),
337
+ collectZen(ctx),
338
+ ])
339
+ return { at: Date.now(), go, ds, or, zen }
340
+ })()
341
+ }
342
+
343
+ await ctx.rpc.register(Budget, {
344
+ snapshot: async (input: any) => {
345
+ if (DEV === "empty") return { at: Date.now() }
346
+ const force = input?.force === true
347
+ if (!force && cache && Date.now() - cacheAt < TTL_MS) return cache
348
+ // A forced read must not join a collection that started before it: a credential switch
349
+ // would receive the old account's numbers and cache them for five minutes.
350
+ if (force) await inFlight?.catch(() => {})
351
+ if (inFlight) return inFlight
352
+ inFlight = collect()
353
+ .catch(failedSnapshot)
354
+ .then((snapshot) => {
355
+ cache = wire(snapshot)
356
+ cacheAt = Date.now()
357
+ return cache
358
+ })
359
+ .finally(() => {
360
+ inFlight = null
361
+ })
362
+ return inFlight
363
+ },
364
+ })
365
+ },
366
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
- "name": "@rizal72/oc-budget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@rizal72/oc-budget",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "How much credit is left on the account behind the model you are using: the Go subscription's windows, the Zen balance, and the pay-per-use wallets, in the sidebar, the footer and the header",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rizal72/opencode-plugins.git"
9
+ },
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "opencode",
13
+ "opencode-plugin",
14
+ "budget",
15
+ "credits",
16
+ "usage"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "exports": {
22
+ ".": "./index.ts",
23
+ "./tui": "./tui.tsx",
24
+ "./rpc": "./rpc.ts",
25
+ "./gui-experimental": "./dist/gui-experimental.cjs"
26
+ },
27
+ "files": [
28
+ "*.ts",
29
+ "*.tsx",
30
+ "README.md",
31
+ "dist",
32
+ "!*.test.ts"
33
+ ]
34
+ }
package/peak.ts ADDED
@@ -0,0 +1,104 @@
1
+ // oc-budget/peak.ts — DeepSeek's two tariffs, one definition for both surfaces.
2
+ //
3
+ // The terminal draws the glyph next to a bar, the desktop puts it on a pill and on the rows of its
4
+ // menu. What "peak" means is neither of their business: the windows are DeepSeek's, published as
5
+ // "01:00-04:00 and 06:00-10:00 UTC, Monday through Friday, excluding Chinese public holidays"
6
+ // (read 2026-10-27).
7
+ //
8
+ // Runnable standalone for its self-check: node plugins/oc-budget/peak.ts --self-check
9
+
10
+ /** Peak hours, in UTC. The arithmetic is DST-proof on purpose. A Chinese public holiday is not encoded,
11
+ * so it reads as peak — over-warning, which is the safe direction. */
12
+ const WINDOWS: ReadonlyArray<readonly [number, number]> = [
13
+ [1, 4],
14
+ [6, 10],
15
+ ]
16
+ const EDGES = [1, 4, 6, 10]
17
+
18
+ const at = (now: number) => new Date(now)
19
+
20
+ /** A dev switch shared by both surfaces: set it to an instant inside a peak window and the peak marks
21
+ * appear at any hour, so the look can be judged — or filmed — without waiting for 01:00 UTC. A Tuesday at
22
+ * 02:00 UTC: `Date.UTC(2026, 9, 27, 2, 0)`. Ships `undefined`, and this plugin is local, so nothing else
23
+ * guards it. Both halves read it from here: one value, or the badge and the pill would disagree. */
24
+ export const SIMULATE: number | undefined = undefined
25
+ /** The clock the peak is judged by: the simulated instant when one is set, the real time otherwise. */
26
+ export const clock = (): number => SIMULATE ?? Date.now()
27
+
28
+ export function isPeak(now = Date.now()): boolean {
29
+ const date = at(now)
30
+ const day = date.getUTCDay()
31
+ if (day === 0 || day === 6) return false
32
+ const hour = date.getUTCHours() + date.getUTCMinutes() / 60
33
+ return WINDOWS.some(([from, to]) => hour >= from && hour < to)
34
+ }
35
+
36
+ /** The next instant the regime flips, and what it flips to. Candidates are the window edges over the
37
+ * next ten days; the first that changes the state wins, which skips the weekend edges that open and
38
+ * close nothing. */
39
+ export function nextChange(now = Date.now()): { peak: boolean; at: number } {
40
+ const peak = isPeak(now)
41
+ const midnight = at(now)
42
+ midnight.setUTCHours(0, 0, 0, 0)
43
+ const edges: number[] = []
44
+ for (let offset = 0; offset <= 10; offset++) {
45
+ for (const hour of EDGES) {
46
+ const time = midnight.getTime() + offset * 86_400_000 + hour * 3_600_000
47
+ if (time > now) edges.push(time)
48
+ }
49
+ }
50
+ edges.sort((a, b) => a - b)
51
+ for (const time of edges) if (isPeak(time) !== peak) return { peak: !peak, at: time }
52
+ return { peak: !peak, at: edges[edges.length - 1] }
53
+ }
54
+
55
+ /**
56
+ * True when the price of *this model* doubles inside a peak window: native DeepSeek, and the DeepSeek
57
+ * ids on Go while the subscription pays. Zen never does — its DeepSeek price is flat at the peak
58
+ * figure, a constant and not a doubling.
59
+ */
60
+ export function doubles(model: { providerID?: string; modelID?: string } | undefined, wallet: string, now = Date.now()): boolean {
61
+ if (!isPeak(now)) return false
62
+ const provider = model?.providerID ?? ""
63
+ if (provider === "deepseek") return true
64
+ if (provider === "opencode-go" && wallet === "go") return /^deepseek/.test(model?.modelID ?? "")
65
+ return false
66
+ }
67
+
68
+ // Self-check: node plugins/oc-budget/peak.ts --self-check
69
+ // Guarded: this module is also bundled into the desktop window, where `process` does not exist.
70
+ if (typeof process !== "undefined" && process.argv?.[2] === "--self-check") {
71
+ const check = (ok: boolean, message: string) => {
72
+ if (!ok) throw new Error("self-check: " + message)
73
+ }
74
+ // 2026-10-27 is a Tuesday: 02:00 UTC is peak, 04:30 is not, 06:00 is again.
75
+ const tue = (h: number, m = 0) => Date.UTC(2026, 9, 27, h, m)
76
+ const sat = (h: number) => Date.UTC(2026, 9, 31, h)
77
+
78
+ check(isPeak(tue(2)), "02:00 UTC on a weekday is peak")
79
+ check(!isPeak(tue(0, 30)), "00:30 UTC is off-peak")
80
+ check(!isPeak(tue(4)), "04:00 is the first off-peak minute")
81
+ check(isPeak(tue(6)), "06:00 is peak again")
82
+ check(!isPeak(tue(10)), "10:00 ends it")
83
+ check(!isPeak(sat(2)), "a weekend is never peak")
84
+
85
+ const deepseek = { providerID: "deepseek", modelID: "deepseek-chat" }
86
+ const goDeepseek = { providerID: "opencode-go", modelID: "deepseek-v4.1-flash" }
87
+ const goOther = { providerID: "opencode-go", modelID: "glm-5.3" }
88
+ const zenDeepseek = { providerID: "opencode", modelID: "deepseek-v4.1-flash" }
89
+
90
+ check(doubles(deepseek, "ds", tue(2)), "native DeepSeek doubles at peak")
91
+ check(!doubles(deepseek, "ds", tue(4)), "and not off-peak")
92
+ check(doubles(goDeepseek, "go", tue(2)), "a Go DeepSeek doubles while Go pays")
93
+ check(!doubles(goDeepseek, "ds", tue(2)), "but not while another wallet pays")
94
+ check(!doubles(goOther, "go", tue(2)), "a Go model that is not DeepSeek never doubles")
95
+ check(!doubles(zenDeepseek, "zen", tue(2)), "Zen's DeepSeek price is flat")
96
+ check(!doubles(undefined, "go", tue(2)), "no model, no doubling")
97
+
98
+ const soon = nextChange(tue(2))
99
+ check(!soon.peak && soon.at === tue(4), "the next change from 02:00 is off-peak at 04:00")
100
+ const friday = nextChange(Date.UTC(2026, 9, 30, 23))
101
+ check(friday.peak && friday.at === Date.UTC(2026, 10, 2, 1), "Friday night flips to peak on Monday at 01:00")
102
+
103
+ console.log("self-check ok: " + WINDOWS.length + " windows, Zen flat, the weekend never peak")
104
+ }
package/rpc.ts ADDED
@@ -0,0 +1,111 @@
1
+ // The one contract shared by the server half and the TUI half of the plugin.
2
+ // The server registers it and implements `snapshot`; the TUI imports the same
3
+ // definition to build an RPC client, so the two can never drift.
4
+ import { Rpc } from "@opencode/plugin/rpc"
5
+
6
+ /** One budget window: how much of it is spent, its status, and when it resets. */
7
+ const Window = {
8
+ type: "object",
9
+ properties: {
10
+ status: { type: "string" },
11
+ percent: { type: "number" },
12
+ resetsAt: { type: "string" },
13
+ },
14
+ additionalProperties: false,
15
+ }
16
+
17
+ export const Budget = Rpc.define({
18
+ id: "budget",
19
+ methods: {
20
+ snapshot: {
21
+ input: {
22
+ type: "object",
23
+ properties: { force: { type: "boolean" } },
24
+ additionalProperties: false,
25
+ },
26
+ output: {
27
+ type: "object",
28
+ properties: {
29
+ at: { type: "number" },
30
+ go: {
31
+ type: "object",
32
+ properties: {
33
+ rolling: Window,
34
+ weekly: Window,
35
+ monthly: Window,
36
+ error: { type: "string" },
37
+ },
38
+ additionalProperties: false,
39
+ },
40
+ ds: {
41
+ type: "object",
42
+ properties: {
43
+ balance: { type: "number" },
44
+ currency: { type: "string" },
45
+ error: { type: "string" },
46
+ },
47
+ additionalProperties: false,
48
+ },
49
+ or: {
50
+ type: "object",
51
+ properties: {
52
+ balance: { type: "number" },
53
+ totalCredits: { type: "number" },
54
+ totalUsage: { type: "number" },
55
+ usageMonthly: { type: "number" },
56
+ error: { type: "string" },
57
+ },
58
+ additionalProperties: false,
59
+ },
60
+ zen: {
61
+ type: "object",
62
+ properties: {
63
+ /** The ledger: signed, and negative when a request cost more than was left. */
64
+ balance: { type: "number" },
65
+ /** The spendable credit, the figure the console prints; the balance clamped at 0. */
66
+ available: { type: "number" },
67
+ /** The console workspace: it pays, and it owns this balance. */
68
+ org: { type: "string" },
69
+ /** How many console connections exist: with one, the name is noise. */
70
+ peers: { type: "number" },
71
+ error: { type: "string" },
72
+ },
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ required: ["at"],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ },
81
+ // Present (empty) on purpose: the loader walks `events` and a missing map is a
82
+ // hard failure rather than an empty one.
83
+ events: {},
84
+ })
85
+
86
+ export interface UsageWindow {
87
+ status?: string
88
+ percent?: number
89
+ resetsAt?: string
90
+ }
91
+
92
+ export interface BudgetSnapshot {
93
+ at: number
94
+ go?: {
95
+ rolling?: UsageWindow
96
+ weekly?: UsageWindow
97
+ monthly?: UsageWindow
98
+ error?: string
99
+ }
100
+ ds?: { balance?: number; currency?: string; error?: string }
101
+ or?: {
102
+ balance?: number
103
+ totalCredits?: number
104
+ totalUsage?: number
105
+ usageMonthly?: number
106
+ error?: string
107
+ }
108
+ zen?: { balance?: number; available?: number; org?: string; peers?: number; error?: string }
109
+ }
110
+
111
+ export default Budget