dsh-opencode-routing 2.1.0 → 2.2.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/README.md +8 -0
- package/client.js +433 -240
- package/package.json +1 -1
- package/router.js +15 -1
- package/test.mjs +35 -0
package/README.md
CHANGED
|
@@ -62,6 +62,14 @@ npm i dsh-opencode-routing
|
|
|
62
62
|
|
|
63
63
|
「查看用量」按钮按账户弹窗展示三个窗口的百分比、重置时间与探测结果(只探测该账户,不切换模型)。
|
|
64
64
|
|
|
65
|
+
「编辑密钥」直接用 `GET /opencode-quota-router.json?key=<账户 id>` 取回该账户的当前密钥并预填输入框
|
|
66
|
+
(本机端点;列表快照始终只给掩码)。模型名按厂商习惯大写(`deepseek-v4.1-flash` → `DeepSeek V4.1 Flash`),
|
|
67
|
+
能取到品牌图标的厂商显示 SVG 图标,取不到的保留字母徽标。「下次探测」是真实的定时器(默认 30 分钟一次),
|
|
68
|
+
页面每秒重绘一次,所以会像倒计时一样走;打开页面本身只读快照,不会触发探测。
|
|
69
|
+
|
|
70
|
+
「此刻用哪个更划算」按套餐杠杆算:Go 付 $10 拿到 $100 官方价额度(≈¥710),所以走 OpenCode 恒为官方价的
|
|
71
|
+
10%(便宜 90%)。真实数据里不会出现官方更便宜,卡片右下角的按钮可以切到示例数据,看那种情况长什么样。
|
|
72
|
+
|
|
65
73
|
模型表的「能力」列来自同一个模型目录接口(`/inference/go/v1/models`)的
|
|
66
74
|
`modalities.input` 与 `capabilities`,标出 文本 / 图像 / 视频 / 音频 / PDF、工具调用、推理,以及上下文长度;
|
|
67
75
|
价格为 0 的模型显示「无限」。
|
package/client.js
CHANGED
|
@@ -40,7 +40,7 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
setActive: '设为当前',
|
|
41
41
|
disable: '停用',
|
|
42
42
|
enable: '启用',
|
|
43
|
-
editKey: '
|
|
43
|
+
editKey: '编辑密钥',
|
|
44
44
|
remove: '删除',
|
|
45
45
|
viewUsage: '查看用量',
|
|
46
46
|
keyUpdated: '密钥已更新',
|
|
@@ -101,15 +101,20 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
peakGoOff: '谷段',
|
|
102
102
|
peakOfficialPeak: '峰段(价格 2 倍)',
|
|
103
103
|
peakOfficialOff: '谷段',
|
|
104
|
-
peakAtPeak: '
|
|
105
|
-
peakOffPeak: '
|
|
104
|
+
peakAtPeak: '高峰期',
|
|
105
|
+
peakOffPeak: '低谷期',
|
|
106
106
|
peakHoliday: '——今天是中国法定节假日,官方整天算谷,OpenCode 照算峰',
|
|
107
107
|
peakExhausted: '但本月额度已经用尽,只能走官方。',
|
|
108
108
|
peakGoPay: '走 OpenCode 你实付',
|
|
109
109
|
peakOfficialPay: '走官方直连你实付',
|
|
110
110
|
peakVerdict: '结论',
|
|
111
111
|
peakGoCheaper: '走 OpenCode 便宜 {percent}%',
|
|
112
|
-
|
|
112
|
+
verdictGo: '走 OpenCode 便宜 {percent}%',
|
|
113
|
+
verdictOfficial: '走官方直连便宜 {percent}%',
|
|
114
|
+
verdictExhausted: '本月额度已用尽,只能走官方直连',
|
|
115
|
+
demoOn: '看官方更便宜的样子',
|
|
116
|
+
demoOff: '回到真实数据',
|
|
117
|
+
demoNote: '示例数据:假设某个模型走 OpenCode 反而更贵(现实中只有额度用尽才会发生)。',
|
|
113
118
|
peakFootnote: '按每 100 万输出 tokens 折算(编码账单主要来自输出),汇率 ¥{fx}/$。峰谷只影响额度烧多快,不改变结论:额度内走 OpenCode 始终比官方直连便宜;例外是额度用尽,或某个模型的额度先见底。',
|
|
114
119
|
footerOfficial: '上面「官方 5 小时 / 本周 / 本月」百分比来自 OpenCode 接口,是账户真实用量,别的客户端或别的机器用了也会算进去。',
|
|
115
120
|
footerLocal: '按模型的金额是本机估算:只统计这个 harness 的会话日志,用来拆开看模型分布;其它工具的调用没有本地记录。',
|
|
@@ -142,7 +147,7 @@ window.__ModuleLoader__.load({
|
|
|
142
147
|
setActive: 'Make active',
|
|
143
148
|
disable: 'Disable',
|
|
144
149
|
enable: 'Enable',
|
|
145
|
-
editKey: '
|
|
150
|
+
editKey: 'Edit key',
|
|
146
151
|
remove: 'Delete',
|
|
147
152
|
viewUsage: 'View usage',
|
|
148
153
|
keyUpdated: 'Key updated',
|
|
@@ -211,7 +216,12 @@ window.__ModuleLoader__.load({
|
|
|
211
216
|
peakOfficialPay: 'You pay via official API',
|
|
212
217
|
peakVerdict: 'Verdict',
|
|
213
218
|
peakGoCheaper: 'OpenCode is {percent}% cheaper',
|
|
214
|
-
|
|
219
|
+
verdictGo: 'OpenCode is {percent}% cheaper',
|
|
220
|
+
verdictOfficial: 'Official API is {percent}% cheaper',
|
|
221
|
+
verdictExhausted: 'This month’s allowance is used up, so only the official API is left',
|
|
222
|
+
demoOn: 'Show the official-is-cheaper case',
|
|
223
|
+
demoOff: 'Back to real data',
|
|
224
|
+
demoNote: 'Sample data: pretend one model is more expensive on OpenCode — in practice that only happens once the allowance runs out.',
|
|
215
225
|
peakFootnote: 'Per 1M output tokens (output dominates a coding bill), at ¥{fx}/$. Peak hours only change how fast the allowance burns, not the verdict: on allowance, OpenCode stays cheaper than the official API; the exceptions are a spent allowance or a model whose individual cap runs out first.',
|
|
216
226
|
footerOfficial: 'The official 5-hour / weekly / monthly percentages come from OpenCode’s API and describe the whole account — usage from other clients or machines counts too.',
|
|
217
227
|
footerLocal: 'Per-model money is a local estimate: it only counts this harness’s session log, which is what makes the model split visible; calls from other tools leave no local record.',
|
|
@@ -244,7 +254,7 @@ window.__ModuleLoader__.load({
|
|
|
244
254
|
setActive: '設為目前',
|
|
245
255
|
disable: '停用',
|
|
246
256
|
enable: '啟用',
|
|
247
|
-
editKey: '
|
|
257
|
+
editKey: '編輯金鑰',
|
|
248
258
|
remove: '刪除',
|
|
249
259
|
viewUsage: '查看用量',
|
|
250
260
|
keyUpdated: '金鑰已更新',
|
|
@@ -305,15 +315,20 @@ window.__ModuleLoader__.load({
|
|
|
305
315
|
peakGoOff: '谷段',
|
|
306
316
|
peakOfficialPeak: '峰段(價格 2 倍)',
|
|
307
317
|
peakOfficialOff: '谷段',
|
|
308
|
-
peakAtPeak: '
|
|
309
|
-
peakOffPeak: '
|
|
318
|
+
peakAtPeak: '高峰期',
|
|
319
|
+
peakOffPeak: '低谷期',
|
|
310
320
|
peakHoliday: '——今天是中國法定假日,官方整天算谷,OpenCode 照算峰',
|
|
311
321
|
peakExhausted: '但本月額度已用盡,只能走官方。',
|
|
312
322
|
peakGoPay: '走 OpenCode 你實付',
|
|
313
323
|
peakOfficialPay: '走官方直連你實付',
|
|
314
324
|
peakVerdict: '結論',
|
|
315
325
|
peakGoCheaper: '走 OpenCode 便宜 {percent}%',
|
|
316
|
-
|
|
326
|
+
verdictGo: '走 OpenCode 便宜 {percent}%',
|
|
327
|
+
verdictOfficial: '走官方直連便宜 {percent}%',
|
|
328
|
+
verdictExhausted: '本月額度已用盡,只能走官方直連',
|
|
329
|
+
demoOn: '看官方更便宜的樣子',
|
|
330
|
+
demoOff: '回到真實資料',
|
|
331
|
+
demoNote: '範例資料:假設某個模型走 OpenCode 反而更貴(現實中只有額度用盡才會發生)。',
|
|
317
332
|
peakFootnote: '依每 100 萬輸出 tokens 折算(編碼帳單主要來自輸出),匯率 ¥{fx}/$。峰谷只影響額度消耗速度,不改變結論:額度內走 OpenCode 始終比官方直連便宜;例外是額度用盡,或某個模型的額度先用完。',
|
|
318
333
|
footerOfficial: '上面「官方 5 小時/本週/本月」百分比來自 OpenCode 介面,是帳號真實用量,其他用戶端或機器用掉的也會算進去。',
|
|
319
334
|
footerLocal: '依模型的金額是本機估算:只統計這個 harness 的工作階段日誌,用來拆開看模型分布;其他工具的呼叫沒有本機記錄。',
|
|
@@ -1084,14 +1099,62 @@ window.__ModuleLoader__.load({
|
|
|
1084
1099
|
const when = (ms) => {
|
|
1085
1100
|
if (!ms) return t('none');
|
|
1086
1101
|
const delta = ms - Date.now();
|
|
1087
|
-
if (delta <= 0) return
|
|
1088
|
-
const minutes = Math.
|
|
1102
|
+
if (delta <= 0) return LANG === 'zh' || LANG === 'zh-tw' ? '已到期' : 'expired';
|
|
1103
|
+
const minutes = Math.floor(delta / 60000);
|
|
1104
|
+
const seconds = Math.floor((delta % 60000) / 1000);
|
|
1089
1105
|
const relative = LANG === 'zh' || LANG === 'zh-tw'
|
|
1090
|
-
?
|
|
1106
|
+
? minutes < 60
|
|
1107
|
+
? `${minutes} 分 ${String(seconds).padStart(2, '0')} 秒后`
|
|
1108
|
+
: `${Math.round(minutes / 60)} 小时后`
|
|
1091
1109
|
: new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1092
1110
|
return relative;
|
|
1093
1111
|
};
|
|
1094
1112
|
|
|
1113
|
+
// The dialog floats over the page, so it cannot inherit a translucent surface:
|
|
1114
|
+
// it needs a solid fill and an explicit text colour for the current theme.
|
|
1115
|
+
const theme = () => {
|
|
1116
|
+
let dark = false;
|
|
1117
|
+
try {
|
|
1118
|
+
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches) dark = true;
|
|
1119
|
+
else {
|
|
1120
|
+
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(getComputedStyle(document.documentElement).backgroundColor ?? '');
|
|
1121
|
+
if (match !== null) dark = (Number(match[1]) + Number(match[2]) + Number(match[3])) / 3 < 128;
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
dark = true;
|
|
1125
|
+
}
|
|
1126
|
+
return dark
|
|
1127
|
+
? { panel: '#202124', text: '#ececec', line: 'rgba(255,255,255,.18)', scrim: 'rgba(0,0,0,.55)' }
|
|
1128
|
+
: { panel: '#ffffff', text: '#1b1b1b', line: 'rgba(0,0,0,.15)', scrim: 'rgba(0,0,0,.35)' };
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// Model ids read badly in a table ("deepseek-v4.1-flash"); the page shows the
|
|
1132
|
+
// vendor casing plus spaces instead.
|
|
1133
|
+
const MODEL_CASE = [
|
|
1134
|
+
[/^deepseek/i, 'DeepSeek'], [/^glm/i, 'GLM'], [/^gpt/i, 'GPT'], [/^claude/i, 'Claude'],
|
|
1135
|
+
[/^grok/i, 'Grok'], [/^qwen/i, 'Qwen'], [/^kimi/i, 'Kimi'], [/^minimax/i, 'MiniMax'],
|
|
1136
|
+
[/^mimo/i, 'MiMo'], [/^longcat/i, 'LongCat'], [/^hunyuan/i, 'Hunyuan'], [/^hy\d/i, 'Hunyuan'],
|
|
1137
|
+
[/^muse/i, 'Muse'],
|
|
1138
|
+
];
|
|
1139
|
+
const prettyModel = (id) => {
|
|
1140
|
+
const text = String(id ?? '');
|
|
1141
|
+
let head = '';
|
|
1142
|
+
let rest = text;
|
|
1143
|
+
for (const [pattern, name] of MODEL_CASE) {
|
|
1144
|
+
if (!pattern.test(text)) continue;
|
|
1145
|
+
head = name;
|
|
1146
|
+
rest = text.replace(pattern, '');
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
const tail = rest
|
|
1150
|
+
.replace(/[-_]+/g, ' ')
|
|
1151
|
+
.split(' ')
|
|
1152
|
+
.filter(Boolean)
|
|
1153
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1154
|
+
.join(' ');
|
|
1155
|
+
return [head, tail].filter(Boolean).join(' ');
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1095
1158
|
const btn = {
|
|
1096
1159
|
padding: '5px 12px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
|
|
1097
1160
|
background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: 12,
|
|
@@ -1181,9 +1244,10 @@ window.__ModuleLoader__.load({
|
|
|
1181
1244
|
|
|
1182
1245
|
function UsageDialog({ account, busy, onProbe, onClose }) {
|
|
1183
1246
|
if (!account) return null;
|
|
1247
|
+
const skin = theme();
|
|
1184
1248
|
return h('div', {
|
|
1185
1249
|
style: {
|
|
1186
|
-
position: 'fixed', inset: 0, background:
|
|
1250
|
+
position: 'fixed', inset: 0, background: skin.scrim, zIndex: 1200,
|
|
1187
1251
|
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
|
1188
1252
|
},
|
|
1189
1253
|
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
|
|
@@ -1191,8 +1255,8 @@ window.__ModuleLoader__.load({
|
|
|
1191
1255
|
h('div', {
|
|
1192
1256
|
style: {
|
|
1193
1257
|
minWidth: 380, maxWidth: 520, width: '100%', padding: '16px 18px', borderRadius: 10,
|
|
1194
|
-
background:
|
|
1195
|
-
border:
|
|
1258
|
+
background: skin.panel, color: skin.text, opacity: 1,
|
|
1259
|
+
border: `1px solid ${skin.line}`, boxShadow: '0 18px 48px rgba(0,0,0,.45)',
|
|
1196
1260
|
},
|
|
1197
1261
|
},
|
|
1198
1262
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } },
|
|
@@ -1201,7 +1265,7 @@ window.__ModuleLoader__.load({
|
|
|
1201
1265
|
style: { fontSize: 11, padding: '1px 6px', borderRadius: 999, border: '1px solid rgba(60,157,93,.6)', color: '#3c9d5d' },
|
|
1202
1266
|
}, t('statusCurrent')) : null,
|
|
1203
1267
|
h('span', { style: { flex: 1 } }),
|
|
1204
|
-
h('button', { type: 'button', style: btn, onClick: onClose }, t('close')),
|
|
1268
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: onClose }, t('close')),
|
|
1205
1269
|
),
|
|
1206
1270
|
h('div', { style: { fontSize: 12, opacity: .6, marginBottom: 12 } },
|
|
1207
1271
|
account.hint ? h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, account.hint) : t('notConfigured')),
|
|
@@ -1212,7 +1276,7 @@ window.__ModuleLoader__.load({
|
|
|
1212
1276
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
|
|
1213
1277
|
account.checkedAt ? h('span', { style: { fontSize: 11, opacity: .55 } }, t('probedAt', { time: clock(account.checkedAt) })) : null,
|
|
1214
1278
|
h('span', { style: { flex: 1 } }),
|
|
1215
|
-
h('button', { type: 'button', style: btn, disabled: busy, onClick: onProbe }, t('dialogProbe')),
|
|
1279
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: onProbe }, t('dialogProbe')),
|
|
1216
1280
|
),
|
|
1217
1281
|
h('div', { style: { fontSize: 11, opacity: .55, marginTop: 12, lineHeight: 1.6 } }, t('dialogNote')),
|
|
1218
1282
|
),
|
|
@@ -1228,6 +1292,14 @@ window.__ModuleLoader__.load({
|
|
|
1228
1292
|
const [keyDraft, setKeyDraft] = React.useState('');
|
|
1229
1293
|
const [inspecting, setInspecting] = React.useState(null);
|
|
1230
1294
|
const [busy, setBusy] = React.useState(false);
|
|
1295
|
+
const [demo, setDemo] = React.useState(false);
|
|
1296
|
+
// Re-render every second so the "next probe" line counts down instead of
|
|
1297
|
+
// freezing at whatever it said when the page opened.
|
|
1298
|
+
const [, setTick] = React.useState(0);
|
|
1299
|
+
React.useEffect(() => {
|
|
1300
|
+
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
|
1301
|
+
return () => clearInterval(timer);
|
|
1302
|
+
}, []);
|
|
1231
1303
|
|
|
1232
1304
|
const load = React.useCallback(async (force) => {
|
|
1233
1305
|
try {
|
|
@@ -1269,6 +1341,18 @@ window.__ModuleLoader__.load({
|
|
|
1269
1341
|
}
|
|
1270
1342
|
}, [load]);
|
|
1271
1343
|
|
|
1344
|
+
// Editing a key starts from the stored value, so the input is pre-filled.
|
|
1345
|
+
const revealKey = React.useCallback(async (id) => {
|
|
1346
|
+
try {
|
|
1347
|
+
const response = await fetch(`${ROUTER_API}?key=${encodeURIComponent(id)}`, { headers: { accept: 'application/json' } });
|
|
1348
|
+
const body = await response.json().catch(() => ({}));
|
|
1349
|
+
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
|
|
1350
|
+
if (typeof body?.key === 'string') setKeyDraft(body.key);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
setActionError(String(error?.message ?? error));
|
|
1353
|
+
}
|
|
1354
|
+
}, []);
|
|
1355
|
+
|
|
1272
1356
|
React.useEffect(() => {
|
|
1273
1357
|
let alive = true;
|
|
1274
1358
|
const run = (force) => {
|
|
@@ -1286,6 +1370,76 @@ window.__ModuleLoader__.load({
|
|
|
1286
1370
|
const data = state.usage;
|
|
1287
1371
|
const router = state.router;
|
|
1288
1372
|
const statusText = STATUS();
|
|
1373
|
+
const chinese = LANG === 'zh' || LANG === 'zh-tw';
|
|
1374
|
+
|
|
1375
|
+
// The router stores machine reasons ("allowance recovered"); the page says
|
|
1376
|
+
// what actually happened instead of echoing the enum.
|
|
1377
|
+
const reasonText = (reason) => {
|
|
1378
|
+
const raw = String(reason ?? '');
|
|
1379
|
+
const pick = (zh, en) => (chinese ? zh : en);
|
|
1380
|
+
const rules = [
|
|
1381
|
+
[/^allowance recovered/i, pick('额度已恢复,已切回 OpenCode 账户池', 'allowance recovered, back on the OpenCode pool')],
|
|
1382
|
+
[/^reset in Settings/i, pick('在设置里重置了封禁标记', 'marks were reset in Settings')],
|
|
1383
|
+
[/^selected in Settings/i, pick('在设置里手动设为当前账户', 'selected by hand in Settings')],
|
|
1384
|
+
[/^first account added/i, pick('首个账户已启用', 'the first account was enabled')],
|
|
1385
|
+
[/^initial$/i, pick('初始状态', 'initial')],
|
|
1386
|
+
[/^loaded$/i, pick('从状态文件恢复', 'restored from the state file')],
|
|
1387
|
+
[/^failover: (\S+) unavailable/i, pick('上一个账户不可用,已自动换下一个', 'the previous account went unavailable, switched to the next one')],
|
|
1388
|
+
[/^account failover after (\w+)/i, pick('鉴权失败($1),已换下一个账户', 'authentication failed ($1), switched to the next account')],
|
|
1389
|
+
[/^quota failover from (\S+)/i, pick('$1 额度用尽,已换下一个账户', '$1 ran out of allowance, switched to the next account')],
|
|
1390
|
+
[/^every OpenCode account is out of allowance/i, pick('所有账户额度都用完了,暂时走官方直连', 'every account is out of allowance, using the official API for now')],
|
|
1391
|
+
[/^OpenCode authentication failed \((\w+)\)/i, pick('OpenCode 鉴权失败($1),暂时走官方直连', 'OpenCode authentication failed ($1), using the official API for now')],
|
|
1392
|
+
[/^OpenCode quota window exhausted/i, pick('本窗口额度用尽,暂时走官方直连', 'this allowance window is used up, using the official API for now')],
|
|
1393
|
+
[/^OpenCode quota exhausted/i, pick('额度用尽,暂时走官方直连', 'out of allowance, using the official API for now')],
|
|
1394
|
+
[/^active account (\S+) removed/i, pick('当前账户被删除,已自动换下一个', 'the active account was deleted, switched to the next one')],
|
|
1395
|
+
[/^auth \((.+)\)/i, pick('鉴权失败:$1', 'authentication failed: $1')],
|
|
1396
|
+
];
|
|
1397
|
+
for (const [pattern, text] of rules) {
|
|
1398
|
+
if (pattern.test(raw)) return raw.replace(pattern, text);
|
|
1399
|
+
}
|
|
1400
|
+
return raw;
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// A vendor badge per model family: the mark and the tint both come from the
|
|
1404
|
+
// family, so a card's gradient matches its avatar.
|
|
1405
|
+
const LOGOS = {
|
|
1406
|
+
deepseek: 'M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45',
|
|
1407
|
+
claude: 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
|
|
1408
|
+
grok: 'M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z',
|
|
1409
|
+
qwen: 'M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z',
|
|
1410
|
+
kimi: 'M21.765.351C22.998.351 24 1.353 24 2.586S22.998 4.82 21.765 4.82h-1.974c-.15 0-.26-.12-.26-.26V2.586A2.237 2.237 0 0 1 21.765.35M9.41 13.388l8.447-8.377c.16-.16.07-.471-.14-.471h-4.55s-.1.02-.14.06l-9.099 9.029c-.14.14-.35.02-.35-.21V4.81c0-.15-.1-.27-.221-.27H.22c-.12 0-.22.12-.22.27v18.57c0 .15.1.27.22.27h3.137c.12 0 .22-.12.22-.27v-3.79c0-.08.03-.16.08-.21l2.826-2.796c.07-.07.16-.08.241-.03l7.546 5.551a8.9 8.9 0 0 0 4.018 1.493c.12.01.23-.11.23-.27V19.76c0-.14-.08-.25-.19-.26a5.8 5.8 0 0 1-2.355-.942l-6.533-4.73c-.14-.09-.15-.32-.03-.441',
|
|
1411
|
+
minimax: 'M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997',
|
|
1412
|
+
mimo: 'M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z',
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const VENDORS = [
|
|
1416
|
+
[/^deepseek/i, { mark: 'DS', name: 'DeepSeek', color: '#4d6bfe', logo: 'deepseek' }],
|
|
1417
|
+
[/^glm/i, { mark: 'GLM', name: 'Zhipu', color: '#3b6cf6' }],
|
|
1418
|
+
[/^gpt/i, { mark: 'GPT', name: 'OpenAI', color: '#10a37f' }],
|
|
1419
|
+
[/^claude/i, { mark: 'C', name: 'Anthropic', color: '#d97757', logo: 'claude' }],
|
|
1420
|
+
[/^grok/i, { mark: 'X', name: 'xAI', color: '#64748b', logo: 'grok' }],
|
|
1421
|
+
[/^qwen/i, { mark: 'Q', name: 'Qwen', color: '#615ced', logo: 'qwen' }],
|
|
1422
|
+
[/^kimi/i, { mark: 'K', name: 'Moonshot', color: '#8b5cf6', logo: 'kimi' }],
|
|
1423
|
+
[/^minimax/i, { mark: 'MM', name: 'MiniMax', color: '#ff6b00', logo: 'minimax' }],
|
|
1424
|
+
[/^mimo/i, { mark: 'Mi', name: 'MiMo', color: '#ff6900', logo: 'mimo' }],
|
|
1425
|
+
[/^longcat/i, { mark: 'LC', name: 'LongCat', color: '#f59e0b' }],
|
|
1426
|
+
[/^hy\d/i, { mark: 'H', name: 'Hunyuan', color: '#0057ff' }],
|
|
1427
|
+
[/^muse/i, { mark: 'M', name: 'Muse', color: '#a855f7' }],
|
|
1428
|
+
[/free$/i, { mark: '∞', name: 'Free', color: '#3c9d5d' }],
|
|
1429
|
+
];
|
|
1430
|
+
const vendorOf = (model) => {
|
|
1431
|
+
const hit = VENDORS.find(([pattern]) => pattern.test(model));
|
|
1432
|
+
const vendor = hit ? { ...hit[1] } : { mark: String(model).slice(0, 2).toUpperCase(), name: model, color: '#6b7280' };
|
|
1433
|
+
if (vendor.logo && LOGOS[vendor.logo]) vendor.path = LOGOS[vendor.logo];
|
|
1434
|
+
return vendor;
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
const CAP_ICON = { text: '📝', image: '🖼️', video: '🎬', audio: '🎵', pdf: '📄' };
|
|
1438
|
+
const capIcon = (label) => {
|
|
1439
|
+
if (label === t('capsTools')) return '🛠️';
|
|
1440
|
+
if (label === t('capsReasoning')) return '🧠';
|
|
1441
|
+
return CAP_ICON[label] ?? '•';
|
|
1442
|
+
};
|
|
1289
1443
|
|
|
1290
1444
|
const routerAccounts = new Map((router?.accounts ?? []).map((account) => [account.id, account]));
|
|
1291
1445
|
const usageAccounts = data?.accounts ?? [];
|
|
@@ -1311,86 +1465,127 @@ window.__ModuleLoader__.load({
|
|
|
1311
1465
|
};
|
|
1312
1466
|
});
|
|
1313
1467
|
|
|
1314
|
-
|
|
1468
|
+
// Layout primitives: every block is a card with its own padding and gap, so
|
|
1469
|
+
// buttons never sit flush against each other or against the table edges.
|
|
1470
|
+
const card = (...children) => h('div', {
|
|
1471
|
+
style: {
|
|
1472
|
+
padding: '14px 16px', border: '1px solid rgba(127,127,127,.25)', borderRadius: 10,
|
|
1473
|
+
background: 'var(--dsh-surface, rgba(127,127,127,.04))',
|
|
1474
|
+
display: 'flex', flexDirection: 'column', gap: 12,
|
|
1475
|
+
},
|
|
1476
|
+
}, ...children);
|
|
1477
|
+
|
|
1478
|
+
const cardHead = (text, extra) => h('div', {
|
|
1479
|
+
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1480
|
+
},
|
|
1481
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, text),
|
|
1482
|
+
extra ?? null,
|
|
1483
|
+
);
|
|
1484
|
+
|
|
1485
|
+
const stat = (label, value) => h('div', {
|
|
1486
|
+
style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 96 },
|
|
1487
|
+
},
|
|
1488
|
+
h('span', { style: { fontSize: 11, opacity: .55 } }, label),
|
|
1489
|
+
h('span', { style: { fontSize: 13, fontWeight: 500 } }, value),
|
|
1490
|
+
);
|
|
1491
|
+
|
|
1492
|
+
const tools = (...children) => h('div', {
|
|
1493
|
+
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1494
|
+
}, ...children);
|
|
1495
|
+
|
|
1496
|
+
const pill = (text, color) => h('span', {
|
|
1497
|
+
style: {
|
|
1498
|
+
fontSize: 11, padding: '1px 8px', borderRadius: 999,
|
|
1499
|
+
border: `1px solid ${color}99`, color,
|
|
1500
|
+
},
|
|
1501
|
+
}, text);
|
|
1502
|
+
|
|
1503
|
+
const accountCards = accountList.map((account, index) => {
|
|
1315
1504
|
const [text, color] = statusText[account.status] ?? [
|
|
1316
1505
|
account.usageError ? t('statusError') : account.enabled === false ? t('statusDisabled') : account.active ? t('statusActive') : t('statusSpare'),
|
|
1317
1506
|
account.usageError ? '#d9534f' : 'inherit',
|
|
1318
1507
|
];
|
|
1319
|
-
return h(
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1508
|
+
return h('div', {
|
|
1509
|
+
key: account.id,
|
|
1510
|
+
style: {
|
|
1511
|
+
display: 'flex', flexDirection: 'column', gap: 12, padding: '12px 14px', borderRadius: 10,
|
|
1512
|
+
border: `1px solid ${account.active ? 'rgba(60,157,93,.45)' : 'rgba(127,127,127,.22)'}`,
|
|
1513
|
+
background: account.active ? 'rgba(60,157,93,.06)' : 'rgba(127,127,127,.03)',
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
1517
|
+
h('span', { style: { opacity: .45, fontSize: 11 } }, `#${index + 1}`),
|
|
1518
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, account.label),
|
|
1519
|
+
account.active ? pill(t('statusCurrent'), '#3c9d5d') : null,
|
|
1520
|
+
h('span', {
|
|
1521
|
+
style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 12, opacity: account.hint ? .65 : .45 },
|
|
1522
|
+
}, account.hint ?? t('notConfigured')),
|
|
1523
|
+
h('span', { style: { flex: 1 } }),
|
|
1524
|
+
h('span', { style: { color, fontSize: 12 } },
|
|
1335
1525
|
text,
|
|
1336
|
-
account.exhaustedUntil ? ` · ${clock(account.exhaustedUntil)}` : '',
|
|
1337
|
-
)),
|
|
1338
|
-
cell(router && h('div', {
|
|
1339
|
-
style: {
|
|
1340
|
-
display: 'grid', gridTemplateColumns: 'repeat(2, minmax(78px, max-content))',
|
|
1341
|
-
gap: 6, padding: '2px 0',
|
|
1342
|
-
},
|
|
1343
|
-
},
|
|
1344
|
-
h('button', { type: 'button', style: btn, onClick: () => setInspecting(account.id) }, t('viewUsage')),
|
|
1345
|
-
h('button', { type: 'button', style: btn, disabled: busy || account.active, onClick: () => void act({ action: 'activate', id: account.id }) }, t('setActive')),
|
|
1346
|
-
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => void act({ action: 'update', id: account.id, enabled: !account.enabled }) }, account.enabled ? t('disable') : t('enable')),
|
|
1347
|
-
h('button', {
|
|
1348
|
-
type: 'button', style: btn, disabled: busy,
|
|
1349
|
-
onClick: () => { void act({ action: 'move', id: account.id, direction: 'up' }); },
|
|
1350
|
-
}, '↑'),
|
|
1351
|
-
h('button', {
|
|
1352
|
-
type: 'button', style: btn, disabled: busy,
|
|
1353
|
-
onClick: () => { void act({ action: 'move', id: account.id, direction: 'down' }); },
|
|
1354
|
-
}, '↓'),
|
|
1355
|
-
h('button', {
|
|
1356
|
-
type: 'button', style: btn, disabled: busy,
|
|
1357
|
-
onClick: () => { setEditing(editing === account.id ? null : account.id); setKeyDraft(''); },
|
|
1358
|
-
}, t('editKey')),
|
|
1359
|
-
h('button', {
|
|
1360
|
-
type: 'button', style: { ...btn, color: '#d9534f' }, disabled: busy,
|
|
1361
|
-
onClick: () => {
|
|
1362
|
-
if (accountList.length <= 1) {
|
|
1363
|
-
setActionError(t('keepOne'));
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
void act({ action: 'remove', id: account.id });
|
|
1367
|
-
},
|
|
1368
|
-
}, t('remove')),
|
|
1369
|
-
) || h('span', { style: { opacity: .5, fontSize: 12 } }, t('readonly'))),
|
|
1526
|
+
account.exhaustedUntil ? ` · ${clock(account.exhaustedUntil)}` : ''),
|
|
1370
1527
|
),
|
|
1528
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 12 } },
|
|
1529
|
+
stat(t('colRolling'), official(account.usage?.rolling)),
|
|
1530
|
+
stat(t('colWeekly'), official(account.usage?.weekly)),
|
|
1531
|
+
stat(t('colMonthly'), official(account.usage?.monthly)),
|
|
1532
|
+
),
|
|
1533
|
+
router
|
|
1534
|
+
? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', paddingTop: 2 } },
|
|
1535
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: () => setInspecting(account.id) }, t('viewUsage')),
|
|
1536
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy || account.active, onClick: () => void act({ action: 'activate', id: account.id }) }, t('setActive')),
|
|
1537
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => void act({ action: 'update', id: account.id, enabled: !account.enabled }) }, account.enabled ? t('disable') : t('enable')),
|
|
1538
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'up' }); } }, '↑'),
|
|
1539
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'down' }); } }, '↓'),
|
|
1540
|
+
h('button', {
|
|
1541
|
+
type: 'button', className: 'ocr-btn', style: btn, disabled: busy,
|
|
1542
|
+
onClick: () => {
|
|
1543
|
+
if (editing === account.id) {
|
|
1544
|
+
setEditing(null);
|
|
1545
|
+
setKeyDraft('');
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
setEditing(account.id);
|
|
1549
|
+
setKeyDraft('');
|
|
1550
|
+
void revealKey(account.id);
|
|
1551
|
+
},
|
|
1552
|
+
}, t('editKey')),
|
|
1553
|
+
h('button', {
|
|
1554
|
+
type: 'button', className: 'ocr-btn', style: { ...btn, color: '#d9534f' }, disabled: busy,
|
|
1555
|
+
onClick: () => {
|
|
1556
|
+
if (accountList.length <= 1) {
|
|
1557
|
+
setActionError(t('keepOne'));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
void act({ action: 'remove', id: account.id });
|
|
1561
|
+
},
|
|
1562
|
+
}, t('remove')),
|
|
1563
|
+
)
|
|
1564
|
+
: h('span', { style: { opacity: .5, fontSize: 12 } }, t('readonly')),
|
|
1371
1565
|
editing === account.id
|
|
1372
|
-
? h('
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
),
|
|
1566
|
+
? h('div', {
|
|
1567
|
+
style: {
|
|
1568
|
+
display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
|
|
1569
|
+
paddingTop: 10, borderTop: '1px solid rgba(127,127,127,.18)',
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
h('span', { style: { opacity: .7, fontSize: 12 } }, t('newKeyFor', { ref: account.keyRef ?? '' })),
|
|
1573
|
+
h('input', {
|
|
1574
|
+
style: { ...input, minWidth: 300 }, type: 'password', placeholder: 'oc_sk_…', value: keyDraft,
|
|
1575
|
+
onChange: (event) => setKeyDraft(event.target.value),
|
|
1576
|
+
}),
|
|
1577
|
+
h('button', {
|
|
1578
|
+
type: 'button', className: 'ocr-btn', style: btn, disabled: busy || keyDraft.trim().length < 8,
|
|
1579
|
+
onClick: async () => {
|
|
1580
|
+
const ok = await act({ action: 'update', id: account.id, key: keyDraft.trim() });
|
|
1581
|
+
if (ok) {
|
|
1582
|
+
setEditing(null);
|
|
1583
|
+
setKeyDraft('');
|
|
1584
|
+
setNotice(t('keyUpdated'));
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
}, t('save')),
|
|
1588
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: () => { setEditing(null); setKeyDraft(''); } }, t('cancel')),
|
|
1394
1589
|
)
|
|
1395
1590
|
: null,
|
|
1396
1591
|
);
|
|
@@ -1408,32 +1603,75 @@ window.__ModuleLoader__.load({
|
|
|
1408
1603
|
}
|
|
1409
1604
|
};
|
|
1410
1605
|
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1606
|
+
const modelCards = (data?.models ?? []).map((row) => {
|
|
1607
|
+
const vendor = vendorOf(row.model);
|
|
1608
|
+
const chips = capabilityTags(row);
|
|
1609
|
+
return h('div', {
|
|
1610
|
+
key: row.model,
|
|
1611
|
+
style: {
|
|
1612
|
+
display: 'flex', flexDirection: 'column', gap: 12, padding: '14px 16px', borderRadius: 10,
|
|
1613
|
+
border: `1px solid ${vendor.color}3d`,
|
|
1614
|
+
background: `linear-gradient(135deg, ${vendor.color}2e, ${vendor.color}0f 55%, rgba(127,127,127,.03))`,
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
1618
|
+
h('span', {
|
|
1424
1619
|
style: {
|
|
1425
|
-
|
|
1426
|
-
|
|
1620
|
+
width: 30, height: 30, borderRadius: '50%', flex: 'none',
|
|
1621
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1622
|
+
background: `${vendor.color}26`, border: `1px solid ${vendor.color}80`,
|
|
1623
|
+
color: vendor.color, fontSize: 11, fontWeight: 700,
|
|
1427
1624
|
},
|
|
1428
|
-
},
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1625
|
+
},
|
|
1626
|
+
vendor.path
|
|
1627
|
+
? h('svg', { viewBox: '0 0 24 24', width: 17, height: 17, role: 'img', 'aria-label': vendor.name },
|
|
1628
|
+
h('path', { d: vendor.path, fill: vendor.color }),
|
|
1629
|
+
)
|
|
1630
|
+
: vendor.mark,
|
|
1631
|
+
),
|
|
1632
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, prettyModel(row.model)),
|
|
1633
|
+
row.free ? pill(t('unlimited'), '#3c9d5d') : null,
|
|
1634
|
+
),
|
|
1635
|
+
chips.length > 0
|
|
1636
|
+
? h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
1637
|
+
chips.map((label) => h('span', {
|
|
1638
|
+
key: label,
|
|
1639
|
+
style: {
|
|
1640
|
+
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
1641
|
+
padding: '2px 8px', borderRadius: 999, fontSize: 11,
|
|
1642
|
+
border: `1px solid ${vendor.color}59`, background: `${vendor.color}14`, color: 'inherit',
|
|
1643
|
+
},
|
|
1644
|
+
}, h('span', null, capIcon(label)), h('span', null, label))),
|
|
1645
|
+
)
|
|
1646
|
+
: null,
|
|
1647
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: 12 } },
|
|
1648
|
+
stat(t('colRemaining'), row.free ? t('unlimited') : money(row.remaining)),
|
|
1649
|
+
stat(t('colMonthlyUsed'), row.free ? t('unlimited') : `${money(row.monthly)} / ${money(row.limit)}`),
|
|
1650
|
+
stat(t('colWeeklyUsed'), row.free ? t('unlimited') : money(row.weekly)),
|
|
1651
|
+
stat(t('colRolling'), percentText(row.rolling, row.rollingLimit, row.free)),
|
|
1652
|
+
stat(t('colRequests'), String(row.requests)),
|
|
1653
|
+
),
|
|
1654
|
+
);
|
|
1655
|
+
});
|
|
1434
1656
|
|
|
1435
1657
|
const peak = data?.peak;
|
|
1436
1658
|
const quotaSpent = typeof data?.usage?.monthly?.percent === 'number' ? data.usage.monthly.percent : null;
|
|
1659
|
+
const exhausted = quotaSpent !== null && quotaSpent >= 100;
|
|
1660
|
+
// The real numbers can never favour the official API (a $10 plan carries
|
|
1661
|
+
// $100 of list price), so the page can also show what that case looks like.
|
|
1662
|
+
const demoPeak = {
|
|
1663
|
+
plan: peak?.plan ?? { leverage: 10 },
|
|
1664
|
+
models: [{ model: 'deepseek-v4-pro', go: { pay: 13.5, factor: 1 }, official: { pay: 9, factor: 1 }, cheaper: 'official', ratio: 1.5 }],
|
|
1665
|
+
};
|
|
1666
|
+
const shown = demo ? demoPeak : peak;
|
|
1667
|
+
const allGo = shown ? shown.models.every((row) => row.cheaper === 'go') : false;
|
|
1668
|
+
const saving = shown && shown.models.length > 0
|
|
1669
|
+
? Math.round(Math.max(...shown.models.map((row) => (row.cheaper === 'go' ? (1 - row.ratio) * 100 : (row.ratio - 1) * 100))))
|
|
1670
|
+
: 0;
|
|
1671
|
+
const verdict = exhausted && !demo
|
|
1672
|
+
? t('verdictExhausted')
|
|
1673
|
+
: allGo ? t('verdictGo', { percent: saving }) : t('verdictOfficial', { percent: saving });
|
|
1674
|
+
const verdictColor = exhausted && !demo ? '#e0a030' : allGo ? '#3c9d5d' : '#e0a030';
|
|
1437
1675
|
const priceCell = (price, peakFlag, winner) => h('span', {
|
|
1438
1676
|
style: winner
|
|
1439
1677
|
? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
|
|
@@ -1442,40 +1680,12 @@ window.__ModuleLoader__.load({
|
|
|
1442
1680
|
|
|
1443
1681
|
const inspectingAccount = accountList.find((account) => account.id === inspecting) ?? null;
|
|
1444
1682
|
|
|
1445
|
-
// Layout primitives: every block is a card with its own padding and gap, so
|
|
1446
|
-
// buttons never sit flush against each other or against the table edges.
|
|
1447
|
-
const card = (...children) => h('div', {
|
|
1448
|
-
style: {
|
|
1449
|
-
padding: '14px 16px', border: '1px solid rgba(127,127,127,.25)', borderRadius: 10,
|
|
1450
|
-
background: 'var(--dsh-surface, rgba(127,127,127,.04))',
|
|
1451
|
-
display: 'flex', flexDirection: 'column', gap: 12,
|
|
1452
|
-
},
|
|
1453
|
-
}, ...children);
|
|
1454
|
-
|
|
1455
|
-
const cardHead = (text, extra) => h('div', {
|
|
1456
|
-
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1457
|
-
},
|
|
1458
|
-
h('span', { style: { fontWeight: 600, fontSize: 13 } }, text),
|
|
1459
|
-
extra ?? null,
|
|
1460
|
-
);
|
|
1461
|
-
|
|
1462
|
-
const stat = (label, value) => h('div', {
|
|
1463
|
-
style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 110 },
|
|
1464
|
-
},
|
|
1465
|
-
h('span', { style: { fontSize: 11, opacity: .55 } }, label),
|
|
1466
|
-
h('span', { style: { fontSize: 13, fontWeight: 500 } }, value),
|
|
1467
|
-
);
|
|
1468
|
-
|
|
1469
|
-
const tools = (...children) => h('div', {
|
|
1470
|
-
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1471
|
-
}, ...children);
|
|
1472
|
-
|
|
1473
1683
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
|
|
1474
1684
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
|
|
1475
1685
|
h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
|
|
1476
1686
|
state.phase === 'ready' ? h('span', { style: { opacity: .55, fontSize: 12 } }, t('updatedAt', { time: clock(data?.checkedAt) })) : null,
|
|
1477
1687
|
h('span', { style: { flex: 1 } }),
|
|
1478
|
-
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => { void load(true); } }, busy ? t('refreshing') : t('refresh')),
|
|
1688
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void load(true); } }, busy ? t('refreshing') : t('refresh')),
|
|
1479
1689
|
),
|
|
1480
1690
|
state.phase === 'error' ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, t('loadError', { message: state.error })) : null,
|
|
1481
1691
|
actionError ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, actionError) : null,
|
|
@@ -1483,26 +1693,20 @@ window.__ModuleLoader__.load({
|
|
|
1483
1693
|
|
|
1484
1694
|
card(
|
|
1485
1695
|
cardHead(t('routerTitle'), router
|
|
1486
|
-
?
|
|
1487
|
-
style: {
|
|
1488
|
-
padding: '1px 8px', borderRadius: 999, fontSize: 12,
|
|
1489
|
-
border: `1px solid ${router.mode === 'fallback' ? 'rgba(224,160,48,.7)' : 'rgba(60,157,93,.6)'}`,
|
|
1490
|
-
color: router.mode === 'fallback' ? '#e0a030' : '#3c9d5d',
|
|
1491
|
-
},
|
|
1492
|
-
}, router.mode === 'fallback' ? t('modeFallback') : t('modePrimary'))
|
|
1696
|
+
? pill(router.mode === 'fallback' ? t('modeFallback') : t('modePrimary'), router.mode === 'fallback' ? '#e0a030' : '#3c9d5d')
|
|
1493
1697
|
: h('span', { style: { opacity: .7, fontSize: 12 } }, t('routerMissing'))),
|
|
1494
|
-
router?.reason ? h('div', { style: { fontSize: 12, opacity: .
|
|
1698
|
+
router?.reason ? h('div', { style: { fontSize: 12, opacity: .65 } }, t('modeReason', { reason: reasonText(router.reason) })) : null,
|
|
1495
1699
|
router
|
|
1496
1700
|
? h('div', { style: { display: 'flex', gap: 32, flexWrap: 'wrap' } },
|
|
1497
1701
|
stat(t('activeAccount'), router.activeLabel ?? t('none')),
|
|
1498
|
-
stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) :
|
|
1702
|
+
stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) : when(Date.now() + (router.probeIntervalMs ?? 0))),
|
|
1499
1703
|
stat(t('colStatus'), router.mode === 'fallback' ? t('modeFallback') : t('modePrimary')),
|
|
1500
1704
|
)
|
|
1501
1705
|
: null,
|
|
1502
1706
|
router
|
|
1503
1707
|
? tools(
|
|
1504
|
-
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => void act({ action: 'probe' }) }, t('probeAll')),
|
|
1505
|
-
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => void act({ action: 'reset' }) }, t('resetMarks')),
|
|
1708
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => void act({ action: 'probe' }) }, t('probeAll')),
|
|
1709
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => void act({ action: 'reset' }) }, t('resetMarks')),
|
|
1506
1710
|
h('span', { style: { flexBasis: '100%', fontSize: 11, opacity: .55, lineHeight: 1.6 } }, t('resetHint')),
|
|
1507
1711
|
)
|
|
1508
1712
|
: null,
|
|
@@ -1510,103 +1714,74 @@ window.__ModuleLoader__.load({
|
|
|
1510
1714
|
|
|
1511
1715
|
card(
|
|
1512
1716
|
cardHead(t('accountsTitle'), h('span', { style: { opacity: .55, fontSize: 12 } }, t('accountsOrder'))),
|
|
1513
|
-
h('div', {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
},
|
|
1518
|
-
...draggable(),
|
|
1519
|
-
},
|
|
1520
|
-
h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
|
|
1521
|
-
h('thead', null,
|
|
1522
|
-
h('tr', null, [t('colIndex'), t('colAccount'), t('colKey'), t('colRolling'), t('colWeekly'), t('colMonthly'), t('colStatus'), t('colActions')].map((title) => th(title))),
|
|
1523
|
-
),
|
|
1524
|
-
h('tbody', null, accountRows.length > 0
|
|
1525
|
-
? accountRows
|
|
1526
|
-
: h('tr', null, h('td', { colSpan: 8, style: { padding: 14, opacity: .6 } }, t('noAccounts')))),
|
|
1527
|
-
),
|
|
1717
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
|
|
1718
|
+
accountCards.length > 0
|
|
1719
|
+
? accountCards
|
|
1720
|
+
: h('div', { style: { opacity: .6, fontSize: 12 } }, t('noAccounts')),
|
|
1528
1721
|
),
|
|
1529
|
-
router ? h('div', {
|
|
1530
|
-
style: {
|
|
1531
|
-
display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap',
|
|
1532
|
-
paddingTop: 12, borderTop: '1px solid rgba(127,127,127,.18)',
|
|
1533
|
-
},
|
|
1534
|
-
},
|
|
1535
|
-
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1536
|
-
t('colAccount'),
|
|
1537
|
-
h('input', {
|
|
1538
|
-
style: { ...input, minWidth: 200 }, placeholder: t('labelPlaceholder'), value: draft.label,
|
|
1539
|
-
onChange: (event) => setDraft({ ...draft, label: event.target.value }),
|
|
1540
|
-
}),
|
|
1541
|
-
),
|
|
1542
|
-
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1543
|
-
t('colKey'),
|
|
1544
|
-
h('input', {
|
|
1545
|
-
style: { ...input, minWidth: 320 }, type: 'password', placeholder: t('keyPlaceholder'), value: draft.key,
|
|
1546
|
-
onChange: (event) => setDraft({ ...draft, key: event.target.value }),
|
|
1547
|
-
}),
|
|
1548
|
-
),
|
|
1549
|
-
h('button', { type: 'button', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
|
|
1550
|
-
) : null,
|
|
1551
1722
|
),
|
|
1552
1723
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
|
|
1573
|
-
h('thead', null,
|
|
1574
|
-
h('tr', null, [t('colModel'), t('peakGoPay'), t('peakOfficialPay'), t('peakVerdict')].map((title) => th(title))),
|
|
1575
|
-
),
|
|
1576
|
-
h('tbody', null, peak.models.map((row) =>
|
|
1577
|
-
h('tr', { key: row.model },
|
|
1578
|
-
cell(h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, row.model)),
|
|
1579
|
-
cell(priceCell(row.go.pay, row.go.factor > 1, row.cheaper === 'go')),
|
|
1580
|
-
cell(priceCell(row.official.pay, row.official.factor > 1, row.cheaper === 'official')),
|
|
1581
|
-
cell(row.cheaper === 'go'
|
|
1582
|
-
? t('peakGoCheaper', { percent: Math.round((1 - row.ratio) * 100) })
|
|
1583
|
-
: t('peakOfficialCheaper', { percent: Math.round((row.ratio - 1) * 100) }), { fontWeight: 600 }),
|
|
1724
|
+
router
|
|
1725
|
+
? card(
|
|
1726
|
+
cardHead(t('addAccount')),
|
|
1727
|
+
h('div', { style: { display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' } },
|
|
1728
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1729
|
+
t('colAccount'),
|
|
1730
|
+
h('input', {
|
|
1731
|
+
style: { ...input, minWidth: 200 }, placeholder: t('labelPlaceholder'), value: draft.label,
|
|
1732
|
+
onChange: (event) => setDraft({ ...draft, label: event.target.value }),
|
|
1733
|
+
}),
|
|
1734
|
+
),
|
|
1735
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1736
|
+
t('colKey'),
|
|
1737
|
+
h('input', {
|
|
1738
|
+
style: { ...input, minWidth: 320 }, type: 'password', placeholder: t('keyPlaceholder'), value: draft.key,
|
|
1739
|
+
onChange: (event) => setDraft({ ...draft, key: event.target.value }),
|
|
1740
|
+
}),
|
|
1741
|
+
),
|
|
1742
|
+
h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
|
|
1584
1743
|
),
|
|
1585
|
-
)
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1744
|
+
)
|
|
1745
|
+
: null,
|
|
1746
|
+
|
|
1747
|
+
shown
|
|
1748
|
+
? card(
|
|
1749
|
+
h('div', {
|
|
1750
|
+
style: {
|
|
1751
|
+
padding: '16px 18px', borderRadius: 10, fontSize: 18, fontWeight: 700, lineHeight: 1.5,
|
|
1752
|
+
color: verdictColor, background: `${verdictColor}22`, border: `1px solid ${verdictColor}80`,
|
|
1753
|
+
},
|
|
1754
|
+
}, verdict),
|
|
1755
|
+
demo ? h('div', { style: { fontSize: 12, opacity: .7 } }, t('demoNote')) : null,
|
|
1756
|
+
h('div', { style: { overflowX: 'auto', cursor: 'grab' }, ...draggable() },
|
|
1757
|
+
h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
|
|
1758
|
+
h('thead', null,
|
|
1759
|
+
h('tr', null, [t('colModel'), t('peakGoPay'), t('peakOfficialPay')].map((title) => th(title))),
|
|
1760
|
+
),
|
|
1761
|
+
h('tbody', null, shown.models.map((row) =>
|
|
1762
|
+
h('tr', { key: row.model },
|
|
1763
|
+
cell(prettyModel(row.model)),
|
|
1764
|
+
cell(priceCell(row.go.pay, row.go.factor > 1, row.cheaper === 'go')),
|
|
1765
|
+
cell(priceCell(row.official.pay, row.official.factor > 1, row.cheaper === 'official')),
|
|
1766
|
+
),
|
|
1767
|
+
)),
|
|
1768
|
+
),
|
|
1769
|
+
),
|
|
1770
|
+
h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
|
|
1771
|
+
h('button', {
|
|
1772
|
+
type: 'button', className: 'ocr-btn', style: btn,
|
|
1773
|
+
onClick: () => setDemo(!demo),
|
|
1774
|
+
}, demo ? t('demoOff') : t('demoOn')),
|
|
1775
|
+
),
|
|
1776
|
+
)
|
|
1777
|
+
: null,
|
|
1590
1778
|
|
|
1591
1779
|
card(
|
|
1592
1780
|
cardHead(t('modelsTitle')),
|
|
1593
|
-
h('div', {
|
|
1594
|
-
style: {
|
|
1595
|
-
overflowX: 'auto', maxHeight: '56vh', overflowY: 'auto', cursor: 'grab',
|
|
1596
|
-
borderRadius: 8, border: '1px solid rgba(127,127,127,.18)',
|
|
1597
|
-
},
|
|
1598
|
-
...draggable(),
|
|
1599
|
-
},
|
|
1600
|
-
h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
|
|
1601
|
-
h('thead', null,
|
|
1602
|
-
h('tr', null, [t('colRemaining'), t('colMonthlyUsed'), t('colWeeklyUsed'), t('colRolling'), t('colModel'), t('colCaps'), t('colRequests'), t('colTokens')].map((title) => th(title))),
|
|
1603
|
-
),
|
|
1604
|
-
h('tbody', null, rows),
|
|
1605
|
-
),
|
|
1606
|
-
),
|
|
1781
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, modelCards),
|
|
1607
1782
|
),
|
|
1608
1783
|
|
|
1609
|
-
h('div', { style: { opacity: .55, fontSize: 12,
|
|
1784
|
+
h('div', { style: { opacity: .55, fontSize: 12, lineHeight: 1.8 } },
|
|
1610
1785
|
t('footerOfficial'),
|
|
1611
1786
|
h('br'),
|
|
1612
1787
|
t('footerLocal'),
|
|
@@ -1625,9 +1800,27 @@ window.__ModuleLoader__.load({
|
|
|
1625
1800
|
);
|
|
1626
1801
|
}
|
|
1627
1802
|
|
|
1803
|
+
// Hover and press feedback cannot live in an inline style object, so the
|
|
1804
|
+
// bundle ships one small stylesheet for its buttons.
|
|
1805
|
+
const CSS = [
|
|
1806
|
+
'.ocr-btn { transition: background .12s ease, border-color .12s ease, transform .06s ease; }',
|
|
1807
|
+
'.ocr-btn:hover:not(:disabled) { background: rgba(127,127,127,.16); border-color: rgba(127,127,127,.75); }',
|
|
1808
|
+
'.ocr-btn:active:not(:disabled) { transform: translateY(1px); background: rgba(127,127,127,.26); }',
|
|
1809
|
+
'.ocr-btn:disabled { opacity: .45; cursor: default; }',
|
|
1810
|
+
].join('\n');
|
|
1811
|
+
const ensureStyles = () => {
|
|
1812
|
+
if (typeof document === 'undefined') return;
|
|
1813
|
+
if (document.getElementById('ocr-styles') !== null) return;
|
|
1814
|
+
const style = document.createElement('style');
|
|
1815
|
+
style.id = 'ocr-styles';
|
|
1816
|
+
style.textContent = CSS;
|
|
1817
|
+
document.head.appendChild(style);
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1628
1820
|
return {
|
|
1629
1821
|
inject: ['slots'],
|
|
1630
1822
|
apply(ctx) {
|
|
1823
|
+
ensureStyles();
|
|
1631
1824
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1632
1825
|
name: 'settings.section',
|
|
1633
1826
|
id: 'opencode-router',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-opencode-routing",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh plugin: OpenCode account pool with automatic A-to-B failover and quota routing, plus a per-account / per-model usage dashboard, in one Settings page.",
|
|
6
6
|
"exports": {
|
package/router.js
CHANGED
|
@@ -803,7 +803,9 @@ export function apply(ctx, config = {}) {
|
|
|
803
803
|
if (state.mode === 'fallback' && state.until > now) marks.push(state.until)
|
|
804
804
|
for (const entry of Object.values(state.quarantine)) if (entry.until > now) marks.push(entry.until)
|
|
805
805
|
for (const account of state.accounts) if (account.exhaustedUntil > now) marks.push(account.exhaustedUntil)
|
|
806
|
-
|
|
806
|
+
// Nothing is quarantined right now, but the periodic probe still runs: report
|
|
807
|
+
// when it will fire instead of leaving the page showing a dash.
|
|
808
|
+
return marks.length > 0 ? Math.min(...marks) : now + cfg.probeIntervalMs
|
|
807
809
|
})()
|
|
808
810
|
return {
|
|
809
811
|
version: 3,
|
|
@@ -965,6 +967,18 @@ export function apply(ctx, config = {}) {
|
|
|
965
967
|
|
|
966
968
|
async function handleApi(request, response) {
|
|
967
969
|
try {
|
|
970
|
+
if (request.method === 'GET') {
|
|
971
|
+
// Settings can ask for one account's key so "edit key" opens with the
|
|
972
|
+
// current value filled in. Local-only endpoint, never in the snapshot.
|
|
973
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
974
|
+
const reveal = url.searchParams.get('key')
|
|
975
|
+
if (reveal !== null) {
|
|
976
|
+
const account = state.accounts.find((entry) => entry.id === reveal)
|
|
977
|
+
if (account === undefined) throw new Error(`没有账户 ${reveal}`)
|
|
978
|
+
sendJson(response, 200, { id: account.id, key: (await accountKey(account)) ?? null })
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
}
|
|
968
982
|
if (request.method === 'POST') {
|
|
969
983
|
const text = await readBody(request)
|
|
970
984
|
let body
|
package/test.mjs
CHANGED
|
@@ -284,6 +284,10 @@ await crash('opencode-go', { code: 'QUOTA', status: 429, message: 'usage limit r
|
|
|
284
284
|
assert.equal((await call(flash)).provider, 'deepseek-account')
|
|
285
285
|
const reset = await api('POST', { action: 'reset' })
|
|
286
286
|
assert.equal(reset.body.mode, 'primary')
|
|
287
|
+
// Nothing is quarantined after a reset, but the periodic probe still has a
|
|
288
|
+
// next run: the page must show a time instead of a dash.
|
|
289
|
+
assert.equal(typeof reset.body.nextProbeAt, 'number', 'nextProbeAt is a timestamp, not null')
|
|
290
|
+
assert.ok(reset.body.nextProbeAt > Date.now(), 'nextProbeAt is in the future')
|
|
287
291
|
assert.equal((await call(flash)).provider, 'opencode-go')
|
|
288
292
|
}
|
|
289
293
|
console.log('plan-wide fallback ok')
|
|
@@ -365,6 +369,37 @@ console.log('usage view ok')
|
|
|
365
369
|
}
|
|
366
370
|
console.log('single-account fallback ok')
|
|
367
371
|
|
|
372
|
+
// "Edit key" pre-fills from the stored value; the snapshot never carries it
|
|
373
|
+
{
|
|
374
|
+
const reveal = async (query) => {
|
|
375
|
+
const handler = apiRoutes.get('/opencode-quota-router.json')
|
|
376
|
+
const response = {
|
|
377
|
+
status: 0,
|
|
378
|
+
body: undefined,
|
|
379
|
+
writeHead(status) {
|
|
380
|
+
this.status = status
|
|
381
|
+
},
|
|
382
|
+
end(chunk) {
|
|
383
|
+
this.body = JSON.parse(chunk)
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
await handler({ method: 'GET', url: `/opencode-quota-router.json${query}` }, response)
|
|
387
|
+
return response
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const hit = await reveal('?key=a1')
|
|
391
|
+
assert.equal(hit.status, 200)
|
|
392
|
+
assert.equal(hit.body.key, 'oc_sk_test', 'the stored key comes back for the editor')
|
|
393
|
+
|
|
394
|
+
const missing = await reveal('?key=zzz')
|
|
395
|
+
assert.equal(missing.status, 400, 'an unknown account is rejected')
|
|
396
|
+
assert.match(missing.body.error, /zzz/)
|
|
397
|
+
|
|
398
|
+
const snapshot = await api('GET')
|
|
399
|
+
assert.equal(JSON.stringify(snapshot.body).includes('oc_sk_test'), false, 'the snapshot stays masked')
|
|
400
|
+
}
|
|
401
|
+
console.log('reveal key ok')
|
|
402
|
+
|
|
368
403
|
for (const dispose of disposers) await dispose()
|
|
369
404
|
server.close()
|
|
370
405
|
console.log('all accounts checks passed')
|