@lijinzhao8/opencode-usage 1.0.0 → 1.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/cli/index.js ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * opencode-usage — Local Web Admin UI
5
+ *
6
+ * Starts a local web server for managing pricing configuration.
7
+ * Usage: npx @lijinzhao8/opencode-usage admin
8
+ * or: node cli/index.js
9
+ */
10
+
11
+ import { createServer } from "node:http";
12
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const PROJECT_ROOT = join(__dirname, "..");
19
+ const CONFIG_PATH = join(PROJECT_ROOT, "config.json");
20
+ const PORT = parseInt(process.env.PORT || "3456", 10);
21
+
22
+ // ── Config Read/Write ───────────────────────────────────────
23
+
24
+ function readConfig() {
25
+ try {
26
+ if (existsSync(CONFIG_PATH)) {
27
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
28
+ }
29
+ } catch (e) {
30
+ console.error("Failed to read config:", e.message);
31
+ }
32
+ return { groups: [], providers: [], currency: "$", cost_decimals: 4, show_per_message: true, show_session_totals: true };
33
+ }
34
+
35
+ function writeConfig(config) {
36
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
37
+ }
38
+
39
+ // ── Admin HTML ──────────────────────────────────────────────
40
+
41
+ function getAdminHTML() {
42
+ return `<!DOCTYPE html>
43
+ <html lang="zh-CN">
44
+ <head>
45
+ <meta charset="UTF-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
47
+ <title>opencode-usage 管理界面</title>
48
+ <style>
49
+ * { box-sizing: border-box; margin: 0; padding: 0; }
50
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e4e4e7; line-height: 1.6; }
51
+ .header { background: #1a1b26; border-bottom: 1px solid #2a2b3d; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
52
+ .header h1 { font-size: 20px; color: #7aa2f7; }
53
+ .header .badge { background: #3b5bdb; color: #fff; padding: 2px 10px; border-radius: 12px; font-size: 12px; }
54
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
55
+ .tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid #2a2b3d; }
56
+ .tab { padding: 10px 20px; cursor: pointer; color: #787c99; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; }
57
+ .tab:hover { color: #a9b1d6; }
58
+ .tab.active { color: #7aa2f7; border-bottom-color: #7aa2f7; }
59
+ .panel { display: none; }
60
+ .panel.active { display: block; }
61
+ .card { background: #1a1b26; border: 1px solid #2a2b3d; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
62
+ .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
63
+ .card-title { font-size: 16px; font-weight: 600; color: #c0caf5; display: flex; align-items: center; gap: 8px; }
64
+ .card-title .dot { width: 8px; height: 8px; border-radius: 50%; }
65
+ .card-title .dot.green { background: #9ece6a; }
66
+ .card-title .dot.gray { background: #565f89; }
67
+ .btn { padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; }
68
+ .btn-primary { background: #3b5bdb; color: #fff; }
69
+ .btn-primary:hover { background: #4c6ef5; }
70
+ .btn-danger { background: #e03131; color: #fff; }
71
+ .btn-danger:hover { background: #f03e3e; }
72
+ .btn-ghost { background: transparent; color: #787c99; border: 1px solid #2a2b3d; }
73
+ .btn-ghost:hover { color: #c0caf5; border-color: #3b5bdb; }
74
+ .btn-sm { padding: 4px 10px; font-size: 12px; }
75
+ .form-group { margin-bottom: 14px; }
76
+ .form-group label { display: block; font-size: 13px; color: #787c99; margin-bottom: 4px; }
77
+ .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 8px 12px; background: #24283b; border: 1px solid #2a2b3d; border-radius: 6px; color: #c0caf5; font-size: 14px; font-family: inherit; }
78
+ .form-group input:focus, .form-group textarea:focus { outline: none; border-color: #7aa2f7; }
79
+ .form-group textarea { min-height: 100px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; resize: vertical; }
80
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
81
+ .form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
82
+ .tag { display: inline-block; background: #2a2b3d; color: #787c99; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin: 2px; }
83
+ .tag.blue { background: #1e3a5f; color: #7aa2f7; }
84
+ .divider { height: 1px; background: #2a2b3d; margin: 16px 0; }
85
+ .empty { text-align: center; padding: 40px; color: #565f89; }
86
+ .save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #1a1b26; border-top: 1px solid #2a2b3d; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; z-index: 100; }
87
+ .save-bar .status { color: #9ece6a; font-size: 13px; opacity: 0; transition: opacity 0.3s; }
88
+ .save-bar .status.show { opacity: 1; }
89
+ .help { font-size: 12px; color: #565f89; margin-top: 4px; }
90
+ .rate-preview { background: #24283b; border: 1px solid #2a2b3d; border-radius: 6px; padding: 12px; font-family: 'SF Mono', monospace; font-size: 13px; color: #9ece6a; white-space: pre-wrap; margin-top: 8px; }
91
+ .inline-flex { display: flex; align-items: center; gap: 8px; }
92
+ .switch { position: relative; width: 36px; height: 20px; }
93
+ .switch input { opacity: 0; width: 0; height: 0; }
94
+ .switch .slider { position: absolute; cursor: pointer; inset: 0; background: #2a2b3d; border-radius: 20px; transition: 0.2s; }
95
+ .switch .slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 3px; bottom: 3px; background: #565f89; border-radius: 50%; transition: 0.2s; }
96
+ .switch input:checked + .slider { background: #3b5bdb; }
97
+ .switch input:checked + .slider:before { transform: translateX(16px); background: #fff; }
98
+ body { padding-bottom: 60px; }
99
+ </style>
100
+ </head>
101
+ <body>
102
+
103
+ <div class="header">
104
+ <h1>opencode-usage 管理界面</h1>
105
+ <span class="badge">v1.0.0</span>
106
+ </div>
107
+
108
+ <div class="container">
109
+ <div class="tabs">
110
+ <div class="tab active" data-tab="groups">中转共享组</div>
111
+ <div class="tab" data-tab="providers">Provider 配置</div>
112
+ <div class="tab" data-tab="general">通用设置</div>
113
+ </div>
114
+
115
+ <!-- ── 中转共享组 ────────────────────────────── -->
116
+ <div class="panel active" id="panel-groups">
117
+ <div id="groups-list"></div>
118
+ <button class="btn btn-primary" onclick="addGroup()">+ 添加中转共享组</button>
119
+ </div>
120
+
121
+ <!-- ── Provider 配置 ────────────────────────── -->
122
+ <div class="panel" id="panel-providers">
123
+ <div id="providers-list"></div>
124
+ <button class="btn btn-primary" onclick="addProvider()">+ 添加 Provider</button>
125
+ </div>
126
+
127
+ <!-- ── 通用设置 ────────────────────────────── -->
128
+ <div class="panel" id="panel-general">
129
+ <div class="card">
130
+ <div class="card-header">
131
+ <div class="card-title">显示设置</div>
132
+ </div>
133
+ <div class="form-row">
134
+ <div class="form-group">
135
+ <label>货币符号</label>
136
+ <input id="cfg-currency" type="text" placeholder="$">
137
+ </div>
138
+ <div class="form-group">
139
+ <label>费用小数位数</label>
140
+ <input id="cfg-decimals" type="number" min="0" max="8" placeholder="4">
141
+ </div>
142
+ </div>
143
+ <div class="form-row">
144
+ <div class="form-group">
145
+ <label>显示每条消息费用</label>
146
+ <label class="switch"><input type="checkbox" id="cfg-per-msg"><span class="slider"></span></label>
147
+ </div>
148
+ <div class="form-group">
149
+ <label>显示会话累计</label>
150
+ <label class="switch"><input type="checkbox" id="cfg-totals"><span class="slider"></span></label>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="save-bar">
158
+ <span class="status" id="save-status">✓ 已保存</span>
159
+ <button class="btn btn-primary" onclick="saveConfig()">保存所有更改</button>
160
+ </div>
161
+
162
+ <script>
163
+ let config = { groups: [], providers: [], currency: "$", cost_decimals: 4, show_per_message: true, show_session_totals: true };
164
+
165
+ // ── Tabs ──
166
+ document.querySelectorAll(".tab").forEach(tab => {
167
+ tab.addEventListener("click", () => {
168
+ document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
169
+ document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
170
+ tab.classList.add("active");
171
+ document.getElementById("panel-" + tab.dataset.tab).classList.add("active");
172
+ });
173
+ });
174
+
175
+ // ── Load Config ──
176
+ async function loadConfig() {
177
+ const res = await fetch("/api/config");
178
+ config = await res.json();
179
+ renderGroups();
180
+ renderProviders();
181
+ document.getElementById("cfg-currency").value = config.currency || "$";
182
+ document.getElementById("cfg-decimals").value = config.cost_decimals ?? 4;
183
+ document.getElementById("cfg-per-msg").checked = config.show_per_message !== false;
184
+ document.getElementById("cfg-totals").checked = config.show_session_totals !== false;
185
+ }
186
+
187
+ // ── Save Config ──
188
+ async function saveConfig() {
189
+ config.currency = document.getElementById("cfg-currency").value || "$";
190
+ config.cost_decimals = parseInt(document.getElementById("cfg-decimals").value) || 4;
191
+ config.show_per_message = document.getElementById("cfg-per-msg").checked;
192
+ config.show_session_totals = document.getElementById("cfg-totals").checked;
193
+ const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config) });
194
+ if (res.ok) {
195
+ const status = document.getElementById("save-status");
196
+ status.classList.add("show");
197
+ setTimeout(() => status.classList.remove("show"), 2000);
198
+ }
199
+ }
200
+
201
+ // ── Groups ──
202
+ function renderGroups() {
203
+ const container = document.getElementById("groups-list");
204
+ if (!config.groups.length) { container.innerHTML = '<div class="empty">暂无中转共享组</div>'; return; }
205
+ container.innerHTML = config.groups.map((g, i) => \`
206
+ <div class="card">
207
+ <div class="card-header">
208
+ <div class="card-title">
209
+ <span class="dot \${g.enabled ? 'green' : 'gray'}"></span>
210
+ \${g.name || '未命名组'}
211
+ <span class="tag blue">\${g.id}</span>
212
+ </div>
213
+ <div class="inline-flex">
214
+ <label class="switch"><input type="checkbox" \${g.enabled ? 'checked' : ''} onchange="config.groups[\${i}].enabled=this.checked"><span class="slider"></span></label>
215
+ <button class="btn btn-danger btn-sm" onclick="removeGroup(\${i})">删除</button>
216
+ </div>
217
+ </div>
218
+ <div class="form-row">
219
+ <div class="form-group">
220
+ <label>组 ID</label>
221
+ <input value="\${g.id}" onchange="config.groups[\${i}].id=this.value">
222
+ </div>
223
+ <div class="form-group">
224
+ <label>组名称</label>
225
+ <input value="\${g.name}" onchange="config.groups[\${i}].name=this.value">
226
+ </div>
227
+ </div>
228
+ <div class="form-group">
229
+ <label>所属 Provider ID(逗号分隔)</label>
230
+ <input value="\${(g.provider_ids||[]).join(', ')}" onchange="config.groups[\${i}].provider_ids=this.value.split(',').map(s=>s.trim()).filter(Boolean)" placeholder="api-proxy, xiaomi-token-plan">
231
+ </div>
232
+ <div class="form-group">
233
+ <label>初始余额($)</label>
234
+ <input type="number" step="0.01" value="\${g.initial_balance||0}" onchange="config.groups[\${i}].initial_balance=parseFloat(this.value)||0">
235
+ </div>
236
+ <div class="form-group">
237
+ <label>费率配置(rates_text)</label>
238
+ <div class="help">格式:model_name input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15 | * 匹配所有 | 支持 calls/call_value/multiplier/time_mult</div>
239
+ <textarea rows="8" onchange="config.groups[\${i}].rates_text=this.value">\${g.rates_text||''}</textarea>
240
+ </div>
241
+ </div>
242
+ \`).join("");
243
+ }
244
+
245
+ function addGroup() {
246
+ config.groups.push({ id: "grp_" + Date.now().toString(36), name: "新中转组", enabled: true, provider_ids: [], initial_balance: 0, rates_text: "* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15" });
247
+ renderGroups();
248
+ }
249
+
250
+ function removeGroup(i) { config.groups.splice(i, 1); renderGroups(); }
251
+
252
+ // ── Providers ──
253
+ function renderProviders() {
254
+ const container = document.getElementById("providers-list");
255
+ if (!config.providers.length) { container.innerHTML = '<div class="empty">暂无 Provider</div>'; return; }
256
+ container.innerHTML = config.providers.map((p, i) => \`
257
+ <div class="card">
258
+ <div class="card-header">
259
+ <div class="card-title">
260
+ <span class="dot \${p.enabled ? 'green' : 'gray'}"></span>
261
+ \${p.name || '未命名'}
262
+ <span class="tag blue">\${p.id}</span>
263
+ </div>
264
+ <div class="inline-flex">
265
+ <label class="switch"><input type="checkbox" \${p.enabled ? 'checked' : ''} onchange="config.providers[\${i}].enabled=this.checked"><span class="slider"></span></label>
266
+ <button class="btn btn-danger btn-sm" onclick="removeProvider(\${i})">删除</button>
267
+ </div>
268
+ </div>
269
+ <div class="form-row-3">
270
+ <div class="form-group">
271
+ <label>Provider ID</label>
272
+ <input value="\${p.id}" onchange="config.providers[\${i}].id=this.value">
273
+ </div>
274
+ <div class="form-group">
275
+ <label>显示名称</label>
276
+ <input value="\${p.name}" onchange="config.providers[\${i}].name=this.value">
277
+ </div>
278
+ <div class="form-group">
279
+ <label>所属中转组 ID</label>
280
+ <select onchange="config.providers[\${i}].group_id=this.value">
281
+ <option value="">(独立,不使用中转组)</option>
282
+ \${config.groups.map(g => \`<option value="\${g.id}" \${p.group_id===g.id?'selected':''}>\${g.name} (\${g.id})</option>\`).join("")}
283
+ </select>
284
+ </div>
285
+ </div>
286
+ <div class="form-group">
287
+ <label>模型费率覆盖(JSON)</label>
288
+ <div class="help">格式:{ "model-name": { "input": 0.15, "output": 0.60 } } — 覆盖中转组的默认费率</div>
289
+ <textarea rows="4" onchange="try{config.providers[\${i}].model_rates=JSON.parse(this.value)}catch(e){alert('JSON 格式错误: '+e.message)}">\${JSON.stringify(p.model_rates||{}, null, 2)}</textarea>
290
+ </div>
291
+ </div>
292
+ \`).join("");
293
+ }
294
+
295
+ function addProvider() {
296
+ config.providers.push({ id: "new_provider", name: "新 Provider", enabled: true, group_id: "", model_rates: {} });
297
+ renderProviders();
298
+ }
299
+
300
+ function removeProvider(i) { config.providers.splice(i, 1); renderProviders(); }
301
+
302
+ // ── Init ──
303
+ loadConfig();
304
+ </script>
305
+ </body>
306
+ </html>`;
307
+ }
308
+
309
+ // ── HTTP Server ─────────────────────────────────────────────
310
+
311
+ const server = createServer((req, res) => {
312
+ // CORS headers
313
+ res.setHeader("Access-Control-Allow-Origin", "*");
314
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
315
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
316
+
317
+ if (req.method === "OPTIONS") {
318
+ res.writeHead(204);
319
+ res.end();
320
+ return;
321
+ }
322
+
323
+ // GET / — Admin UI
324
+ if (req.method === "GET" && req.url === "/") {
325
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
326
+ res.end(getAdminHTML());
327
+ return;
328
+ }
329
+
330
+ // GET /api/config — Read config
331
+ if (req.method === "GET" && req.url === "/api/config") {
332
+ res.writeHead(200, { "Content-Type": "application/json" });
333
+ res.end(JSON.stringify(readConfig()));
334
+ return;
335
+ }
336
+
337
+ // POST /api/config — Write config
338
+ if (req.method === "POST" && req.url === "/api/config") {
339
+ let body = "";
340
+ req.on("data", (chunk) => (body += chunk));
341
+ req.on("end", () => {
342
+ try {
343
+ const config = JSON.parse(body);
344
+ writeConfig(config);
345
+ res.writeHead(200, { "Content-Type": "application/json" });
346
+ res.end(JSON.stringify({ ok: true }));
347
+ } catch (e) {
348
+ res.writeHead(400, { "Content-Type": "application/json" });
349
+ res.end(JSON.stringify({ error: e.message }));
350
+ }
351
+ });
352
+ return;
353
+ }
354
+
355
+ // 404
356
+ res.writeHead(404);
357
+ res.end("Not Found");
358
+ });
359
+
360
+ server.listen(PORT, () => {
361
+ console.log(`\n opencode-usage 管理界面已启动\n`);
362
+ console.log(` 地址: http://localhost:${PORT}`);
363
+ console.log(` 配置: ${CONFIG_PATH}\n`);
364
+ console.log(` 按 Ctrl+C 停止\n`);
365
+ });
package/config.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "currency": "$",
3
+ "cost_decimals": 4,
4
+ "show_per_message": true,
5
+ "show_session_totals": true,
6
+ "groups": [
7
+ {
8
+ "id": "grp_api_proxy",
9
+ "name": "API-Proxy",
10
+ "enabled": true,
11
+ "provider_ids": ["api-proxy"],
12
+ "initial_balance": 0,
13
+ "rates_text": "# 按 token 计费(美元/百万token)\n* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15\n\n# 按次计费示例\ngpt-4 calls=1 call_value=0.10\n\n# 带倍率\ndeepseek-chat input=0.15 output=0.60 multiplier=1.5\n\n# 带时间段倍率(北京时间,支持跨天)\nclaude-3 input=3.00 output=15.00 time_mult=22:00-02:00=1.3;08:00-20:00=1.0"
14
+ },
15
+ {
16
+ "id": "grp_xiaomi",
17
+ "name": "Xiaomi Token Plan",
18
+ "enabled": true,
19
+ "provider_ids": ["xiaomi-token-plan"],
20
+ "initial_balance": 0,
21
+ "rates_text": "* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15"
22
+ }
23
+ ],
24
+ "providers": [
25
+ {
26
+ "id": "api-proxy",
27
+ "name": "API-Proxy",
28
+ "enabled": true,
29
+ "group_id": "grp_api_proxy",
30
+ "model_rates": {}
31
+ },
32
+ {
33
+ "id": "xiaomi-token-plan",
34
+ "name": "Xiaomi Token Plan",
35
+ "enabled": true,
36
+ "group_id": "grp_xiaomi",
37
+ "model_rates": {}
38
+ },
39
+ {
40
+ "id": "penguin-lm-gpt",
41
+ "name": "Penguin LM GPT",
42
+ "enabled": true,
43
+ "group_id": "",
44
+ "model_rates": {}
45
+ }
46
+ ]
47
+ }
package/dist/index.js CHANGED
@@ -1,46 +1,23 @@
1
1
  // src/pricing.ts
