dsh-opencode-routing 2.1.0 → 2.2.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/client.js +282 -222
- package/package.json +1 -1
- package/router.js +3 -1
- package/test.mjs +4 -0
package/client.js
CHANGED
|
@@ -109,7 +109,9 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
peakOfficialPay: '走官方直连你实付',
|
|
110
110
|
peakVerdict: '结论',
|
|
111
111
|
peakGoCheaper: '走 OpenCode 便宜 {percent}%',
|
|
112
|
-
|
|
112
|
+
verdictGo: '走 OpenCode 便宜 {percent}%',
|
|
113
|
+
verdictOfficial: '走官方直连便宜 {percent}%',
|
|
114
|
+
verdictExhausted: '本月额度已用尽,只能走官方直连',
|
|
113
115
|
peakFootnote: '按每 100 万输出 tokens 折算(编码账单主要来自输出),汇率 ¥{fx}/$。峰谷只影响额度烧多快,不改变结论:额度内走 OpenCode 始终比官方直连便宜;例外是额度用尽,或某个模型的额度先见底。',
|
|
114
116
|
footerOfficial: '上面「官方 5 小时 / 本周 / 本月」百分比来自 OpenCode 接口,是账户真实用量,别的客户端或别的机器用了也会算进去。',
|
|
115
117
|
footerLocal: '按模型的金额是本机估算:只统计这个 harness 的会话日志,用来拆开看模型分布;其它工具的调用没有本地记录。',
|
|
@@ -211,7 +213,9 @@ window.__ModuleLoader__.load({
|
|
|
211
213
|
peakOfficialPay: 'You pay via official API',
|
|
212
214
|
peakVerdict: 'Verdict',
|
|
213
215
|
peakGoCheaper: 'OpenCode is {percent}% cheaper',
|
|
214
|
-
|
|
216
|
+
verdictGo: 'OpenCode is {percent}% cheaper',
|
|
217
|
+
verdictOfficial: 'Official API is {percent}% cheaper',
|
|
218
|
+
verdictExhausted: 'This month’s allowance is used up, so only the official API is left',
|
|
215
219
|
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
220
|
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
221
|
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.',
|
|
@@ -313,7 +317,9 @@ window.__ModuleLoader__.load({
|
|
|
313
317
|
peakOfficialPay: '走官方直連你實付',
|
|
314
318
|
peakVerdict: '結論',
|
|
315
319
|
peakGoCheaper: '走 OpenCode 便宜 {percent}%',
|
|
316
|
-
|
|
320
|
+
verdictGo: '走 OpenCode 便宜 {percent}%',
|
|
321
|
+
verdictOfficial: '走官方直連便宜 {percent}%',
|
|
322
|
+
verdictExhausted: '本月額度已用盡,只能走官方直連',
|
|
317
323
|
peakFootnote: '依每 100 萬輸出 tokens 折算(編碼帳單主要來自輸出),匯率 ¥{fx}/$。峰谷只影響額度消耗速度,不改變結論:額度內走 OpenCode 始終比官方直連便宜;例外是額度用盡,或某個模型的額度先用完。',
|
|
318
324
|
footerOfficial: '上面「官方 5 小時/本週/本月」百分比來自 OpenCode 介面,是帳號真實用量,其他用戶端或機器用掉的也會算進去。',
|
|
319
325
|
footerLocal: '依模型的金額是本機估算:只統計這個 harness 的工作階段日誌,用來拆開看模型分布;其他工具的呼叫沒有本機記錄。',
|
|
@@ -1286,6 +1292,65 @@ window.__ModuleLoader__.load({
|
|
|
1286
1292
|
const data = state.usage;
|
|
1287
1293
|
const router = state.router;
|
|
1288
1294
|
const statusText = STATUS();
|
|
1295
|
+
const chinese = LANG === 'zh' || LANG === 'zh-tw';
|
|
1296
|
+
|
|
1297
|
+
// The router stores machine reasons ("allowance recovered"); the page says
|
|
1298
|
+
// what actually happened instead of echoing the enum.
|
|
1299
|
+
const reasonText = (reason) => {
|
|
1300
|
+
const raw = String(reason ?? '');
|
|
1301
|
+
const pick = (zh, en) => (chinese ? zh : en);
|
|
1302
|
+
const rules = [
|
|
1303
|
+
[/^allowance recovered/i, pick('额度已恢复,已切回 OpenCode 账户池', 'allowance recovered, back on the OpenCode pool')],
|
|
1304
|
+
[/^reset in Settings/i, pick('在设置里重置了封禁标记', 'marks were reset in Settings')],
|
|
1305
|
+
[/^selected in Settings/i, pick('在设置里手动设为当前账户', 'selected by hand in Settings')],
|
|
1306
|
+
[/^first account added/i, pick('首个账户已启用', 'the first account was enabled')],
|
|
1307
|
+
[/^initial$/i, pick('初始状态', 'initial')],
|
|
1308
|
+
[/^loaded$/i, pick('从状态文件恢复', 'restored from the state file')],
|
|
1309
|
+
[/^failover: (\S+) unavailable/i, pick('上一个账户不可用,已自动换下一个', 'the previous account went unavailable, switched to the next one')],
|
|
1310
|
+
[/^account failover after (\w+)/i, pick('鉴权失败($1),已换下一个账户', 'authentication failed ($1), switched to the next account')],
|
|
1311
|
+
[/^quota failover from (\S+)/i, pick('$1 额度用尽,已换下一个账户', '$1 ran out of allowance, switched to the next account')],
|
|
1312
|
+
[/^every OpenCode account is out of allowance/i, pick('所有账户额度都用完了,暂时走官方直连', 'every account is out of allowance, using the official API for now')],
|
|
1313
|
+
[/^OpenCode authentication failed \((\w+)\)/i, pick('OpenCode 鉴权失败($1),暂时走官方直连', 'OpenCode authentication failed ($1), using the official API for now')],
|
|
1314
|
+
[/^OpenCode quota window exhausted/i, pick('本窗口额度用尽,暂时走官方直连', 'this allowance window is used up, using the official API for now')],
|
|
1315
|
+
[/^OpenCode quota exhausted/i, pick('额度用尽,暂时走官方直连', 'out of allowance, using the official API for now')],
|
|
1316
|
+
[/^active account (\S+) removed/i, pick('当前账户被删除,已自动换下一个', 'the active account was deleted, switched to the next one')],
|
|
1317
|
+
[/^auth \((.+)\)/i, pick('鉴权失败:$1', 'authentication failed: $1')],
|
|
1318
|
+
];
|
|
1319
|
+
for (const [pattern, text] of rules) {
|
|
1320
|
+
if (pattern.test(raw)) return raw.replace(pattern, text);
|
|
1321
|
+
}
|
|
1322
|
+
return raw;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// A vendor badge per model family: the mark and the tint both come from the
|
|
1326
|
+
// family, so a card's gradient matches its avatar.
|
|
1327
|
+
const VENDORS = [
|
|
1328
|
+
[/^deepseek/i, { mark: 'DS', name: 'DeepSeek', color: '#4d6bfe' }],
|
|
1329
|
+
[/^glm/i, { mark: 'GLM', name: 'Zhipu', color: '#3b6cf6' }],
|
|
1330
|
+
[/^gpt/i, { mark: 'GPT', name: 'OpenAI', color: '#10a37f' }],
|
|
1331
|
+
[/^claude/i, { mark: 'C', name: 'Anthropic', color: '#d97757' }],
|
|
1332
|
+
[/^grok/i, { mark: 'X', name: 'xAI', color: '#64748b' }],
|
|
1333
|
+
[/^qwen/i, { mark: 'Q', name: 'Qwen', color: '#615ced' }],
|
|
1334
|
+
[/^kimi/i, { mark: 'K', name: 'Moonshot', color: '#8b5cf6' }],
|
|
1335
|
+
[/^minimax/i, { mark: 'MM', name: 'MiniMax', color: '#ff6b00' }],
|
|
1336
|
+
[/^mimo/i, { mark: 'Mi', name: 'MiMo', color: '#ff6900' }],
|
|
1337
|
+
[/^longcat/i, { mark: 'LC', name: 'LongCat', color: '#f59e0b' }],
|
|
1338
|
+
[/^hy\d/i, { mark: 'H', name: 'Hunyuan', color: '#0057ff' }],
|
|
1339
|
+
[/^muse/i, { mark: 'M', name: 'Muse', color: '#a855f7' }],
|
|
1340
|
+
[/free$/i, { mark: '∞', name: 'Free', color: '#3c9d5d' }],
|
|
1341
|
+
];
|
|
1342
|
+
const vendorOf = (model) => {
|
|
1343
|
+
const hit = VENDORS.find(([pattern]) => pattern.test(model));
|
|
1344
|
+
if (hit) return hit[1];
|
|
1345
|
+
return { mark: String(model).slice(0, 2).toUpperCase(), name: model, color: '#6b7280' };
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
const CAP_ICON = { text: '📝', image: '🖼️', video: '🎬', audio: '🎵', pdf: '📄' };
|
|
1349
|
+
const capIcon = (label) => {
|
|
1350
|
+
if (label === t('capsTools')) return '🛠️';
|
|
1351
|
+
if (label === t('capsReasoning')) return '🧠';
|
|
1352
|
+
return CAP_ICON[label] ?? '•';
|
|
1353
|
+
};
|
|
1289
1354
|
|
|
1290
1355
|
const routerAccounts = new Map((router?.accounts ?? []).map((account) => [account.id, account]));
|
|
1291
1356
|
const usageAccounts = data?.accounts ?? [];
|
|
@@ -1311,86 +1376,118 @@ window.__ModuleLoader__.load({
|
|
|
1311
1376
|
};
|
|
1312
1377
|
});
|
|
1313
1378
|
|
|
1314
|
-
|
|
1379
|
+
// Layout primitives: every block is a card with its own padding and gap, so
|
|
1380
|
+
// buttons never sit flush against each other or against the table edges.
|
|
1381
|
+
const card = (...children) => h('div', {
|
|
1382
|
+
style: {
|
|
1383
|
+
padding: '14px 16px', border: '1px solid rgba(127,127,127,.25)', borderRadius: 10,
|
|
1384
|
+
background: 'var(--dsh-surface, rgba(127,127,127,.04))',
|
|
1385
|
+
display: 'flex', flexDirection: 'column', gap: 12,
|
|
1386
|
+
},
|
|
1387
|
+
}, ...children);
|
|
1388
|
+
|
|
1389
|
+
const cardHead = (text, extra) => h('div', {
|
|
1390
|
+
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1391
|
+
},
|
|
1392
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, text),
|
|
1393
|
+
extra ?? null,
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
const stat = (label, value) => h('div', {
|
|
1397
|
+
style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 96 },
|
|
1398
|
+
},
|
|
1399
|
+
h('span', { style: { fontSize: 11, opacity: .55 } }, label),
|
|
1400
|
+
h('span', { style: { fontSize: 13, fontWeight: 500 } }, value),
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
const tools = (...children) => h('div', {
|
|
1404
|
+
style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
1405
|
+
}, ...children);
|
|
1406
|
+
|
|
1407
|
+
const pill = (text, color) => h('span', {
|
|
1408
|
+
style: {
|
|
1409
|
+
fontSize: 11, padding: '1px 8px', borderRadius: 999,
|
|
1410
|
+
border: `1px solid ${color}99`, color,
|
|
1411
|
+
},
|
|
1412
|
+
}, text);
|
|
1413
|
+
|
|
1414
|
+
const accountCards = accountList.map((account, index) => {
|
|
1315
1415
|
const [text, color] = statusText[account.status] ?? [
|
|
1316
1416
|
account.usageError ? t('statusError') : account.enabled === false ? t('statusDisabled') : account.active ? t('statusActive') : t('statusSpare'),
|
|
1317
1417
|
account.usageError ? '#d9534f' : 'inherit',
|
|
1318
1418
|
];
|
|
1319
|
-
return h(
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1419
|
+
return h('div', {
|
|
1420
|
+
key: account.id,
|
|
1421
|
+
style: {
|
|
1422
|
+
display: 'flex', flexDirection: 'column', gap: 12, padding: '12px 14px', borderRadius: 10,
|
|
1423
|
+
border: `1px solid ${account.active ? 'rgba(60,157,93,.45)' : 'rgba(127,127,127,.22)'}`,
|
|
1424
|
+
background: account.active ? 'rgba(60,157,93,.06)' : 'rgba(127,127,127,.03)',
|
|
1425
|
+
},
|
|
1426
|
+
},
|
|
1427
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
1428
|
+
h('span', { style: { opacity: .45, fontSize: 11 } }, `#${index + 1}`),
|
|
1429
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, account.label),
|
|
1430
|
+
account.active ? pill(t('statusCurrent'), '#3c9d5d') : null,
|
|
1431
|
+
h('span', {
|
|
1432
|
+
style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 12, opacity: account.hint ? .65 : .45 },
|
|
1433
|
+
}, account.hint ?? t('notConfigured')),
|
|
1434
|
+
h('span', { style: { flex: 1 } }),
|
|
1435
|
+
h('span', { style: { color, fontSize: 12 } },
|
|
1335
1436
|
text,
|
|
1336
|
-
account.exhaustedUntil ? ` · ${clock(account.exhaustedUntil)}` : '',
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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'))),
|
|
1437
|
+
account.exhaustedUntil ? ` · ${clock(account.exhaustedUntil)}` : ''),
|
|
1438
|
+
),
|
|
1439
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 12 } },
|
|
1440
|
+
stat(t('colRolling'), official(account.usage?.rolling)),
|
|
1441
|
+
stat(t('colWeekly'), official(account.usage?.weekly)),
|
|
1442
|
+
stat(t('colMonthly'), official(account.usage?.monthly)),
|
|
1370
1443
|
),
|
|
1444
|
+
router
|
|
1445
|
+
? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', paddingTop: 2 } },
|
|
1446
|
+
h('button', { type: 'button', style: btn, onClick: () => setInspecting(account.id) }, t('viewUsage')),
|
|
1447
|
+
h('button', { type: 'button', style: btn, disabled: busy || account.active, onClick: () => void act({ action: 'activate', id: account.id }) }, t('setActive')),
|
|
1448
|
+
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')),
|
|
1449
|
+
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'up' }); } }, '↑'),
|
|
1450
|
+
h('button', { type: 'button', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'down' }); } }, '↓'),
|
|
1451
|
+
h('button', {
|
|
1452
|
+
type: 'button', style: btn, disabled: busy,
|
|
1453
|
+
onClick: () => { setEditing(editing === account.id ? null : account.id); setKeyDraft(''); },
|
|
1454
|
+
}, t('editKey')),
|
|
1455
|
+
h('button', {
|
|
1456
|
+
type: 'button', style: { ...btn, color: '#d9534f' }, disabled: busy,
|
|
1457
|
+
onClick: () => {
|
|
1458
|
+
if (accountList.length <= 1) {
|
|
1459
|
+
setActionError(t('keepOne'));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
void act({ action: 'remove', id: account.id });
|
|
1463
|
+
},
|
|
1464
|
+
}, t('remove')),
|
|
1465
|
+
)
|
|
1466
|
+
: h('span', { style: { opacity: .5, fontSize: 12 } }, t('readonly')),
|
|
1371
1467
|
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
|
-
),
|
|
1468
|
+
? h('div', {
|
|
1469
|
+
style: {
|
|
1470
|
+
display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
|
|
1471
|
+
paddingTop: 10, borderTop: '1px solid rgba(127,127,127,.18)',
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
h('span', { style: { opacity: .7, fontSize: 12 } }, t('newKeyFor', { ref: account.keyRef ?? '' })),
|
|
1475
|
+
h('input', {
|
|
1476
|
+
style: { ...input, minWidth: 300 }, type: 'password', placeholder: 'oc_sk_…', value: keyDraft,
|
|
1477
|
+
onChange: (event) => setKeyDraft(event.target.value),
|
|
1478
|
+
}),
|
|
1479
|
+
h('button', {
|
|
1480
|
+
type: 'button', style: btn, disabled: busy || keyDraft.trim().length < 8,
|
|
1481
|
+
onClick: async () => {
|
|
1482
|
+
const ok = await act({ action: 'update', id: account.id, key: keyDraft.trim() });
|
|
1483
|
+
if (ok) {
|
|
1484
|
+
setEditing(null);
|
|
1485
|
+
setKeyDraft('');
|
|
1486
|
+
setNotice(t('keyUpdated'));
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
}, t('save')),
|
|
1490
|
+
h('button', { type: 'button', style: btn, onClick: () => { setEditing(null); setKeyDraft(''); } }, t('cancel')),
|
|
1394
1491
|
)
|
|
1395
1492
|
: null,
|
|
1396
1493
|
);
|
|
@@ -1408,32 +1505,62 @@ window.__ModuleLoader__.load({
|
|
|
1408
1505
|
}
|
|
1409
1506
|
};
|
|
1410
1507
|
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1508
|
+
const modelCards = (data?.models ?? []).map((row) => {
|
|
1509
|
+
const vendor = vendorOf(row.model);
|
|
1510
|
+
const chips = capabilityTags(row);
|
|
1511
|
+
return h('div', {
|
|
1512
|
+
key: row.model,
|
|
1513
|
+
style: {
|
|
1514
|
+
display: 'flex', flexDirection: 'column', gap: 12, padding: '14px 16px', borderRadius: 10,
|
|
1515
|
+
border: `1px solid ${vendor.color}3d`,
|
|
1516
|
+
background: `linear-gradient(135deg, ${vendor.color}2e, ${vendor.color}0f 55%, rgba(127,127,127,.03))`,
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
1520
|
+
h('span', {
|
|
1424
1521
|
style: {
|
|
1425
|
-
|
|
1426
|
-
|
|
1522
|
+
width: 30, height: 30, borderRadius: '50%', flex: 'none',
|
|
1523
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1524
|
+
background: `${vendor.color}26`, border: `1px solid ${vendor.color}80`,
|
|
1525
|
+
color: vendor.color, fontSize: 11, fontWeight: 700,
|
|
1427
1526
|
},
|
|
1428
|
-
},
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1527
|
+
}, vendor.mark),
|
|
1528
|
+
h('span', { style: { fontWeight: 600, fontSize: 13 } }, row.model),
|
|
1529
|
+
row.free ? pill(t('unlimited'), '#3c9d5d') : null,
|
|
1530
|
+
),
|
|
1531
|
+
chips.length > 0
|
|
1532
|
+
? h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
1533
|
+
chips.map((label) => h('span', {
|
|
1534
|
+
key: label,
|
|
1535
|
+
style: {
|
|
1536
|
+
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
1537
|
+
padding: '2px 8px', borderRadius: 999, fontSize: 11,
|
|
1538
|
+
border: `1px solid ${vendor.color}59`, background: `${vendor.color}14`, color: 'inherit',
|
|
1539
|
+
},
|
|
1540
|
+
}, h('span', null, capIcon(label)), h('span', null, label))),
|
|
1541
|
+
)
|
|
1542
|
+
: null,
|
|
1543
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: 12 } },
|
|
1544
|
+
stat(t('colRemaining'), row.free ? t('unlimited') : money(row.remaining)),
|
|
1545
|
+
stat(t('colMonthlyUsed'), row.free ? t('unlimited') : `${money(row.monthly)} / ${money(row.limit)}`),
|
|
1546
|
+
stat(t('colWeeklyUsed'), row.free ? t('unlimited') : money(row.weekly)),
|
|
1547
|
+
stat(t('colRolling'), percentText(row.rolling, row.rollingLimit, row.free)),
|
|
1548
|
+
stat(t('colRequests'), String(row.requests)),
|
|
1549
|
+
),
|
|
1550
|
+
);
|
|
1551
|
+
});
|
|
1434
1552
|
|
|
1435
1553
|
const peak = data?.peak;
|
|
1436
1554
|
const quotaSpent = typeof data?.usage?.monthly?.percent === 'number' ? data.usage.monthly.percent : null;
|
|
1555
|
+
const exhausted = quotaSpent !== null && quotaSpent >= 100;
|
|
1556
|
+
const allGo = peak ? peak.models.every((row) => row.cheaper === 'go') : false;
|
|
1557
|
+
const saving = peak && peak.models.length > 0
|
|
1558
|
+
? Math.round(Math.max(...peak.models.map((row) => (row.cheaper === 'go' ? (1 - row.ratio) * 100 : (row.ratio - 1) * 100))))
|
|
1559
|
+
: 0;
|
|
1560
|
+
const verdict = exhausted
|
|
1561
|
+
? t('verdictExhausted')
|
|
1562
|
+
: allGo ? t('verdictGo', { percent: saving }) : t('verdictOfficial', { percent: saving });
|
|
1563
|
+
const verdictColor = exhausted ? '#e0a030' : allGo ? '#3c9d5d' : '#e0a030';
|
|
1437
1564
|
const priceCell = (price, peakFlag, winner) => h('span', {
|
|
1438
1565
|
style: winner
|
|
1439
1566
|
? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
|
|
@@ -1442,34 +1569,6 @@ window.__ModuleLoader__.load({
|
|
|
1442
1569
|
|
|
1443
1570
|
const inspectingAccount = accountList.find((account) => account.id === inspecting) ?? null;
|
|
1444
1571
|
|
|
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
1572
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
|
|
1474
1573
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
|
|
1475
1574
|
h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
|
|
@@ -1483,19 +1582,13 @@ window.__ModuleLoader__.load({
|
|
|
1483
1582
|
|
|
1484
1583
|
card(
|
|
1485
1584
|
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'))
|
|
1585
|
+
? pill(router.mode === 'fallback' ? t('modeFallback') : t('modePrimary'), router.mode === 'fallback' ? '#e0a030' : '#3c9d5d')
|
|
1493
1586
|
: h('span', { style: { opacity: .7, fontSize: 12 } }, t('routerMissing'))),
|
|
1494
|
-
router?.reason ? h('div', { style: { fontSize: 12, opacity: .
|
|
1587
|
+
router?.reason ? h('div', { style: { fontSize: 12, opacity: .65 } }, t('modeReason', { reason: reasonText(router.reason) })) : null,
|
|
1495
1588
|
router
|
|
1496
1589
|
? h('div', { style: { display: 'flex', gap: 32, flexWrap: 'wrap' } },
|
|
1497
1590
|
stat(t('activeAccount'), router.activeLabel ?? t('none')),
|
|
1498
|
-
stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) :
|
|
1591
|
+
stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) : when(Date.now() + (router.probeIntervalMs ?? 0))),
|
|
1499
1592
|
stat(t('colStatus'), router.mode === 'fallback' ? t('modeFallback') : t('modePrimary')),
|
|
1500
1593
|
)
|
|
1501
1594
|
: null,
|
|
@@ -1510,103 +1603,70 @@ window.__ModuleLoader__.load({
|
|
|
1510
1603
|
|
|
1511
1604
|
card(
|
|
1512
1605
|
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
|
-
),
|
|
1606
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
|
|
1607
|
+
accountCards.length > 0
|
|
1608
|
+
? accountCards
|
|
1609
|
+
: h('div', { style: { opacity: .6, fontSize: 12 } }, t('noAccounts')),
|
|
1528
1610
|
),
|
|
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
1611
|
),
|
|
1552
1612
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1613
|
+
router
|
|
1614
|
+
? card(
|
|
1615
|
+
cardHead(t('addAccount')),
|
|
1616
|
+
h('div', { style: { display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' } },
|
|
1617
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1618
|
+
t('colAccount'),
|
|
1619
|
+
h('input', {
|
|
1620
|
+
style: { ...input, minWidth: 200 }, placeholder: t('labelPlaceholder'), value: draft.label,
|
|
1621
|
+
onChange: (event) => setDraft({ ...draft, label: event.target.value }),
|
|
1622
|
+
}),
|
|
1623
|
+
),
|
|
1624
|
+
h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
|
|
1625
|
+
t('colKey'),
|
|
1626
|
+
h('input', {
|
|
1627
|
+
style: { ...input, minWidth: 320 }, type: 'password', placeholder: t('keyPlaceholder'), value: draft.key,
|
|
1628
|
+
onChange: (event) => setDraft({ ...draft, key: event.target.value }),
|
|
1629
|
+
}),
|
|
1630
|
+
),
|
|
1631
|
+
h('button', { type: 'button', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
|
|
1632
|
+
),
|
|
1633
|
+
)
|
|
1634
|
+
: null,
|
|
1635
|
+
|
|
1636
|
+
peak
|
|
1637
|
+
? card(
|
|
1638
|
+
h('div', {
|
|
1639
|
+
style: {
|
|
1640
|
+
padding: '16px 18px', borderRadius: 10, fontSize: 18, fontWeight: 700, lineHeight: 1.5,
|
|
1641
|
+
color: verdictColor, background: `${verdictColor}22`, border: `1px solid ${verdictColor}80`,
|
|
1642
|
+
},
|
|
1643
|
+
}, verdict),
|
|
1644
|
+
h('div', { style: { overflowX: 'auto', cursor: 'grab' }, ...draggable() },
|
|
1645
|
+
h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
|
|
1646
|
+
h('thead', null,
|
|
1647
|
+
h('tr', null, [t('colModel'), t('peakGoPay'), t('peakOfficialPay'), t('peakVerdict')].map((title) => th(title))),
|
|
1648
|
+
),
|
|
1649
|
+
h('tbody', null, peak.models.map((row) =>
|
|
1650
|
+
h('tr', { key: row.model },
|
|
1651
|
+
cell(h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, row.model)),
|
|
1652
|
+
cell(priceCell(row.go.pay, row.go.factor > 1, row.cheaper === 'go')),
|
|
1653
|
+
cell(priceCell(row.official.pay, row.official.factor > 1, row.cheaper === 'official')),
|
|
1654
|
+
cell(row.cheaper === 'go'
|
|
1655
|
+
? t('peakGoCheaper', { percent: Math.round((1 - row.ratio) * 100) })
|
|
1656
|
+
: t('peakOfficialCheaper', { percent: Math.round((row.ratio - 1) * 100) }), { fontWeight: 600 }),
|
|
1657
|
+
),
|
|
1658
|
+
)),
|
|
1659
|
+
),
|
|
1584
1660
|
),
|
|
1585
|
-
)
|
|
1586
|
-
|
|
1587
|
-
h('div', { style: { opacity: .55, fontSize: 11, marginTop: 6, lineHeight: 1.6 } },
|
|
1588
|
-
t('peakFootnote', { fx: peak.cnyPerUsd })),
|
|
1589
|
-
),
|
|
1661
|
+
)
|
|
1662
|
+
: null,
|
|
1590
1663
|
|
|
1591
1664
|
card(
|
|
1592
1665
|
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
|
-
),
|
|
1666
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, modelCards),
|
|
1607
1667
|
),
|
|
1608
1668
|
|
|
1609
|
-
h('div', { style: { opacity: .55, fontSize: 12,
|
|
1669
|
+
h('div', { style: { opacity: .55, fontSize: 12, lineHeight: 1.8 } },
|
|
1610
1670
|
t('footerOfficial'),
|
|
1611
1671
|
h('br'),
|
|
1612
1672
|
t('footerLocal'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-opencode-routing",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
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,
|
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')
|