@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/README.md +97 -2
- package/budget.ts +66 -0
- package/dist/gui-experimental.cjs +53 -0
- package/gui.tsx +622 -0
- package/header-menu.tsx +52 -0
- package/index.ts +366 -0
- package/package.json +33 -5
- package/peak.ts +104 -0
- package/rpc.ts +111 -0
- package/tui.tsx +504 -0
- package/vite.config.ts +6 -0
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|