2
- var DEFAULT_GROUP_RATES = `* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15`;
3
- var DEFAULT_GROUPS = [
4
- {
5
- id: "grp_api_proxy",
6
- name: "API-Proxy",
7
- enabled: true,
8
- provider_ids: ["api-proxy"],
9
- initial_balance: 0,
10
- rates_text: DEFAULT_GROUP_RATES
11
- },
12
- {
13
- id: "grp_xiaomi",
14
- name: "Xiaomi Token Plan",
15
- enabled: true,
16
- provider_ids: ["xiaomi-token-plan"],
17
- initial_balance: 0,
18
- rates_text: DEFAULT_GROUP_RATES
19
- }
20
- ];
21
- var DEFAULT_PROVIDERS = [
22
- {
23
- id: "api-proxy",
24
- name: "API-Proxy",
25
- enabled: true,
26
- group_id: "grp_api_proxy",
27
- model_rates: {}
28
- },
29
- {
30
- id: "xiaomi-token-plan",
31
- name: "Xiaomi Token Plan",
32
- enabled: true,
33
- group_id: "grp_xiaomi",
34
- model_rates: {}
35
- },
36
- {
37
- id: "penguin-lm-gpt",
38
- name: "Penguin LM GPT",
39
- enabled: true,
40
- group_id: "",
41
- model_rates: {}
42
- }
43
- ];
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ var __filename2 = fileURLToPath(import.meta.url);
6
+ var __dirname2 = dirname(__filename2);
7
+ var PROJECT_ROOT = join(__dirname2, "..");
8
+ var CONFIG_PATH = join(PROJECT_ROOT, "config.json");
9
+ function loadConfigFromFile() {
10
+ try {
11
+ if (existsSync(CONFIG_PATH)) {
12
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
13
+ return JSON.parse(raw);
14
+ }
15
+ } catch {}
16
+ return null;
17
+ }
18
+ function getConfig() {
19
+ return loadConfigFromFile() || {};
20
+ }
44
21
  var ALLOWANCE_SCALE = 1e6;
