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 +21 -0
- package/README.md +58 -0
- package/README.pt-BR.md +54 -0
- package/bin/openrouter-costs.mjs +449 -0
- package/package.json +37 -0
- package/src/plugin/openrouter-cost.tsx +159 -0
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
|
package/README.pt-BR.md
ADDED
|
@@ -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
|
+

|
|
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
|
+
}
|