opencode-providers-balances 0.0.0-stage → 0.1.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/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 default env prefix. |
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 `<ID>_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.1",
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,450 @@
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 } 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 default env prefix and `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 `<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
+ /** True when the provider has no usable key — hidden, not rendered. */
103
+ skipped: boolean
104
+ }
105
+
106
+ export interface BalanceState {
107
+ value: string
108
+ stale: boolean
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Declarative formatting
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Read a dot path from a JSON body; numeric segments index arrays. */
116
+ export function jsonPathGet(body: unknown, path: string): unknown {
117
+ let cur: unknown = body
118
+ for (const part of path.split(".")) {
119
+ if (cur == null || typeof cur !== "object") return undefined
120
+ cur = Array.isArray(cur) ? cur[Number(part)] : (cur as Record<string, unknown>)[part]
121
+ }
122
+ return cur
123
+ }
124
+
125
+ /** Coerce a value to a finite number, or null. */
126
+ function num(v: unknown): number | null {
127
+ const n = typeof v === "number" ? v : Number(v)
128
+ return Number.isFinite(n) ? n : null
129
+ }
130
+
131
+ /** Build a formatter from the declarative rules in a spec. */
132
+ function buildFormatter(spec: ProviderConfig): (body: unknown) => string | null {
133
+ return (body) => {
134
+ if (spec.require && jsonPathGet(body, spec.require.path) !== spec.require.equals) return null
135
+ if (!spec.jsonPath) return null
136
+ const n = num(jsonPathGet(body, spec.jsonPath))
137
+ if (n == null) return null
138
+ let prefix = spec.prefix ?? ""
139
+ if (spec.prefixFrom) {
140
+ const raw = jsonPathGet(body, spec.prefixFrom.path)
141
+ prefix = spec.prefixFrom.map[String(raw)] ?? spec.prefixFrom.fallback ?? ""
142
+ }
143
+ return `${prefix}${n.toFixed(2)}`
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Config access (opencode.jsonc)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ let configCache: { at: number; data: Record<string, unknown> | null } | null = null
152
+
153
+ function readConfig(): Record<string, unknown> | null {
154
+ const now = Date.now()
155
+ if (configCache && now - configCache.at < CONFIG_CACHE_MS) return configCache.data
156
+ let data: Record<string, unknown> | null = null
157
+ for (const name of ["opencode.jsonc", "opencode.json"]) {
158
+ const p = join(homedir(), ".config/opencode", name)
159
+ if (!existsSync(p)) continue
160
+ try {
161
+ data = parseJsonc(readFileSync(p, "utf8"))
162
+ break
163
+ } catch {}
164
+ }
165
+ configCache = { at: now, data }
166
+ return data
167
+ }
168
+
169
+ /** Strip JSONC comments without corrupting string values. */
170
+ function parseJsonc(raw: string): Record<string, unknown> {
171
+ let out = ""
172
+ let inString = false
173
+ let quote = ""
174
+ for (let i = 0; i < raw.length; i++) {
175
+ const ch = raw[i]
176
+ const next = raw[i + 1]
177
+ if (inString) {
178
+ out += ch
179
+ if (ch === "\\") {
180
+ out += next ?? ""
181
+ i++
182
+ } else if (ch === quote) {
183
+ inString = false
184
+ }
185
+ continue
186
+ }
187
+ if (ch === '"' || ch === "'") {
188
+ inString = true
189
+ quote = ch
190
+ out += ch
191
+ continue
192
+ }
193
+ if (ch === "/" && next === "/") {
194
+ while (i < raw.length && raw[i] !== "\n") i++
195
+ out += "\n"
196
+ continue
197
+ }
198
+ if (ch === "/" && next === "*") {
199
+ i += 2
200
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++
201
+ i++
202
+ continue
203
+ }
204
+ out += ch
205
+ }
206
+ out = out.replace(/,(\s*[}\]])/g, "$1")
207
+ return JSON.parse(out) as Record<string, unknown>
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Key resolution
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Read this plugin's own options from `opencode.jsonc`.
216
+ *
217
+ * OpenCode V2 beta delivers plugin options to the server entrypoint but not to
218
+ * the TUI entrypoint, so the TUI falls back to reading them from the config.
219
+ */
220
+ export function loadOptionsFromConfig(selfHints: string[] = ["opencode-providers-balances"]): BalancesOptions {
221
+ const cfg = readConfig()
222
+ const entries = cfg?.plugins
223
+ if (!Array.isArray(entries)) return {}
224
+ for (const entry of entries) {
225
+ if (Array.isArray(entry)) {
226
+ if (matchesSelf(entry[0], selfHints)) return (entry[1] ?? {}) as BalancesOptions
227
+ } else if (entry && typeof entry === "object") {
228
+ const obj = entry as Record<string, unknown>
229
+ if (matchesSelf(obj.package, selfHints)) return (obj.options ?? {}) as BalancesOptions
230
+ }
231
+ }
232
+ return {}
233
+ }
234
+
235
+ function matchesSelf(spec: unknown, hints: string[]): boolean {
236
+ if (typeof spec !== "string") return false
237
+ const norm = spec.replace(/\\/g, "/").toLowerCase()
238
+ return hints.some((h) => norm.includes(h.toLowerCase()))
239
+ }
240
+
241
+ // --- SQLite credential store (V2) ------------------------------------------
242
+
243
+ /** Lazily resolve a SQLite driver across Bun and Node. */
244
+ function loadSqlite(): { open: (path: string) => unknown } | null {
245
+ const bun = tryRequire("bun:sqlite")
246
+ if (bun?.Database) {
247
+ return { open: (p) => new bun.Database(p, { readonly: true }) }
248
+ }
249
+ const node = tryRequire("node:sqlite")
250
+ if (node?.DatabaseSync) {
251
+ return { open: (p) => new node.DatabaseSync(p, { readOnly: true }) }
252
+ }
253
+ return null
254
+ }
255
+
256
+ function tryRequire(mod: string): any {
257
+ const attempts: Array<() => unknown> = [
258
+ () => (import.meta as any).require?.(mod),
259
+ () => (globalThis as any).require?.(mod),
260
+ () => createRequire(import.meta.url)(mod),
261
+ () => (Function("return require")() as any)(mod),
262
+ ]
263
+ for (const attempt of attempts) {
264
+ try {
265
+ const result = attempt()
266
+ if (result) return result
267
+ } catch {}
268
+ }
269
+ return null
270
+ }
271
+
272
+ /** Stored credential keys by integration id, cached briefly. */
273
+ let keyCache: { at: number; keys: Record<string, string> } | null = null
274
+
275
+ function allIntegrationKeys(): Record<string, string> {
276
+ const now = Date.now()
277
+ if (keyCache && now - keyCache.at < KEY_CACHE_MS) return keyCache.keys
278
+ keyCache = { at: now, keys: readAllCredentials() }
279
+ return keyCache.keys
280
+ }
281
+
282
+ function readAllCredentials(): Record<string, string> {
283
+ const path = join(homedir(), ".local/share/opencode/opencode.db")
284
+ if (!existsSync(path)) return {}
285
+ const driver = loadSqlite()
286
+ if (!driver) return {}
287
+ let db: any
288
+ try {
289
+ db = driver.open(path)
290
+ const rows = runQuery(db, "SELECT integration_id, value FROM credential") as Array<{
291
+ integration_id?: unknown
292
+ value?: unknown
293
+ }>
294
+ const out: Record<string, string> = {}
295
+ for (const row of rows ?? []) {
296
+ const id = row?.integration_id != null ? String(row.integration_id) : ""
297
+ const key = row?.value != null ? extractKey(JSON.parse(String(row.value))) : null
298
+ if (id && key) out[id] = key
299
+ }
300
+ return out
301
+ } catch {
302
+ return {}
303
+ } finally {
304
+ try {
305
+ db?.close?.()
306
+ } catch {}
307
+ }
308
+ }
309
+
310
+ /** Run a query against either the Bun or Node SQLite driver. */
311
+ function runQuery(db: any, sql: string): unknown[] {
312
+ try {
313
+ const stmt = typeof db.query === "function" ? db.query(sql) : db.prepare(sql)
314
+ return stmt.all()
315
+ } catch {
316
+ return []
317
+ }
318
+ }
319
+
320
+ function extractKey(v: unknown): string | null {
321
+ if (!v) return null
322
+ if (typeof v === "string") return v.trim() || null
323
+ if (typeof v === "object") {
324
+ for (const field of ["key", "token", "apiKey", "value"]) {
325
+ const s = (v as Record<string, unknown>)[field]
326
+ if (typeof s === "string" && s.trim()) return s.trim()
327
+ }
328
+ }
329
+ return null
330
+ }
331
+
332
+ /** Resolve a provider's key from the first source that has one. */
333
+ export function keyForProvider(p: Provider): string | null {
334
+ if (p.literalKey?.trim()) return p.literalKey.trim()
335
+ if (p.integration) {
336
+ const k = allIntegrationKeys()[p.integration]
337
+ if (k) return k
338
+ }
339
+ if (p.configProvider) {
340
+ const cfg = readConfig()
341
+ const providers = cfg?.providers as Record<string, any> | undefined
342
+ const k = extractKey(providers?.[p.configProvider]?.settings?.apiKey)
343
+ if (k) return k
344
+ }
345
+ return process.env[p.env]?.trim() || null
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Provider construction
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /** Convert a declarative spec into an executable provider. */
353
+ function toProvider(spec: ProviderConfig): Provider {
354
+ return {
355
+ id: spec.id,
356
+ label: spec.label,
357
+ url: spec.url,
358
+ authScheme: spec.authScheme,
359
+ env: spec.env ?? `${spec.id.toUpperCase()}_API_KEY`,
360
+ integration: spec.integration,
361
+ configProvider: spec.configProvider,
362
+ literalKey: spec.key,
363
+ format: buildFormatter(spec),
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Build executable providers from configuration. Duplicate ids keep the last
369
+ * spec; `disable` removes ids. An empty or missing list yields no providers.
370
+ */
371
+ export function buildProviders(options?: BalancesOptions): Provider[] {
372
+ const disabled = new Set(options?.disable ?? [])
373
+ const seen = new Set<string>()
374
+ const ordered: Provider[] = []
375
+ const specs = options?.providers ?? []
376
+ for (const spec of specs) {
377
+ if (!spec?.id || disabled.has(spec.id)) continue
378
+ if (seen.has(spec.id)) {
379
+ // Last spec wins; replace in place to preserve position.
380
+ const idx = ordered.findIndex((p) => p.id === spec.id)
381
+ ordered[idx] = toProvider(spec)
382
+ } else {
383
+ seen.add(spec.id)
384
+ ordered.push(toProvider(spec))
385
+ }
386
+ }
387
+ return ordered
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Fetching
392
+ // ---------------------------------------------------------------------------
393
+
394
+ export async function fetchProvider(p: Provider): Promise<FetchResult> {
395
+ const key = keyForProvider(p)
396
+ // Hidden rather than stale when unconfigured.
397
+ if (!key) return { value: "", stale: false, skipped: true }
398
+
399
+ const ctrl = new AbortController()
400
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
401
+ try {
402
+ const scheme = p.authScheme ?? "Bearer"
403
+ const res = await fetch(p.url, {
404
+ headers: { Authorization: `${scheme} ${key}` },
405
+ signal: ctrl.signal,
406
+ })
407
+ if (!res.ok) return { value: "", stale: true, skipped: false }
408
+ const value = p.format(await res.json())
409
+ return value ? { value, stale: false, skipped: false } : { value: "", stale: true, skipped: false }
410
+ } catch {
411
+ return { value: "", stale: true, skipped: false }
412
+ } finally {
413
+ clearTimeout(timer)
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Merge fresh results into state. Successful values replace; a failed refresh
419
+ * keeps the previous value marked stale; a skipped provider is removed.
420
+ */
421
+ export function collect(
422
+ state: Map<ProviderKey, BalanceState>,
423
+ providers: Provider[],
424
+ results: FetchResult[],
425
+ ): void {
426
+ providers.forEach((p, i) => {
427
+ const fresh = results[i]
428
+ if (!fresh || fresh.skipped) {
429
+ state.delete(p.id)
430
+ return
431
+ }
432
+ if (fresh.value) {
433
+ state.set(p.id, { value: fresh.value, stale: false })
434
+ return
435
+ }
436
+ const prev = state.get(p.id)
437
+ if (prev?.value) state.set(p.id, { value: prev.value, stale: true })
438
+ else state.delete(p.id)
439
+ })
440
+ }
441
+
442
+ export function renderRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): string[] {
443
+ const rows: string[] = []
444
+ for (const p of providers) {
445
+ const s = state.get(p.id)
446
+ if (!s?.value) continue
447
+ rows.push(`${s.stale ? "!" : "•"} ${p.label} ${s.value}`)
448
+ }
449
+ return rows
450
+ }
package/tui.tsx ADDED
@@ -0,0 +1,72 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { createSignal } from "solid-js"
4
+ import {
5
+ buildProviders,
6
+ collect,
7
+ DEFAULT_REFRESH_MINUTES,
8
+ fetchProvider,
9
+ loadOptionsFromConfig,
10
+ renderRows,
11
+ type BalanceState,
12
+ type BalancesOptions,
13
+ type Provider,
14
+ type ProviderKey,
15
+ } from "./providers"
16
+
17
+ /**
18
+ * Resolve options. OpenCode V2 beta passes plugin options to the server
19
+ * entrypoint but not to the TUI entrypoint, so fall back to reading our own
20
+ * options from opencode.jsonc when the context gives us nothing.
21
+ */
22
+ function resolveOptions(contextOptions: unknown): BalancesOptions {
23
+ const fromContext = (contextOptions ?? {}) as BalancesOptions
24
+ if (Array.isArray(fromContext.providers) && fromContext.providers.length > 0) return fromContext
25
+ const fromConfig = loadOptionsFromConfig()
26
+ return {
27
+ ...fromConfig,
28
+ ...fromContext,
29
+ providers: fromContext.providers ?? fromConfig.providers,
30
+ refreshMinutes: fromContext.refreshMinutes ?? fromConfig.refreshMinutes,
31
+ disable: fromContext.disable ?? fromConfig.disable,
32
+ }
33
+ }
34
+
35
+ export default Plugin.define({
36
+ id: "providers-balances.tui",
37
+ async setup(context) {
38
+ const options = resolveOptions(context.options)
39
+ const providers: Provider[] = buildProviders(options)
40
+ const refreshMs = Math.max(1, options.refreshMinutes ?? DEFAULT_REFRESH_MINUTES) * 60_000
41
+
42
+ const state = new Map<ProviderKey, BalanceState>()
43
+ const [rows, setRows] = createSignal("")
44
+
45
+ async function refresh() {
46
+ const results = await Promise.all(providers.map((p) => fetchProvider(p)))
47
+ collect(state, providers, results)
48
+ const list = renderRows(state, providers)
49
+ setRows(list.length ? `\n${list.join("\n")}` : "")
50
+ }
51
+
52
+ // Fetch before registering so the first render already has values.
53
+ await refresh()
54
+
55
+ const unregister = context.ui.slot({
56
+ append: "sidebar.content",
57
+ render: () => (
58
+ <text>
59
+ <b>Balances</b>
60
+ {rows()}
61
+ </text>
62
+ ),
63
+ })
64
+
65
+ const timer = setInterval(() => void refresh(), refreshMs)
66
+
67
+ return () => {
68
+ clearInterval(timer)
69
+ unregister()
70
+ }
71
+ },
72
+ })