@lijinzhao8/opencode-usage 1.0.0 → 1.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/cli/index.js +365 -0
- package/config.json +47 -0
- package/dist/index.js +30 -44
- package/dist/pricing.d.ts +4 -45
- package/dist/tui.js +26 -48
- package/package.json +7 -2
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: #f0f4f8; color: #1a202c; line-height: 1.6; }
|
|
51
|
+
.header { background: #fff; border-bottom: 1px solid #d1dbe8; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
|
52
|
+
.header h1 { font-size: 20px; color: #2563eb; }
|
|
53
|
+
.header .badge { background: #2563eb; 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 #d1dbe8; }
|
|
56
|
+
.tab { padding: 10px 20px; cursor: pointer; color: #6b7a8d; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; }
|
|
57
|
+
.tab:hover { color: #2563eb; }
|
|
58
|
+
.tab.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
|
|
59
|
+
.panel { display: none; }
|
|
60
|
+
.panel.active { display: block; }
|
|
61
|
+
.card { background: #fff; border: 1px solid #d1dbe8; border-radius: 10px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
|
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: #1a202c; display: flex; align-items: center; gap: 8px; }
|
|
64
|
+
.card-title .dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
65
|
+
.card-title .dot.green { background: #22c55e; }
|
|
66
|
+
.card-title .dot.gray { background: #9ca3af; }
|
|
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: #2563eb; color: #fff; }
|
|
69
|
+
.btn-primary:hover { background: #1d4ed8; }
|
|
70
|
+
.btn-danger { background: #ef4444; color: #fff; }
|
|
71
|
+
.btn-danger:hover { background: #dc2626; }
|
|
72
|
+
.btn-ghost { background: transparent; color: #6b7a8d; border: 1px solid #d1dbe8; }
|
|
73
|
+
.btn-ghost:hover { color: #2563eb; border-color: #2563eb; }
|
|
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: #6b7a8d; margin-bottom: 4px; }
|
|
77
|
+
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 8px 12px; background: #f7f9fc; border: 1px solid #d1dbe8; border-radius: 6px; color: #1a202c; font-size: 14px; font-family: inherit; transition: border-color 0.15s; }
|
|
78
|
+
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
|
|
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: #e8eef5; color: #4b6584; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin: 2px; }
|
|
83
|
+
.tag.blue { background: #dbeafe; color: #2563eb; }
|
|
84
|
+
.divider { height: 1px; background: #d1dbe8; margin: 16px 0; }
|
|
85
|
+
.empty { text-align: center; padding: 40px; color: #9ca3af; }
|
|
86
|
+
.save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid #d1dbe8; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; z-index: 100; box-shadow: 0 -1px 3px rgba(0,0,0,0.04); }
|
|
87
|
+
.save-bar .status { color: #22c55e; font-size: 13px; opacity: 0; transition: opacity 0.3s; }
|
|
88
|
+
.save-bar .status.show { opacity: 1; }
|
|
89
|
+
.help { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
|
90
|
+
.rate-preview { background: #f7f9fc; border: 1px solid #d1dbe8; border-radius: 6px; padding: 12px; font-family: 'SF Mono', monospace; font-size: 13px; color: #22c55e; 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: #d1dbe8; border-radius: 20px; transition: 0.2s; }
|
|
95
|
+
.switch .slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: 0.2s; box-shadow: 0 1px 2px rgba(0,0,0,0.15); }
|
|
96
|
+
.switch input:checked + .slider { background: #2563eb; }
|
|
97
|
+
.switch input:checked + .slider:before { transform: translateX(16px); }
|
|
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.1.1</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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
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.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|