opencode-providers-balances 0.0.0-stage → 0.1.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pavel Romanov
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 CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # opencode-providers-balances
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/opencode-providers-balances.svg?color=blue)](https://www.npmjs.com/package/opencode-providers-balances)
4
+ [![license](https://img.shields.io/github/license/fibit/opencode-providers-balances)](LICENSE)
5
+
6
+ > Show provider account balances in the OpenCode TUI sidebar.
7
+
8
+ An OpenCode **TUI** plugin. It renders only in the terminal interface; the web
9
+ client is not supported.
10
+
11
+ Providers live entirely in configuration — the plugin ships with no built-in
12
+ provider data. List them under the plugin's `options.providers` and each one
13
+ renders as a row in the `sidebar.content` slot, refreshed every 5 minutes. A
14
+ failed refresh keeps the last-known value and marks it stale with `!`.
15
+
16
+ ## Features
17
+
18
+ - Any number of providers, configured in `opencode.jsonc` — no code changes.
19
+ - Declarative response parsing: `jsonPath`, `prefix`, `prefixFrom`, `require`.
20
+ - Keys resolved from the OpenCode credential store, config, or environment.
21
+ - Hidden rows for providers without a resolvable key or numeric value.
22
+
23
+ ## Prerequisites
24
+
25
+ - OpenCode **V2** (the plugin API is beta).
26
+ - A key for each provider you configure — via `/connect`, `opencode.jsonc`, or
27
+ an environment variable.
28
+
29
+ ## Install
30
+
31
+ Add the plugin to `~/.config/opencode/opencode.jsonc` and configure providers:
32
+
33
+ ```jsonc
34
+ {
35
+ "$schema": "https://opencode.ai/config.json",
36
+ "plugins": [
37
+ {
38
+ "package": "opencode-providers-balances",
39
+ "options": {
40
+ "refreshMinutes": 5,
41
+ "providers": [
42
+ {
43
+ "id": "deepseek",
44
+ "label": "DeepSeek",
45
+ "url": "https://api.deepseek.com/user/balance",
46
+ "integration": "deepseek",
47
+ "require": { "path": "is_available", "equals": true },
48
+ "jsonPath": "balance_infos.0.total_balance",
49
+ "prefixFrom": { "path": "balance_infos.0.currency", "map": { "USD": "$" } }
50
+ },
51
+ {
52
+ "id": "openrouter",
53
+ "label": "OpenRouter",
54
+ "url": "https://openrouter.ai/api/v1/credits",
55
+ "integration": "openrouter",
56
+ "jsonPath": "data.total_credits",
57
+ "prefix": "$"
58
+ },
59
+ {
60
+ "id": "aitunnel",
61
+ "label": "AITUNNEL",
62
+ "url": "https://api.aitunnel.ru/v1/aitunnel/balance",
63
+ "configProvider": "aitunnel",
64
+ "jsonPath": "balance",
65
+ "prefix": "₽"
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ Restart the TUI (or `opencode service restart`) after changing the config.
75
+
76
+ ## Usage
77
+
78
+ Once installed, the sidebar shows a bold **Balances** heading and one row per
79
+ provider:
80
+
81
+ ```
82
+ Balances
83
+ • DeepSeek $10.01
84
+ • OpenRouter $0.00
85
+ • AITUNNEL ₽17779.49
86
+ ```
87
+
88
+ Each row is `•` normally, or `!` when the last refresh failed but a previous
89
+ value is kept.
90
+
91
+ The three examples show the common shapes: an **integration** key with a
92
+ currency-derived prefix (DeepSeek), an **integration** key with a fixed prefix
93
+ (OpenRouter), and a **custom provider** key from the config with a ruble
94
+ balance (AITUNNEL).
95
+
96
+ ## Configuration
97
+
98
+ ### Options
99
+
100
+ | Option | Type | Default | Description |
101
+ | --- | --- | --- | --- |
102
+ | `refreshMinutes` | number | `5` | Minutes between refreshes (minimum 1). |
103
+ | `disable` | string[] | `[]` | Provider ids to hide (convenience). |
104
+ | `providers` | object[] | `[]` | Providers to display. Empty or missing means no panel. |
105
+
106
+ ### Provider fields
107
+
108
+ | Field | Required | Description |
109
+ | --- | --- | --- |
110
+ | `id` | yes | Stable id; used for `disable` and as the base of the default env var (sanitized — see `env`). |
111
+ | `label` | yes | Sidebar label. |
112
+ | `url` | yes | Balance endpoint. |
113
+ | `jsonPath` | no | Dot path into the JSON body. Numeric segments index arrays, e.g. `balance_infos.0.total_balance`. The value must be a number (or numeric string) and is formatted with two decimals. |
114
+ | `prefix` | no | Fixed string prepended to the value (e.g. `"$"`, `"€"`, `"₽"`). |
115
+ | `prefixFrom` | no | Derive the prefix from a value: `{ path, map, fallback? }`, e.g. `{ "path": "currency", "map": { "USD": "$" } }`. Takes precedence over `prefix`. |
116
+ | `require` | no | Gate the row: `{ path, equals }`; hidden unless the value at `path` strictly equals `equals`. |
117
+ | `authScheme` | no | Authorization scheme, default `Bearer`. |
118
+ | `env` | no | Env var holding the key, default `<SANITIZED_ID>_API_KEY` — the id uppercased with runs of non-alphanumerics turned into `_` (e.g. `router-ai` → `ROUTER_AI_API_KEY`). |
119
+ | `key` | no | Literal key (discouraged — prefer env/integration). |
120
+ | `integration` | no | Id in the V2 SQLite `credential` table. |
121
+ | `configProvider` | no | Provider id in `opencode.jsonc` whose `settings.apiKey` to use. |
122
+
123
+ ### Key resolution
124
+
125
+ For each provider, the key is taken from the first source that has one:
126
+
127
+ 1. `key` in the provider spec (literal — discouraged)
128
+ 2. an integration in the OpenCode V2 SQLite credential store (`integration`)
129
+ 3. the matching provider's `settings.apiKey` in `opencode.jsonc` (`configProvider`)
130
+ 4. an environment variable (`env`, default `<ID>_API_KEY`)
131
+
132
+ ## Notes
133
+
134
+ - A provider whose key cannot be resolved is hidden rather than shown as stale.
135
+ - A malformed spec (e.g. a `jsonPath` that resolves to a non-number) hides that
136
+ row instead of failing the plugin.
137
+ - OpenAI's prepaid balance is not exposed via API, so it cannot be shown.
138
+ - RouterAI's `/credits` value is billed in rubles, hence `₽`. AITUNNEL's
139
+ `/balance` is also in rubles.
140
+ - A provider configured directly in `opencode.jsonc` (not via `/connect`) uses
141
+ `configProvider` — see the AITUNNEL example.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ npm install
147
+ npm test
148
+ npm run typecheck
149
+ ```
150
+
151
+ Runtime dependencies (`@opencode/plugin`, `@opentui/solid`, `solid-js`) are
152
+ provided by OpenCode and declared as `peerDependencies`; they are installed
153
+ locally only for type-checking.
154
+
155
+ ## License
156
+
157
+ MIT — see [LICENSE](LICENSE).
package/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ // Server-side entrypoint. All behavior lives in the TUI entrypoint (./tui).
4
+ export default Plugin.define({
5
+ id: "providers-balances",
6
+ setup() {},
7
+ })
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "opencode-providers-balances",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "OpenCode TUI plugin that shows provider account balances in the session sidebar. Providers are configured entirely in opencode.jsonc.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "opencode2",
9
+ "balance",
10
+ "usage",
11
+ "quota",
12
+ "sidebar",
13
+ "tui"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Pavel Romanov",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/fibit/opencode-providers-balances.git"
20
+ },
21
+ "type": "module",
22
+ "main": "./index.ts",
23
+ "exports": {
24
+ ".": "./index.ts",
25
+ "./tui": "./tui.tsx"
26
+ },
27
+ "files": [
28
+ "index.ts",
29
+ "providers.ts",
30
+ "tui.tsx",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --experimental-strip-types test.mjs",
36
+ "typecheck": "tsc --noEmit --skipLibCheck --module esnext --moduleResolution bundler --target esnext --jsx preserve --jsxImportSource @opentui/solid index.ts tui.tsx providers.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "@opencode/plugin": "beta",
40
+ "@opentui/core": ">=0.5.8",
41
+ "@opentui/solid": ">=0.5.8",
42
+ "solid-js": ">=1.9.0"
43
+ },
44
+ "devDependencies": {
45
+ "@opencode/plugin": "beta",
46
+ "@opentui/core": ">=0.5.8",
47
+ "@opentui/solid": ">=0.5.8",
48
+ "solid-js": "1.9.12",
49
+ "typescript": "^5.9.0"
50
+ }
51
+ }
package/providers.ts ADDED
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Balance fetching for the opencode-providers-balances plugin.
3
+ *
4
+ * Holds the core logic: declarative formatting, key resolution, and the
5
+ * balance fetch. No OpenTUI/JSX imports here, so it is safe to import from
6
+ * both the server and TUI entrypoints.
7
+ *
8
+ * Provider definitions live entirely in plugin options (opencode.jsonc); this
9
+ * file contains no built-in provider data.
10
+ */
11
+
12
+ import { readFileSync, existsSync } from "node:fs"
13
+ import { homedir } from "node:os"
14
+ import { join, resolve } from "node:path"
15
+ import { createRequire } from "node:module"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export const DEFAULT_REFRESH_MINUTES = 5
22
+ const FETCH_TIMEOUT_MS = 10_000
23
+ const KEY_CACHE_MS = 60_000
24
+ const CONFIG_CACHE_MS = 60_000
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export type ProviderKey = string
31
+
32
+ export interface Provider {
33
+ /** Stable id, also the render/state key. */
34
+ id: ProviderKey
35
+ /** Sidebar label. */
36
+ label: string
37
+ /** Balance endpoint. */
38
+ url: string
39
+ /** Authorization scheme; defaults to `Bearer`. */
40
+ authScheme?: string
41
+ /** Environment variable holding the key. */
42
+ env: string
43
+ /** Integration id in the V2 SQLite credential store, when applicable. */
44
+ integration?: string
45
+ /** Provider id in `opencode.jsonc` whose `settings.apiKey` to use. */
46
+ configProvider?: string
47
+ /** Literal key supplied via options (discouraged). */
48
+ literalKey?: string
49
+ /** Turn a response body into the display value, or null when unavailable. */
50
+ format: (body: unknown) => string | null
51
+ }
52
+
53
+ /**
54
+ * Declarative provider spec accepted from plugin options. A spec is turned
55
+ * into a formatter from three optional rules:
56
+ * - `require` gate: return null unless path === equals.
57
+ * - `jsonPath` dot path into the body (numeric segments index arrays).
58
+ * - `prefix` / `prefixFrom` fixed or value-derived display prefix.
59
+ */
60
+ export interface ProviderConfig {
61
+ /** Stable id; also the base of the default env var and the `disable` key. */
62
+ id: string
63
+ /** Sidebar label. */
64
+ label: string
65
+ /** Balance endpoint. */
66
+ url: string
67
+ /** Authorization scheme; defaults to `Bearer`. */
68
+ authScheme?: string
69
+ /** Env var holding the key; defaults to `<SANITIZED_ID>_API_KEY`. */
70
+ env?: string
71
+ /** Literal key (discouraged — prefer env/integration). */
72
+ key?: string
73
+ /** Integration id in the V2 SQLite `credential` table. */
74
+ integration?: string
75
+ /** Provider id in `opencode.jsonc` whose `settings.apiKey` to use. */
76
+ configProvider?: string
77
+ /** Dot path into the JSON body; numeric segments index arrays. */
78
+ jsonPath?: string
79
+ /** Fixed string prepended to the value (e.g. `"$"`, `"€"`, `"₽"`). */
80
+ prefix?: string
81
+ /** Derive the prefix from a body value; takes precedence over `prefix`. */
82
+ prefixFrom?: { path: string; map: Record<string, string>; fallback?: string }
83
+ /** Gate the row: hide it unless the value at `path` strictly equals `equals`. */
84
+ require?: { path: string; equals: unknown }
85
+ }
86
+
87
+ export interface BalancesOptions {
88
+ /** Providers to display; empty or missing means no panel. */
89
+ providers?: ProviderConfig[]
90
+ /** Provider ids to hide. */
91
+ disable?: string[]
92
+ /** Minutes between refreshes (minimum 1). */
93
+ refreshMinutes?: number
94
+ }
95
+
96
+ /** Result of fetching one provider in a refresh cycle. */
97
+ export interface FetchResult {
98
+ /** Display value; empty when unavailable. */
99
+ value: string
100
+ /** True when the provider is configured but the last refresh failed. */
101
+ stale: boolean
102
+ /**
103
+ * True when the provider must not be rendered: no usable key, a failed
104
+ * `require` gate, or a response with no numeric value. Distinguishing this
105
+ * from `stale` keeps an intentionally unavailable provider from showing a
106
+ * previous balance marked stale.
107
+ */
108
+ hidden: boolean
109
+ }
110
+
111
+ export interface BalanceState {
112
+ value: string
113
+ stale: boolean
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Declarative formatting
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Read a dot path from a JSON body; numeric segments index arrays. */
121
+ export function jsonPathGet(body: unknown, path: string): unknown {
122
+ let cur: unknown = body
123
+ for (const part of path.split(".")) {
124
+ if (cur == null || typeof cur !== "object") return undefined
125
+ cur = Array.isArray(cur) ? cur[Number(part)] : (cur as Record<string, unknown>)[part]
126
+ }
127
+ return cur
128
+ }
129
+
130
+ /**
131
+ * Coerce a value to a finite number, or null. Only a real number or a non-blank
132
+ * numeric string counts; `null`, booleans, arrays, objects and blank strings are
133
+ * rejected rather than coerced (so `Number(null) === 0` can't masquerade as a
134
+ * zero balance).
135
+ */
136
+ function num(v: unknown): number | null {
137
+ if (typeof v === "number") return Number.isFinite(v) ? v : null
138
+ if (typeof v === "string") {
139
+ const t = v.trim()
140
+ if (!t) return null
141
+ const n = Number(t)
142
+ return Number.isFinite(n) ? n : null
143
+ }
144
+ return null
145
+ }
146
+
147
+ /** Build a formatter from the declarative rules in a spec. */
148
+ function buildFormatter(spec: ProviderConfig): (body: unknown) => string | null {
149
+ return (body) => {
150
+ if (spec.require && jsonPathGet(body, spec.require.path) !== spec.require.equals) return null
151
+ if (!spec.jsonPath) return null
152
+ const n = num(jsonPathGet(body, spec.jsonPath))
153
+ if (n == null) return null
154
+ let prefix = spec.prefix ?? ""
155
+ if (spec.prefixFrom) {
156
+ const raw = jsonPathGet(body, spec.prefixFrom.path)
157
+ prefix = spec.prefixFrom.map[String(raw)] ?? spec.prefixFrom.fallback ?? ""
158
+ }
159
+ return `${prefix}${n.toFixed(2)}`
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Config access (opencode.jsonc)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ let configCache: { at: number; data: Record<string, unknown> | null } | null = null
168
+
169
+ /**
170
+ * Root directories OpenCode uses for global state, honoring the XDG base
171
+ * directories exactly like OpenCode's own `roots()`: `$XDG_CONFIG_HOME` and
172
+ * `$XDG_DATA_HOME`, falling back to `~/.config` and `~/.local/share` when unset
173
+ * or empty.
174
+ */
175
+ export function opencodeRoots(app = "opencode"): { config: string; data: string } {
176
+ const home = homedir()
177
+ const configHome = process.env.XDG_CONFIG_HOME || join(home, ".config")
178
+ const dataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share")
179
+ return { config: join(configHome, app), data: join(dataHome, app) }
180
+ }
181
+
182
+ /**
183
+ * Candidate global config files in precedence order, honoring OpenCode's
184
+ * `OPENCODE_CONFIG` (explicit file) and `OPENCODE_CONFIG_DIR` (config directory
185
+ * override) before the XDG-derived default.
186
+ */
187
+ export function configFilePaths(): string[] {
188
+ const dir = process.env.OPENCODE_CONFIG_DIR || opencodeRoots().config
189
+ const files = ["opencode.jsonc", "opencode.json"].map((name) => join(dir, name))
190
+ const explicit = process.env.OPENCODE_CONFIG
191
+ return explicit ? [explicit, ...files] : files
192
+ }
193
+
194
+ function readConfig(): Record<string, unknown> | null {
195
+ const now = Date.now()
196
+ if (configCache && now - configCache.at < CONFIG_CACHE_MS) return configCache.data
197
+ let data: Record<string, unknown> | null = null
198
+ const inline = process.env.OPENCODE_CONFIG_CONTENT
199
+ if (inline) {
200
+ try {
201
+ data = parseJsonc(inline)
202
+ } catch {}
203
+ }
204
+ if (!data) {
205
+ for (const p of configFilePaths()) {
206
+ if (!existsSync(p)) continue
207
+ try {
208
+ data = parseJsonc(readFileSync(p, "utf8"))
209
+ break
210
+ } catch {}
211
+ }
212
+ }
213
+ configCache = { at: now, data }
214
+ return data
215
+ }
216
+
217
+ /**
218
+ * Parse JSONC: strip line/block comments and trailing commas, then hand the
219
+ * result to `JSON.parse`. Comments and commas inside string values are left
220
+ * intact. Only `"` delimits strings, matching JSONC.
221
+ */
222
+ export function parseJsonc(raw: string): Record<string, unknown> {
223
+ return JSON.parse(stripTrailingCommas(stripComments(raw))) as Record<string, unknown>
224
+ }
225
+
226
+ /** Remove line and block comments, ignoring any that appear inside strings. */
227
+ function stripComments(raw: string): string {
228
+ let out = ""
229
+ let inString = false
230
+ for (let i = 0; i < raw.length; i++) {
231
+ const ch = raw[i]
232
+ const next = raw[i + 1]
233
+ if (inString) {
234
+ out += ch
235
+ if (ch === "\\") {
236
+ out += next ?? ""
237
+ i++
238
+ } else if (ch === '"') {
239
+ inString = false
240
+ }
241
+ continue
242
+ }
243
+ if (ch === '"') {
244
+ inString = true
245
+ out += ch
246
+ continue
247
+ }
248
+ if (ch === "/" && next === "/") {
249
+ while (i < raw.length && raw[i] !== "\n") i++
250
+ out += "\n"
251
+ continue
252
+ }
253
+ if (ch === "/" && next === "*") {
254
+ i += 2
255
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++
256
+ i++
257
+ continue
258
+ }
259
+ out += ch
260
+ }
261
+ return out
262
+ }
263
+
264
+ /** Drop commas that precede a closing brace/bracket, ignoring string values. */
265
+ function stripTrailingCommas(s: string): string {
266
+ let out = ""
267
+ let inString = false
268
+ for (let i = 0; i < s.length; i++) {
269
+ const ch = s[i]
270
+ if (inString) {
271
+ out += ch
272
+ if (ch === "\\") {
273
+ out += s[i + 1] ?? ""
274
+ i++
275
+ } else if (ch === '"') {
276
+ inString = false
277
+ }
278
+ continue
279
+ }
280
+ if (ch === '"') {
281
+ inString = true
282
+ out += ch
283
+ continue
284
+ }
285
+ if (ch === ",") {
286
+ let j = i + 1
287
+ while (j < s.length && /\s/.test(s[j])) j++
288
+ if (s[j] === "}" || s[j] === "]") continue
289
+ }
290
+ out += ch
291
+ }
292
+ return out
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Key resolution
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Read this plugin's own options from `opencode.jsonc`.
301
+ *
302
+ * OpenCode V2 beta delivers plugin options to the server entrypoint but not to
303
+ * the TUI entrypoint, so the TUI falls back to reading them from the config.
304
+ */
305
+ export function loadOptionsFromConfig(selfHints: string[] = ["opencode-providers-balances"]): BalancesOptions {
306
+ const cfg = readConfig()
307
+ const entries = cfg?.plugins
308
+ if (!Array.isArray(entries)) return {}
309
+ for (const entry of entries) {
310
+ if (Array.isArray(entry)) {
311
+ if (matchesSelf(entry[0], selfHints)) return (entry[1] ?? {}) as BalancesOptions
312
+ } else if (entry && typeof entry === "object") {
313
+ const obj = entry as Record<string, unknown>
314
+ if (matchesSelf(obj.package, selfHints)) return (obj.options ?? {}) as BalancesOptions
315
+ }
316
+ }
317
+ return {}
318
+ }
319
+
320
+ function matchesSelf(spec: unknown, hints: string[]): boolean {
321
+ if (typeof spec !== "string") return false
322
+ const norm = spec.replace(/\\/g, "/").toLowerCase()
323
+ return hints.some((h) => norm.includes(h.toLowerCase()))
324
+ }
325
+
326
+ // --- SQLite credential store (V2) ------------------------------------------
327
+
328
+ /** Lazily resolve a SQLite driver across Bun and Node. */
329
+ function loadSqlite(): { open: (path: string) => unknown } | null {
330
+ const bun = tryRequire("bun:sqlite")
331
+ if (bun?.Database) {
332
+ return { open: (p) => new bun.Database(p, { readonly: true }) }
333
+ }
334
+ const node = tryRequire("node:sqlite")
335
+ if (node?.DatabaseSync) {
336
+ return { open: (p) => new node.DatabaseSync(p, { readOnly: true }) }
337
+ }
338
+ return null
339
+ }
340
+
341
+ function tryRequire(mod: string): any {
342
+ const attempts: Array<() => unknown> = [
343
+ () => (import.meta as any).require?.(mod),
344
+ () => (globalThis as any).require?.(mod),
345
+ () => createRequire(import.meta.url)(mod),
346
+ () => (Function("return require")() as any)(mod),
347
+ ]
348
+ for (const attempt of attempts) {
349
+ try {
350
+ const result = attempt()
351
+ if (result) return result
352
+ } catch {}
353
+ }
354
+ return null
355
+ }
356
+
357
+ /** Stored credential keys by integration id, cached briefly. */
358
+ let keyCache: { at: number; keys: Record<string, string> } | null = null
359
+
360
+ function allIntegrationKeys(): Record<string, string> {
361
+ const now = Date.now()
362
+ if (keyCache && now - keyCache.at < KEY_CACHE_MS) return keyCache.keys
363
+ keyCache = { at: now, keys: readAllCredentials() }
364
+ return keyCache.keys
365
+ }
366
+
367
+ /** Path to the SQLite credential store, or null when no persistent DB is used. */
368
+ export function databasePath(): string | null {
369
+ const override = process.env.OPENCODE_DB
370
+ const dataDir = opencodeRoots().data
371
+ if (override) return override === ":memory:" ? null : resolve(dataDir, override)
372
+ return join(dataDir, "opencode.db")
373
+ }
374
+
375
+ function readAllCredentials(): Record<string, string> {
376
+ const path = databasePath()
377
+ if (!path || !existsSync(path)) return {}
378
+ const driver = loadSqlite()
379
+ if (!driver) return {}
380
+ let db: any
381
+ try {
382
+ db = driver.open(path)
383
+ const rows = runQuery(db, "SELECT integration_id, value FROM credential") as Array<{
384
+ integration_id?: unknown
385
+ value?: unknown
386
+ }>
387
+ const out: Record<string, string> = {}
388
+ for (const row of rows ?? []) {
389
+ const id = row?.integration_id != null ? String(row.integration_id) : ""
390
+ const key = row?.value != null ? extractKey(JSON.parse(String(row.value))) : null
391
+ if (id && key) out[id] = key
392
+ }
393
+ return out
394
+ } catch {
395
+ return {}
396
+ } finally {
397
+ try {
398
+ db?.close?.()
399
+ } catch {}
400
+ }
401
+ }
402
+
403
+ /** Run a query against either the Bun or Node SQLite driver. */
404
+ function runQuery(db: any, sql: string): unknown[] {
405
+ try {
406
+ const stmt = typeof db.query === "function" ? db.query(sql) : db.prepare(sql)
407
+ return stmt.all()
408
+ } catch {
409
+ return []
410
+ }
411
+ }
412
+
413
+ function extractKey(v: unknown): string | null {
414
+ if (!v) return null
415
+ if (typeof v === "string") return v.trim() || null
416
+ if (typeof v === "object") {
417
+ for (const field of ["key", "token", "apiKey", "value"]) {
418
+ const s = (v as Record<string, unknown>)[field]
419
+ if (typeof s === "string" && s.trim()) return s.trim()
420
+ }
421
+ }
422
+ return null
423
+ }
424
+
425
+ /** Resolve a provider's key from the first source that has one. */
426
+ function keyForProvider(p: Provider): string | null {
427
+ if (p.literalKey?.trim()) return p.literalKey.trim()
428
+ if (p.integration) {
429
+ const k = allIntegrationKeys()[p.integration]
430
+ if (k) return k
431
+ }
432
+ if (p.configProvider) {
433
+ const cfg = readConfig()
434
+ const providers = cfg?.providers as Record<string, any> | undefined
435
+ const k = extractKey(providers?.[p.configProvider]?.settings?.apiKey)
436
+ if (k) return k
437
+ }
438
+ return process.env[p.env]?.trim() || null
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Provider construction
443
+ // ---------------------------------------------------------------------------
444
+
445
+ /**
446
+ * Derive a conventional, settable env var name from a provider id:
447
+ * uppercase, runs of characters that are not `A-Z0-9` collapsed to `_`, and a
448
+ * leading `_` added when the result would otherwise start with a digit. So
449
+ * `router-ai` -> `ROUTER_AI_API_KEY` rather than `ROUTER-AI_API_KEY`.
450
+ */
451
+ function defaultEnvVar(id: string): string {
452
+ const base = id
453
+ .toUpperCase()
454
+ .replace(/[^A-Z0-9]+/g, "_")
455
+ .replace(/^_+|_+$/g, "")
456
+ const safe = base === "" ? "PROVIDER" : /^[0-9]/.test(base) ? `_${base}` : base
457
+ return `${safe}_API_KEY`
458
+ }
459
+
460
+ /** Convert a declarative spec into an executable provider. */
461
+ function toProvider(spec: ProviderConfig): Provider {
462
+ return {
463
+ id: spec.id,
464
+ label: spec.label,
465
+ url: spec.url,
466
+ authScheme: spec.authScheme,
467
+ env: spec.env ?? defaultEnvVar(spec.id),
468
+ integration: spec.integration,
469
+ configProvider: spec.configProvider,
470
+ literalKey: spec.key,
471
+ format: buildFormatter(spec),
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Build executable providers from configuration. Duplicate ids keep the last
477
+ * spec; `disable` removes ids. An empty or missing list yields no providers.
478
+ */
479
+ export function buildProviders(options?: BalancesOptions): Provider[] {
480
+ const disabled = new Set(options?.disable ?? [])
481
+ const seen = new Set<string>()
482
+ const ordered: Provider[] = []
483
+ const specs = options?.providers ?? []
484
+ for (const spec of specs) {
485
+ if (!spec?.id || disabled.has(spec.id)) continue
486
+ if (seen.has(spec.id)) {
487
+ // Last spec wins; replace in place to preserve position.
488
+ const idx = ordered.findIndex((p) => p.id === spec.id)
489
+ ordered[idx] = toProvider(spec)
490
+ } else {
491
+ seen.add(spec.id)
492
+ ordered.push(toProvider(spec))
493
+ }
494
+ }
495
+ return ordered
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Fetching
500
+ // ---------------------------------------------------------------------------
501
+
502
+ export async function fetchProvider(p: Provider): Promise<FetchResult> {
503
+ const key = keyForProvider(p)
504
+ // Hidden rather than stale when unconfigured.
505
+ if (!key) return { value: "", stale: false, hidden: true }
506
+
507
+ const ctrl = new AbortController()
508
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
509
+ try {
510
+ const scheme = p.authScheme ?? "Bearer"
511
+ const res = await fetch(p.url, {
512
+ headers: { Authorization: `${scheme} ${key}` },
513
+ signal: ctrl.signal,
514
+ })
515
+ if (!res.ok) return { value: "", stale: true, hidden: false }
516
+ const value = p.format(await res.json())
517
+ // A response that parses but yields no value hides the row; it is not a
518
+ // failed fetch, so it must not resurface as a stale balance.
519
+ return value ? { value, stale: false, hidden: false } : { value: "", stale: false, hidden: true }
520
+ } catch {
521
+ return { value: "", stale: true, hidden: false }
522
+ } finally {
523
+ clearTimeout(timer)
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Merge fresh results into state. Successful values replace; a failed refresh
529
+ * keeps the previous value marked stale; a hidden provider is removed.
530
+ */
531
+ export function collect(
532
+ state: Map<ProviderKey, BalanceState>,
533
+ providers: Provider[],
534
+ results: FetchResult[],
535
+ ): void {
536
+ providers.forEach((p, i) => {
537
+ const fresh = results[i]
538
+ if (!fresh || fresh.hidden) {
539
+ state.delete(p.id)
540
+ return
541
+ }
542
+ if (fresh.value) {
543
+ state.set(p.id, { value: fresh.value, stale: false })
544
+ return
545
+ }
546
+ const prev = state.get(p.id)
547
+ if (prev?.value) state.set(p.id, { value: prev.value, stale: true })
548
+ else state.delete(p.id)
549
+ })
550
+ }
551
+
552
+ /** One renderable balance line: a provider that currently has a value. */
553
+ export interface BalanceRow {
554
+ id: ProviderKey
555
+ label: string
556
+ value: string
557
+ stale: boolean
558
+ }
559
+
560
+ /** Providers with a current value, in provider order. */
561
+ export function balanceRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): BalanceRow[] {
562
+ const rows: BalanceRow[] = []
563
+ for (const p of providers) {
564
+ const s = state.get(p.id)
565
+ if (!s?.value) continue
566
+ rows.push({ id: p.id, label: p.label, value: s.value, stale: s.stale })
567
+ }
568
+ return rows
569
+ }
570
+
571
+ export function renderRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): string[] {
572
+ return balanceRows(state, providers).map((r) => `${r.stale ? "!" : "•"} ${r.label} ${r.value}`)
573
+ }
package/tui.tsx ADDED
@@ -0,0 +1,86 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { createSignal, For } from "solid-js"
4
+ import {
5
+ balanceRows,
6
+ buildProviders,
7
+ collect,
8
+ DEFAULT_REFRESH_MINUTES,
9
+ fetchProvider,
10
+ loadOptionsFromConfig,
11
+ type BalanceRow,
12
+ type BalanceState,
13
+ type BalancesOptions,
14
+ type Provider,
15
+ type ProviderKey,
16
+ } from "./providers"
17
+
18
+ /** Resolve options, falling back to opencode.jsonc when the context gives none. */
19
+ function resolveOptions(contextOptions: unknown): BalancesOptions {
20
+ const fromContext = (contextOptions ?? {}) as BalancesOptions
21
+ if (Array.isArray(fromContext.providers) && fromContext.providers.length > 0) return fromContext
22
+ const fromConfig = loadOptionsFromConfig()
23
+ return {
24
+ ...fromConfig,
25
+ ...fromContext,
26
+ providers: fromContext.providers ?? fromConfig.providers,
27
+ refreshMinutes: fromContext.refreshMinutes ?? fromConfig.refreshMinutes,
28
+ disable: fromContext.disable ?? fromConfig.disable,
29
+ }
30
+ }
31
+
32
+ export default Plugin.define({
33
+ id: "providers-balances.tui",
34
+ setup(context) {
35
+ const options = resolveOptions(context.options)
36
+ const providers: Provider[] = buildProviders(options)
37
+ const refreshMs = Math.max(1, options.refreshMinutes ?? DEFAULT_REFRESH_MINUTES) * 60_000
38
+
39
+ const state = new Map<ProviderKey, BalanceState>()
40
+ const [rows, setRows] = createSignal<BalanceRow[]>([])
41
+ const [loading, setLoading] = createSignal(providers.length > 0)
42
+ let stopped = false
43
+
44
+ async function refresh() {
45
+ const results = await Promise.all(providers.map((p) => fetchProvider(p)))
46
+ if (stopped) return
47
+ collect(state, providers, results)
48
+ setRows(balanceRows(state, providers))
49
+ setLoading(false)
50
+ }
51
+
52
+ const unregister = context.ui.slot({
53
+ append: "sidebar.content",
54
+ // Show a placeholder during the first fetch, then either the rows or
55
+ // nothing at all (never a bare heading).
56
+ render: () => {
57
+ const list = rows()
58
+ if (!list.length && !loading()) return null
59
+ return (
60
+ <text>
61
+ <b>Balances</b>
62
+ <For each={list}>
63
+ {(r) => (
64
+ <span style={{ fg: r.stale ? context.theme.text.feedback.warning.base : context.theme.text.base }}>
65
+ {`\n${r.stale ? "!" : "•"} ${r.label} ${r.value}`}
66
+ </span>
67
+ )}
68
+ </For>
69
+ {loading() && !list.length ? "\n…" : ""}
70
+ </text>
71
+ )
72
+ },
73
+ })
74
+
75
+ // Register first, then fetch in the background: a slow or unreachable
76
+ // endpoint must not delay setup or the first render.
77
+ void refresh()
78
+ const timer = setInterval(() => void refresh(), refreshMs)
79
+
80
+ return () => {
81
+ stopped = true
82
+ clearInterval(timer)
83
+ unregister()
84
+ }
85
+ },
86
+ })