opencode-openrouter-costs 0.1.0

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) 2026 Marcelo (mhenrique94)
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 ADDED
@@ -0,0 +1,58 @@
1
+ # opencode-openrouter-costs
2
+
3
+ [English](./README.md) | [Português (BR)](./README.pt-BR.md)
4
+
5
+ OpenCode TUI plugin — sidebar widget showing OpenRouter session cost and account balance.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ # From npm (recommended)
11
+ npx opencode-openrouter-costs
12
+
13
+ # From source (GitHub)
14
+ npx github:mhenrique94/opencode-openrouter-costs
15
+ ```
16
+
17
+ The installer asks for your preferred language (English or Portuguese) on first run.
18
+ All subsequent output — installer instructions and the widget itself — will use that language.
19
+
20
+ ## What it does
21
+
22
+ Adds a compact widget to the OpenCode sidebar (above "MCP Servers"):
23
+
24
+ ```
25
+ OpenRouter
26
+ Session: $0.0523
27
+ Balance: $4.21
28
+ ```
29
+
30
+ - **Session** — cost of the current session (resets on new session)
31
+ - **Balance** — remaining OpenRouter account credit
32
+
33
+ ## Requirements
34
+
35
+ - OpenCode >= 1.18.30
36
+ - OpenRouter provider configured (via `/models` or `OPENROUTER_API_KEY`)
37
+
38
+ ## Uninstall
39
+
40
+ ```sh
41
+ npx opencode-openrouter-costs --remove
42
+ ```
43
+
44
+ ## Language
45
+
46
+ The widget supports English and Portuguese (BR). The language is chosen at install time
47
+ and persisted in `~/.config/opencode/openrouter-cost.json`. To switch later, re-run the
48
+ installer or edit the config file directly:
49
+
50
+ ```json
51
+ {"lang": "pt"}
52
+ ```
53
+
54
+ Valid values: `"en"` or `"pt"`.
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,54 @@
1
+ # opencode-openrouter-costs
2
+
3
+ [English](./README.md) | [Português (BR)](./README.pt-BR.md)
4
+
5
+ Plugin TUI para OpenCode — widget no painel lateral mostrando gasto da sessão e saldo da conta OpenRouter.
6
+
7
+ ## Instalar
8
+
9
+ ```sh
10
+ # Do npm (recomendado)
11
+ npx opencode-openrouter-costs
12
+
13
+ # Do código-fonte (GitHub)
14
+ npx github:mhenrique94/opencode-openrouter-costs
15
+ ```
16
+
17
+ O instalador pergunta o idioma preferido (Inglês ou Português) na primeira execução.
18
+ Todo o output subsequente — instruções do instalador e o próprio widget — usa o idioma escolhido.
19
+
20
+ ## O que faz
21
+
22
+ Adiciona um widget compacto no sidebar do OpenCode (acima de "Servidor MCP"):
23
+
24
+ ![Widget OpenRouter em Português — Sessão e Saldo](img/widget-pt.png)
25
+
26
+ - **Sessão** — gasto da sessão atual (reseta ao criar nova sessão)
27
+ - **Saldo** — crédito restante da conta OpenRouter
28
+
29
+ ## Requisitos
30
+
31
+ - OpenCode >= 1.18.30
32
+ - Provider OpenRouter configurado (via `/models` ou `OPENROUTER_API_KEY`)
33
+
34
+ ## Desinstalar
35
+
36
+ ```sh
37
+ npx opencode-openrouter-costs --remove
38
+ ```
39
+
40
+ ## Idioma
41
+
42
+ O widget suporta Inglês e Português (BR). O idioma é escolhido no momento da instalação
43
+ e salvo em `~/.config/opencode/openrouter-cost.json`. Para trocar depois, execute o
44
+ instalador novamente ou edite o arquivo de config diretamente:
45
+
46
+ ```json
47
+ {"lang": "pt"}
48
+ ```
49
+
50
+ Valores válidos: `"en"` ou `"pt"`.
51
+
52
+ ## Licença
53
+
54
+ MIT
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, unlinkSync } from "fs"
4
+ import { join, dirname } from "path"
5
+ import { homedir } from "os"
6
+ import { createInterface } from "readline"
7
+ import { fileURLToPath } from "url"
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = dirname(__filename)
11
+
12
+ const CONFIG_DIR = join(homedir(), ".config", "opencode")
13
+ const PLUGINS_DIR = join(CONFIG_DIR, "plugins")
14
+ const PLUGIN_NAME = "openrouter-cost.tsx"
15
+ const PLUGIN_DEST = join(PLUGINS_DIR, PLUGIN_NAME)
16
+ const PLUGIN_REL = `./plugins/${PLUGIN_NAME}`
17
+ const TUI_JSON = join(CONFIG_DIR, "tui.json")
18
+ const PKG_JSON = join(CONFIG_DIR, "package.json")
19
+ const PLUGIN_SRC = join(__dirname, "..", "src", "plugin", PLUGIN_NAME)
20
+ const LANG_JSON = join(CONFIG_DIR, "openrouter-cost.json")
21
+
22
+ const DEPS = {
23
+ "@opentui/solid": "0.5.11",
24
+ "solid-js": "1.9.12",
25
+ }
26
+
27
+ const MESSAGES = {
28
+ en: {
29
+ banner: "OpenRouter Cost Plugin — installer for OpenCode\n",
30
+ steps: (plugin, pluginsDir, tuiJson, pkgJson) =>
31
+ [
32
+ "What will happen:",
33
+ ` 1. Copy ${plugin} → ${pluginsDir}/`,
34
+ ` 2. Add entry to ${tuiJson} (plugin)`,
35
+ ` 3. Ensure @opentui/solid + solid-js in ${pkgJson}`,
36
+ ].join("\n"),
37
+ requirements: "Requirements: OpenCode >= 1.18.30 with OpenRouter provider configured\n",
38
+ confirm: "Install? [y/N] ",
39
+ copying: "1. Copying plugin...",
40
+ copied: "Copied.",
41
+ identical: "Plugin already exists and is identical — skip.",
42
+ existsPrompt: " Plugin already exists; overwrite? [y/N] ",
43
+ existsYes: "Overwritten.",
44
+ existsSkip: "Skip.",
45
+ updatingTui: "2. Updating tui.json...",
46
+ addedTui: (plugin) => `Added ${plugin} to plugin array.`,
47
+ tuiExists: "Entry already exists — skip.",
48
+ addingDeps: "3. Ensuring dependencies in package.json...",
49
+ depsAdded: "Dependencies added.",
50
+ depsExists: "Dependencies already present — skip.",
51
+ skippedDeps: "Skipping dependencies (--no-deps).",
52
+ success: [
53
+ "",
54
+ "Plugin installed! Restart OpenCode and open a session.",
55
+ 'The widget appears in the sidebar above "MCP Servers".',
56
+ "",
57
+ 'If OpenRouter is not configured, the widget shows "not configured".',
58
+ "Configure via /models or set OPENROUTER_API_KEY.",
59
+ ].join("\n"),
60
+ dryRun: (from, to) => `[dry-run] Would copy ${from} → ${to}`,
61
+ bannerRemove: "OpenRouter Cost Plugin — Uninstaller\n",
62
+ confirmRemove: "Remove plugin? [y/N] ",
63
+ removingPlugin: "1. Removing plugin...",
64
+ removedPlugin: "Removed.",
65
+ pluginNotFound: "Plugin not found — skip.",
66
+ removingTui: "2. Removing from tui.json...",
67
+ removedTui: "Removed.",
68
+ tuiNotFound: "tui.json not found or no plugin array — skip.",
69
+ removingDeps: "3. Removing dependencies...",
70
+ removedDeps: "Dependencies removed.",
71
+ depsNotFound: "Dependencies not found — skip.",
72
+ successRemove: "Plugin removed. Restart OpenCode.",
73
+ cancelled: "Cancelled.",
74
+ },
75
+ pt: {
76
+ banner: "OpenRouter Cost Plugin — instalador para OpenCode\n",
77
+ steps: (plugin, pluginsDir, tuiJson, pkgJson) =>
78
+ [
79
+ "O que será feito:",
80
+ ` 1. Copiar ${plugin} → ${pluginsDir}/`,
81
+ ` 2. Adicionar entrada em ${tuiJson} (plugin)`,
82
+ ` 3. Garantir @opentui/solid + solid-js em ${pkgJson}`,
83
+ ].join("\n"),
84
+ requirements: "Requisitos: OpenCode >= 1.18.30 com provider OpenRouter configurado\n",
85
+ confirm: "Instalar? [y/N] ",
86
+ copying: "1. Copiando plugin...",
87
+ copied: "Copiado.",
88
+ identical: "Plugin já existe e é idêntico — skip.",
89
+ existsPrompt: " Plugin já existe; reescrever? [y/N] ",
90
+ existsYes: "Reescrito.",
91
+ existsSkip: "Skip.",
92
+ updatingTui: "2. Atualizando tui.json...",
93
+ addedTui: (plugin) => `Adicionado ${plugin} ao plugin array.`,
94
+ tuiExists: "Entrada já existe — skip.",
95
+ addingDeps: "3. Garantindo dependências em package.json...",
96
+ depsAdded: "Dependências adicionadas.",
97
+ depsExists: "Dependências já presentes — skip.",
98
+ skippedDeps: "Pulando dependências (--no-deps).",
99
+ success: [
100
+ "",
101
+ "Plugin instalado! Reinicie o OpenCode e abra uma sessão.",
102
+ 'O widget aparece no sidebar acima de "Servidor MCP".',
103
+ "",
104
+ 'Se o OpenRouter não estiver configurado, o widget mostra "não configurado".',
105
+ "Configure via /models ou setando OPENROUTER_API_KEY.",
106
+ ].join("\n"),
107
+ dryRun: (from, to) => `[dry-run] Copiaria ${from} → ${to}`,
108
+ bannerRemove: "OpenRouter Cost Plugin — Desinstalador\n",
109
+ confirmRemove: "Remover plugin? [y/N] ",
110
+ removingPlugin: "1. Removendo plugin...",
111
+ removedPlugin: "Removido.",
112
+ pluginNotFound: "Plugin não encontrado — skip.",
113
+ removingTui: "2. Removendo de tui.json...",
114
+ removedTui: "Removido.",
115
+ tuiNotFound: "tui.json não encontrado ou sem plugin array — skip.",
116
+ removingDeps: "3. Removendo dependências...",
117
+ removedDeps: "Dependências removidas.",
118
+ depsNotFound: "Dependências não encontradas — skip.",
119
+ successRemove: "Plugin removido. Reinicie o OpenCode.",
120
+ cancelled: "Cancelado.",
121
+ },
122
+ }
123
+
124
+ function timestamp() {
125
+ const d = new Date()
126
+ const pad = (n) => String(n).padStart(2, "0")
127
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
128
+ }
129
+
130
+ function backup(filePath) {
131
+ if (!existsSync(filePath)) return
132
+ const bak = `${filePath}.bak-${timestamp()}`
133
+ copyFileSync(filePath, bak)
134
+ }
135
+
136
+ function readJson(filePath) {
137
+ if (!existsSync(filePath)) return null
138
+ try {
139
+ return JSON.parse(readFileSync(filePath, "utf-8"))
140
+ } catch {
141
+ return null
142
+ }
143
+ }
144
+
145
+ function writeJson(filePath, data) {
146
+ backup(filePath)
147
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8")
148
+ }
149
+
150
+ function confirm(rl, message) {
151
+ return new Promise((resolve) => {
152
+ rl.question(message, (answer) => {
153
+ resolve(/^(y|yes)$/i.test(answer.trim()))
154
+ })
155
+ })
156
+ }
157
+
158
+ function readLangFromConfig() {
159
+ const data = readJson(LANG_JSON)
160
+ if (data?.lang === "pt") return "pt"
161
+ if (data?.lang === "en") return "en"
162
+ return null
163
+ }
164
+
165
+ function writeLangConfig(lang) {
166
+ const data = { lang }
167
+ if (existsSync(LANG_JSON)) {
168
+ backup(LANG_JSON)
169
+ }
170
+ writeFileSync(LANG_JSON, JSON.stringify(data, null, 2) + "\n", "utf-8")
171
+ }
172
+
173
+ function deleteLangConfig() {
174
+ if (existsSync(LANG_JSON)) {
175
+ unlinkSync(LANG_JSON)
176
+ }
177
+ }
178
+
179
+ async function promptLang(rl) {
180
+ console.log("OpenRouter Cost Plugin\n")
181
+ console.log("Choose your language / Escolha o idioma:\n")
182
+ console.log(" [1] English ← Enter = default")
183
+ console.log(" [2] Português (BR)\n")
184
+ const answer = await new Promise((resolve) => {
185
+ rl.question("> ", (a) => resolve(a.trim()))
186
+ })
187
+ if (answer === "2") return "pt"
188
+ if (answer === "1" || answer === "") return "en"
189
+ console.log("Invalid choice — defaulting to English.\n")
190
+ return "en"
191
+ }
192
+
193
+ async function install(opts) {
194
+ if (!existsSync(CONFIG_DIR)) {
195
+ console.error("Execute `opencode` pelo menos uma vez primeiro.")
196
+ process.exit(1)
197
+ }
198
+
199
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
200
+
201
+ // 1. Define language
202
+ let lang
203
+ if (opts.lang) {
204
+ if (opts.lang !== "en" && opts.lang !== "pt") {
205
+ console.warn(`Invalid --lang "${opts.lang}". Defaulting to English.\n`)
206
+ lang = "en"
207
+ } else {
208
+ lang = opts.lang
209
+ }
210
+ } else if (opts.yes) {
211
+ lang = readLangFromConfig() || "en"
212
+ } else {
213
+ lang = await promptLang(rl)
214
+ }
215
+
216
+ const msg = MESSAGES[lang]
217
+
218
+ // 2. Verify config dir
219
+ if (!existsSync(CONFIG_DIR)) {
220
+ console.error("Execute `opencode` pelo menos uma vez primeiro.")
221
+ process.exit(1)
222
+ }
223
+
224
+ // 3. Banner
225
+ console.log(msg.banner)
226
+ console.log(msg.steps(PLUGIN_NAME, PLUGINS_DIR, TUI_JSON, PKG_JSON) + "\n")
227
+ console.log(msg.requirements)
228
+
229
+ if (!opts.yes) {
230
+ const ok = await confirm(rl, msg.confirm)
231
+ if (!ok) {
232
+ console.log(msg.cancelled)
233
+ rl.close()
234
+ return
235
+ }
236
+ }
237
+
238
+ // Step 1: Copy plugin
239
+ console.log(`\n${msg.copying}`)
240
+ if (!opts.dryRun) {
241
+ if (!existsSync(PLUGINS_DIR)) {
242
+ mkdirSync(PLUGINS_DIR, { recursive: true })
243
+ }
244
+ if (existsSync(PLUGIN_DEST)) {
245
+ const existing = readFileSync(PLUGIN_DEST, "utf-8")
246
+ const source = readFileSync(PLUGIN_SRC, "utf-8")
247
+ if (existing === source) {
248
+ console.log(msg.identical)
249
+ } else {
250
+ if (!opts.yes) {
251
+ const overwrite = await confirm(rl, msg.existsPrompt)
252
+ if (!overwrite) {
253
+ console.log(msg.existsSkip)
254
+ } else {
255
+ backup(PLUGIN_DEST)
256
+ copyFileSync(PLUGIN_SRC, PLUGIN_DEST)
257
+ console.log(msg.existsYes)
258
+ }
259
+ } else {
260
+ backup(PLUGIN_DEST)
261
+ copyFileSync(PLUGIN_SRC, PLUGIN_DEST)
262
+ console.log(msg.existsYes)
263
+ }
264
+ }
265
+ } else {
266
+ copyFileSync(PLUGIN_SRC, PLUGIN_DEST)
267
+ console.log(msg.copied)
268
+ }
269
+ } else {
270
+ console.log(msg.dryRun(PLUGIN_SRC, PLUGIN_DEST))
271
+ }
272
+
273
+ // Step 2: Update tui.json
274
+ console.log(`\n${msg.updatingTui}`)
275
+ const tuiData = readJson(TUI_JSON) || {}
276
+ const plugins = Array.isArray(tuiData.plugin) ? tuiData.plugin : []
277
+ if (!plugins.includes(PLUGIN_REL)) {
278
+ if (!opts.dryRun) {
279
+ plugins.push(PLUGIN_REL)
280
+ tuiData.plugin = plugins
281
+ writeJson(TUI_JSON, tuiData)
282
+ console.log(` ${msg.addedTui(PLUGIN_REL)}`)
283
+ } else {
284
+ console.log(` ${msg.dryRun(PLUGIN_REL, TUI_JSON)}`)
285
+ }
286
+ } else {
287
+ console.log(` ${msg.tuiExists}`)
288
+ }
289
+
290
+ // Step 3: Add deps to package.json
291
+ if (!opts.noDeps) {
292
+ console.log(`\n${msg.addingDeps}`)
293
+ const pkgData = readJson(PKG_JSON) || {}
294
+ if (!pkgData.dependencies) pkgData.dependencies = {}
295
+ let added = false
296
+ for (const [name, version] of Object.entries(DEPS)) {
297
+ if (!pkgData.dependencies[name]) {
298
+ if (!opts.dryRun) {
299
+ pkgData.dependencies[name] = version
300
+ added = true
301
+ } else {
302
+ console.log(` [dry-run] Would add ${name}@${version}`)
303
+ }
304
+ }
305
+ }
306
+ if (!opts.dryRun) {
307
+ if (added) {
308
+ writeJson(PKG_JSON, pkgData)
309
+ console.log(` ${msg.depsAdded}`)
310
+ } else {
311
+ console.log(` ${msg.depsExists}`)
312
+ }
313
+ }
314
+ } else {
315
+ console.log(`\n${msg.skippedDeps}`)
316
+ }
317
+
318
+ // Step 4: Write lang config
319
+ if (!opts.dryRun) {
320
+ writeLangConfig(lang)
321
+ }
322
+
323
+ rl.close()
324
+
325
+ console.log(msg.success)
326
+ }
327
+
328
+ async function uninstall(opts) {
329
+ if (!existsSync(CONFIG_DIR)) {
330
+ console.error("Execute `opencode` pelo menos uma vez primeiro.")
331
+ process.exit(1)
332
+ }
333
+
334
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
335
+
336
+ // 1. Read language from config; default en
337
+ const lang = readLangFromConfig() || "en"
338
+ const msg = MESSAGES[lang]
339
+
340
+ console.log(msg.bannerRemove)
341
+
342
+ if (!opts.yes) {
343
+ const ok = await confirm(rl, msg.confirmRemove)
344
+ if (!ok) {
345
+ console.log(msg.cancelled)
346
+ rl.close()
347
+ return
348
+ }
349
+ }
350
+
351
+ // Step 1: Remove plugin file
352
+ console.log(`\n${msg.removingPlugin}`)
353
+ if (existsSync(PLUGIN_DEST)) {
354
+ if (!opts.dryRun) {
355
+ unlinkSync(PLUGIN_DEST)
356
+ console.log(` ${msg.removedPlugin}`)
357
+ } else {
358
+ console.log(` [dry-run] Would remove ${PLUGIN_DEST}`)
359
+ }
360
+ } else {
361
+ console.log(` ${msg.pluginNotFound}`)
362
+ }
363
+
364
+ // Step 2: Remove from tui.json
365
+ console.log(`\n${msg.removingTui}`)
366
+ const tuiData = readJson(TUI_JSON)
367
+ if (tuiData && Array.isArray(tuiData.plugin)) {
368
+ const idx = tuiData.plugin.indexOf(PLUGIN_REL)
369
+ if (idx !== -1) {
370
+ if (!opts.dryRun) {
371
+ backup(TUI_JSON)
372
+ tuiData.plugin.splice(idx, 1)
373
+ if (tuiData.plugin.length === 0) {
374
+ delete tuiData.plugin
375
+ }
376
+ writeJson(TUI_JSON, tuiData)
377
+ console.log(` ${msg.removedTui}`)
378
+ } else {
379
+ console.log(` [dry-run] Would remove ${PLUGIN_REL} from plugin array.`)
380
+ }
381
+ } else {
382
+ console.log(` ${msg.tuiNotFound}`)
383
+ }
384
+ } else {
385
+ console.log(` ${msg.tuiNotFound}`)
386
+ }
387
+
388
+ // Step 3: Remove deps from package.json
389
+ console.log(`\n${msg.removingDeps}`)
390
+ const pkgData = readJson(PKG_JSON)
391
+ if (pkgData && pkgData.dependencies) {
392
+ let removed = false
393
+ for (const name of Object.keys(DEPS)) {
394
+ if (pkgData.dependencies[name]) {
395
+ if (!opts.dryRun) {
396
+ delete pkgData.dependencies[name]
397
+ removed = true
398
+ } else {
399
+ console.log(` [dry-run] Would remove ${name}`)
400
+ }
401
+ }
402
+ }
403
+ if (!opts.dryRun && removed) {
404
+ writeJson(PKG_JSON, pkgData)
405
+ console.log(` ${msg.removedDeps}`)
406
+ } else if (!removed) {
407
+ console.log(` ${msg.depsNotFound}`)
408
+ }
409
+ } else {
410
+ console.log(` ${msg.depsNotFound}`)
411
+ }
412
+
413
+ // Step 4: Remove lang config
414
+ if (!opts.dryRun) {
415
+ deleteLangConfig()
416
+ }
417
+
418
+ rl.close()
419
+
420
+ console.log(`\n${msg.successRemove}`)
421
+ }
422
+
423
+ // --- CLI ---
424
+ const args = process.argv.slice(2)
425
+ const flags = {
426
+ yes: args.includes("--yes") || args.includes("-y"),
427
+ dryRun: args.includes("--dry-run"),
428
+ noDeps: args.includes("--no-deps"),
429
+ remove: args.includes("--remove"),
430
+ }
431
+
432
+ // Parse --lang en|pt
433
+ let langFlag = null
434
+ const langIdx = args.indexOf("--lang")
435
+ if (langIdx !== -1 && args[langIdx + 1]) {
436
+ langFlag = args[langIdx + 1]
437
+ }
438
+
439
+ if (flags.remove) {
440
+ uninstall(flags).catch((err) => {
441
+ console.error(err)
442
+ process.exit(1)
443
+ })
444
+ } else {
445
+ install({ ...flags, lang: langFlag }).catch((err) => {
446
+ console.error(err)
447
+ process.exit(1)
448
+ })
449
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "opencode-openrouter-costs",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode TUI plugin — sidebar widget showing OpenRouter session cost and account balance",
5
+ "license": "MIT",
6
+ "author": "Marcelo (mhenrique94)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/mhenrique94/opencode-openrouter-costs.git"
10
+ },
11
+ "bin": {
12
+ "opencode-openrouter-costs": "./bin/openrouter-costs.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=18",
16
+ "opencode": ">=1.18.30"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/plugin/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "tui",
28
+ "openrouter",
29
+ "cost",
30
+ "balance",
31
+ "widget",
32
+ "sidebar"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test test/**/*.test.mjs"
36
+ }
37
+ }
@@ -0,0 +1,159 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
+ import { createSignal, onCleanup } from "solid-js"
4
+ import { readFile } from "fs/promises"
5
+ import { homedir } from "os"
6
+ import { join } from "path"
7
+
8
+ type Lang = "en" | "pt"
9
+
10
+ const LABELS = {
11
+ en: {
12
+ session: "Session",
13
+ balance: "Balance",
14
+ notConfigured: "not configured",
15
+ loading: "…",
16
+ },
17
+ pt: {
18
+ session: "Sessão",
19
+ balance: "Saldo",
20
+ notConfigured: "não configurado",
21
+ loading: "…",
22
+ },
23
+ } as const
24
+
25
+ function t(lang: Lang, key: keyof (typeof LABELS)["en"]): string {
26
+ return LABELS[lang]?.[key] ?? LABELS.en[key]
27
+ }
28
+
29
+ const money = new Intl.NumberFormat("en-US", {
30
+ style: "currency",
31
+ currency: "USD",
32
+ })
33
+
34
+ const DEFAULT_TTL_MS = 60_000
35
+
36
+ async function readKey(): Promise<string | null> {
37
+ const envKey = process.env.OPENROUTER_API_KEY
38
+ if (envKey) return envKey
39
+
40
+ try {
41
+ const authPath = join(homedir(), ".local", "share", "opencode", "auth.json")
42
+ const raw = await readFile(authPath, "utf-8")
43
+ const auth = JSON.parse(raw)
44
+ return auth?.openrouter?.key ?? null
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ async function readLang(): Promise<Lang> {
51
+ try {
52
+ const configPath = join(homedir(), ".config", "opencode", "openrouter-cost.json")
53
+ const raw = await readFile(configPath, "utf-8")
54
+ const config = JSON.parse(raw)
55
+ if (config?.lang === "pt") return "pt"
56
+ if (config?.lang === "en") return "en"
57
+ return "en"
58
+ } catch {
59
+ return "en"
60
+ }
61
+ }
62
+
63
+ function View(props: { api: TuiPluginApi; session_id: string; key: string | null; lang: Lang }) {
64
+ const theme = () => props.api.theme.current
65
+ const session = () => props.api.state.session.get(props.session_id)
66
+ const sessionCost = () => session()?.cost ?? 0
67
+
68
+ const [balance, setBalance] = createSignal<string | null>(null)
69
+ let lastFetch = 0
70
+ let fetching = false
71
+ let disposed = false
72
+
73
+ async function fetchBalance() {
74
+ if (!props.key || disposed || fetching) return
75
+ fetching = true
76
+ try {
77
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
78
+ headers: { Authorization: `Bearer ${props.key}` },
79
+ })
80
+ if (!res.ok) {
81
+ if (!disposed) setBalance(null)
82
+ return
83
+ }
84
+ const json = await res.json()
85
+ const total = json?.data?.total_credits ?? 0
86
+ const used = json?.data?.total_usage ?? 0
87
+ const remaining = Math.max(0, total - used)
88
+ if (!disposed) {
89
+ setBalance(money.format(remaining))
90
+ lastFetch = Date.now()
91
+ }
92
+ } catch {
93
+ if (!disposed) setBalance(null)
94
+ } finally {
95
+ fetching = false
96
+ }
97
+ }
98
+
99
+ function maybeRefresh() {
100
+ if (Date.now() - lastFetch >= DEFAULT_TTL_MS) {
101
+ fetchBalance()
102
+ }
103
+ }
104
+
105
+ fetchBalance()
106
+
107
+ const unsub = props.api.event.on("message.updated", () => {
108
+ maybeRefresh()
109
+ })
110
+
111
+ onCleanup(() => {
112
+ disposed = true
113
+ unsub()
114
+ })
115
+
116
+ if (!props.key) {
117
+ return (
118
+ <box>
119
+ <text fg={theme().text}>
120
+ <b>OpenRouter</b>
121
+ </text>
122
+ <text fg={theme().warning}>{t(props.lang, "notConfigured")}</text>
123
+ </box>
124
+ )
125
+ }
126
+
127
+ return (
128
+ <box>
129
+ <text fg={theme().text}>
130
+ <b>OpenRouter</b>
131
+ </text>
132
+ <text fg={theme().textMuted}>{t(props.lang, "session")}: {money.format(sessionCost())}</text>
133
+ <text fg={theme().textMuted}>
134
+ {t(props.lang, "balance")}: {balance() ?? t(props.lang, "loading")}
135
+ </text>
136
+ </box>
137
+ )
138
+ }
139
+
140
+ const tui: TuiPlugin = async (api) => {
141
+ const [key, lang] = await Promise.all([readKey(), readLang()])
142
+ if (!key) {
143
+ console.warn("OpenRouter Cost Plugin: no key found. Configure via /models or set OPENROUTER_API_KEY.")
144
+ }
145
+
146
+ api.slots.register({
147
+ order: 150,
148
+ slots: {
149
+ sidebar_content(_ctx, props) {
150
+ return <View api={api} session_id={props.session_id} key={key} lang={lang} />
151
+ },
152
+ },
153
+ })
154
+ }
155
+
156
+ export default {
157
+ id: "openrouter-cost",
158
+ tui,
159
+ }