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 +21 -0
- package/README.md +156 -2
- package/index.ts +7 -0
- package/package.json +49 -4
- package/providers.ts +450 -0
- package/tui.tsx +72 -0
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
|
-
#
|
|
1
|
+
# opencode-providers-balances
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-providers-balances)
|
|
4
|
+
[](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
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-providers-balances",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
})
|