45
22
  function parseRateLine(line) {
46
23
  const trimmed = line.trim();
@@ -183,8 +160,14 @@ function parseUsageFromEvent(event) {
183
160
  return { input, output, cache_hit, cache_create };
184
161
  }
185
162
  async function opencodeUsagePlugin(ctx, options) {
186
- const groups = options?.groups ?? DEFAULT_GROUPS;
187
- const providers = options?.providers ?? DEFAULT_PROVIDERS;
163
+ function loadConfig() {
164
+ const fileConfig = getConfig();
165
+ return {
166
+ groups: options?.groups ?? fileConfig.groups ?? [],
167
+ providers: options?.providers ?? fileConfig.providers ?? []
168
+ };
169
+ }
170
+ let { groups, providers } = loadConfig();
188
171
  let currentSessionId = null;
189
172
  let sessionUsage = {
190
173
  total_input: 0,
@@ -230,6 +213,9 @@ async function opencodeUsagePlugin(ctx, options) {
230
213
  }
231
214
  }
232
215
  if (type === "message.updated" || type === "message.completed" || type === "message.finished") {
216
+ const fresh = loadConfig();
217
+ groups = fresh.groups;
218
+ providers = fresh.providers;
233
219
  const usage = parseUsageFromEvent(event);
234
220
  if (!usage)
235
221
  return;
package/dist/pricing.d.ts CHANGED
@@ -1,32 +1,13 @@
1
1
  /**
2
2
  * Pricing configuration for opencode-usage plugin.
3
3
  *
4
- * Mirrors the API-Proxy's rates_text format:
5
- * model_name input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15
6
- *
7
- * Each line is: <model_pattern> <key>=<value> ...
8
- *
9
- * Supported keys:
10
- * input - cost per million input tokens (after cache subtracted)
11
- * output - cost per million output tokens
12
- * cache_hit - cost per million cache hit tokens
13
- * cache_create - cost per million cache creation tokens
14
- * calls - cost per call (overrides per-token billing)
15
- * call_value - dollar value per call
16
- * multiplier - global cost multiplier (default: 1)
17
- * time_mult - time-based multipliers (Beijing time)
18
- * format: "HH:MM-HH:MM=mult;HH:MM-HH:MM=mult"
19
- * supports crossing midnight (e.g. 22:00-02:00)
20
- *
21
- * The "*" model pattern matches any model not explicitly listed.
4
+ * Loads config from config.json (user-editable via web UI).
5
+ * Falls back to defaults if config.json not found.
22
6
  */
23
- import type { RelayGroup, ProviderConfig, RateConfig, TokenUsage } from "./types";
24
- /** Default per-token rate for relay groups */
7
+ import type { RelayGroup, ProviderConfig, RateConfig, TokenUsage, UsagePluginOptions } from "./types";
8
+ export declare function getConfig(): UsagePluginOptions;
25
9
  export declare const DEFAULT_GROUP_RATES = "* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15";
26
- /** Default per-token rate for standalone providers */
27
10
  export declare const DEFAULT_PROVIDER_RATES = "* input=1.00 output=4.00 cache_hit=0.15 cache_create=1.00";
28
- export declare const DEFAULT_GROUPS: RelayGroup[];
29
- export declare const DEFAULT_PROVIDERS: ProviderConfig[];
30
11
  /**
31
12
  * Parse a single rate line into a RateConfig object.
32
13
  * Format: "model_name key=value key=value ..."
@@ -44,28 +25,6 @@ export declare function parseRates(ratesText: string): Record<string, RateConfig
44
25
  * Priority: exact match → wildcard "*" → null
45
26
  */
46
27
  export declare function findRate(rates: Record<string, RateConfig>, model: string): RateConfig | null;
47
- /**
48
- * Compute the time-based multiplier for the current moment (Beijing time).
49
- * Supports ranges that cross midnight, e.g. "22:00-02:00=1.5"
50
- */
51
28
  export declare function computeTimeMultiplier(timeMultStr: string): number;
52
- /**
53
- * Calculate the cost of a request in dollars.
54
- *
55
- * Formula:
56
- * normalInput = max(0, input - cacheHit - cacheCreate)
57
- * cost = (normalInput / 1M * rate.input
58
- * + output / 1M * rate.output
59
- * + cacheHit / 1M * rate.cache_hit
60
- * + cacheCreate / 1M * rate.cache_create)
61
- * * multiplier * timeMultiplier
62
- *
63
- * For per-call billing:
64
- * cost = calls * call_value
65
- */
66
29
  export declare function calculateCost(usage: TokenUsage, rate: RateConfig): number;
67
- /**
68
- * Calculate cost for a specific provider + model combination.
69
- * Looks up rates from the group's rates_text, then applies provider model_rates overrides.
70
- */
71
30
  export declare function calculateModelCost(usage: TokenUsage, providerId: string, modelId: string, groups: RelayGroup[], providers: ProviderConfig[]): number;
package/dist/tui.js CHANGED
@@ -2,48 +2,25 @@ import { createRequire } from "node:module";
2
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
3
 
4
4
  // src/pricing.ts
5
- var DEFAULT_GROUP_RATES = `* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15`;
6
- var DEFAULT_GROUPS = [
7
- {
8
- id: "grp_api_proxy",
9
- name: "API-Proxy",
10
- enabled: true,
11
- provider_ids: ["api-proxy"],
12
- initial_balance: 0,
13
- rates_text: DEFAULT_GROUP_RATES
14
- },
15
- {
16
- id: "grp_xiaomi",
17
- name: "Xiaomi Token Plan",
18
- enabled: true,
19
- provider_ids: ["xiaomi-token-plan"],
20
- initial_balance: 0,
21
- rates_text: DEFAULT_GROUP_RATES
22
- }
23
- ];
24
- var DEFAULT_PROVIDERS = [
25
- {
26
- id: "api-proxy",
27
- name: "API-Proxy",
28
- enabled: true,
29
- group_id: "grp_api_proxy",
30
- model_rates: {}
31
- },
32
- {
33
- id: "xiaomi-token-plan",
34
- name: "Xiaomi Token Plan",
35
- enabled: true,
36
- group_id: "grp_xiaomi",
37
- model_rates: {}
38
- },
39
- {
40
- id: "penguin-lm-gpt",
41
- name: "Penguin LM GPT",
42
- enabled: true,
43
- group_id: "",
44
- model_rates: {}
45
- }
46
- ];
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ var __filename2 = fileURLToPath(import.meta.url);
9
+ var __dirname2 = dirname(__filename2);
10
+ var PROJECT_ROOT = join(__dirname2, "..");
11
+ var CONFIG_PATH = join(PROJECT_ROOT, "config.json");
12
+ function loadConfigFromFile() {
13
+ try {
14
+ if (existsSync(CONFIG_PATH)) {
15
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ } catch {}
19
+ return null;
20
+ }
21
+ function getConfig() {
22
+ return loadConfigFromFile() || {};
23
+ }
47
24
 
48
25
  // src/tui.ts
49
26
  function element(tag, props = {}, children = []) {
@@ -88,12 +65,13 @@ var tui_default = {
88
65
  id: "opencode-usage:tui",
89
66
  tui: async (api, options, meta) => {
90
67
  const t = api.theme.current;
91
- const currency = options?.currency ?? "$";
92
- const costDecimals = options?.cost_decimals ?? 4;
93
- const showPerMsg = options?.show_per_message !== false;
94
- const showTotals = options?.show_session_totals !== false;
95
- const groups = options?.groups ?? DEFAULT_GROUPS;
96
- const providers = options?.providers ?? DEFAULT_PROVIDERS;
68
+ const fileConfig = getConfig();
69
+ const currency = options?.currency ?? fileConfig.currency ?? "$";
70
+ const costDecimals = options?.cost_decimals ?? fileConfig.cost_decimals ?? 4;
71
+ const showPerMsg = options?.show_per_message ?? fileConfig.show_per_message ?? true;
72
+ const showTotals = options?.show_session_totals ?? fileConfig.show_session_totals ?? true;
73
+ const groups = options?.groups ?? fileConfig.groups ?? [];
74
+ const providers = options?.providers ?? fileConfig.providers ?? [];
97
75
  function getUsageState() {
98
76
  try {
99
77
  const state = globalThis.__opencode_usage_state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lijinzhao8/opencode-usage",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "OpenCode plugin that displays real-time API usage and cost tracking, replicating API-Proxy's pricing logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,13 @@
15
15
  "types": "./dist/tui.d.ts"
16
16
  }
17
17
  },
18
+ "bin": {
19
+ "opencode-usage": "./cli/index.js"
20
+ },
18
21
  "files": [
19
- "dist"
22
+ "dist",
23
+ "cli",
24
+ "config.json"
20
25
  ],
21
26
  "scripts": {
22
27
  "clean": "rm -rf dist",