dsh-opencode-routing 2.2.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 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,8 +101,8 @@ 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 你实付',
@@ -112,6 +112,9 @@ window.__ModuleLoader__.load({
112
112
  verdictGo: '走 OpenCode 便宜 {percent}%',
113
113
  verdictOfficial: '走官方直连便宜 {percent}%',
114
114
  verdictExhausted: '本月额度已用尽,只能走官方直连',
115
+ demoOn: '看官方更便宜的样子',
116
+ demoOff: '回到真实数据',
117
+ demoNote: '示例数据:假设某个模型走 OpenCode 反而更贵(现实中只有额度用尽才会发生)。',
115
118
  peakFootnote: '按每 100 万输出 tokens 折算(编码账单主要来自输出),汇率 ¥{fx}/$。峰谷只影响额度烧多快,不改变结论:额度内走 OpenCode 始终比官方直连便宜;例外是额度用尽,或某个模型的额度先见底。',
116
119
  footerOfficial: '上面「官方 5 小时 / 本周 / 本月」百分比来自 OpenCode 接口,是账户真实用量,别的客户端或别的机器用了也会算进去。',
117
120
  footerLocal: '按模型的金额是本机估算:只统计这个 harness 的会话日志,用来拆开看模型分布;其它工具的调用没有本地记录。',
@@ -144,7 +147,7 @@ window.__ModuleLoader__.load({
144
147
  setActive: 'Make active',
145
148
  disable: 'Disable',
146
149
  enable: 'Enable',
147
- editKey: 'Change key',
150
+ editKey: 'Edit key',
148
151
  remove: 'Delete',
149
152
  viewUsage: 'View usage',
150
153
  keyUpdated: 'Key updated',
@@ -216,6 +219,9 @@ window.__ModuleLoader__.load({
216
219
  verdictGo: 'OpenCode is {percent}% cheaper',
217
220
  verdictOfficial: 'Official API is {percent}% cheaper',
218
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.',
219
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.',
220
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.',
221
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.',
@@ -248,7 +254,7 @@ window.__ModuleLoader__.load({
248
254
  setActive: '設為目前',
249
255
  disable: '停用',
250
256
  enable: '啟用',
251
- editKey: '更換金鑰',
257
+ editKey: '編輯金鑰',
252
258
  remove: '刪除',
253
259
  viewUsage: '查看用量',
254
260
  keyUpdated: '金鑰已更新',
@@ -309,8 +315,8 @@ window.__ModuleLoader__.load({
309
315
  peakGoOff: '谷段',
310
316
  peakOfficialPeak: '峰段(價格 2 倍)',
311
317
  peakOfficialOff: '谷段',
312
- peakAtPeak: '峰段',
313
- peakOffPeak: '谷段',
318
+ peakAtPeak: '高峰期',
319
+ peakOffPeak: '低谷期',
314
320
  peakHoliday: '——今天是中國法定假日,官方整天算谷,OpenCode 照算峰',
315
321
  peakExhausted: '但本月額度已用盡,只能走官方。',
316
322
  peakGoPay: '走 OpenCode 你實付',
@@ -320,6 +326,9 @@ window.__ModuleLoader__.load({
320
326
  verdictGo: '走 OpenCode 便宜 {percent}%',
321
327
  verdictOfficial: '走官方直連便宜 {percent}%',
322
328
  verdictExhausted: '本月額度已用盡,只能走官方直連',
329
+ demoOn: '看官方更便宜的樣子',
330
+ demoOff: '回到真實資料',
331
+ demoNote: '範例資料:假設某個模型走 OpenCode 反而更貴(現實中只有額度用盡才會發生)。',
323
332
  peakFootnote: '依每 100 萬輸出 tokens 折算(編碼帳單主要來自輸出),匯率 ¥{fx}/$。峰谷只影響額度消耗速度,不改變結論:額度內走 OpenCode 始終比官方直連便宜;例外是額度用盡,或某個模型的額度先用完。',
324
333
  footerOfficial: '上面「官方 5 小時/本週/本月」百分比來自 OpenCode 介面,是帳號真實用量,其他用戶端或機器用掉的也會算進去。',
325
334
  footerLocal: '依模型的金額是本機估算:只統計這個 harness 的工作階段日誌,用來拆開看模型分布;其他工具的呼叫沒有本機記錄。',
@@ -1090,14 +1099,62 @@ window.__ModuleLoader__.load({
1090
1099
  const when = (ms) => {
1091
1100
  if (!ms) return t('none');
1092
1101
  const delta = ms - Date.now();
1093
- if (delta <= 0) return localeOf(navigator.language) === 'zh' || LANG === 'zh-tw' ? '已到期' : 'expired';
1094
- const minutes = Math.round(delta / 60000);
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);
1095
1105
  const relative = LANG === 'zh' || LANG === 'zh-tw'
1096
- ? `${minutes < 60 ? `${minutes} 分钟` : `${Math.round(minutes / 60)} 小时`}后`
1106
+ ? minutes < 60
1107
+ ? `${minutes} 分 ${String(seconds).padStart(2, '0')} 秒后`
1108
+ : `${Math.round(minutes / 60)} 小时后`
1097
1109
  : new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1098
1110
  return relative;
1099
1111
  };
1100
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
+
1101
1158
  const btn = {
1102
1159
  padding: '5px 12px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
1103
1160
  background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: 12,
@@ -1187,9 +1244,10 @@ window.__ModuleLoader__.load({
1187
1244
 
1188
1245
  function UsageDialog({ account, busy, onProbe, onClose }) {
1189
1246
  if (!account) return null;
1247
+ const skin = theme();
1190
1248
  return h('div', {
1191
1249
  style: {
1192
- position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 1200,
1250
+ position: 'fixed', inset: 0, background: skin.scrim, zIndex: 1200,
1193
1251
  display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
1194
1252
  },
1195
1253
  onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
@@ -1197,8 +1255,8 @@ window.__ModuleLoader__.load({
1197
1255
  h('div', {
1198
1256
  style: {
1199
1257
  minWidth: 380, maxWidth: 520, width: '100%', padding: '16px 18px', borderRadius: 10,
1200
- background: 'var(--dsh-surface, #1b1b1b)', color: 'inherit',
1201
- border: '1px solid rgba(127,127,127,.35)', boxShadow: '0 18px 48px rgba(0,0,0,.45)',
1258
+ background: skin.panel, color: skin.text, opacity: 1,
1259
+ border: `1px solid ${skin.line}`, boxShadow: '0 18px 48px rgba(0,0,0,.45)',
1202
1260
  },
1203
1261
  },
1204
1262
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } },
@@ -1207,7 +1265,7 @@ window.__ModuleLoader__.load({
1207
1265
  style: { fontSize: 11, padding: '1px 6px', borderRadius: 999, border: '1px solid rgba(60,157,93,.6)', color: '#3c9d5d' },
1208
1266
  }, t('statusCurrent')) : null,
1209
1267
  h('span', { style: { flex: 1 } }),
1210
- h('button', { type: 'button', style: btn, onClick: onClose }, t('close')),
1268
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: onClose }, t('close')),
1211
1269
  ),
1212
1270
  h('div', { style: { fontSize: 12, opacity: .6, marginBottom: 12 } },
1213
1271
  account.hint ? h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, account.hint) : t('notConfigured')),
@@ -1218,7 +1276,7 @@ window.__ModuleLoader__.load({
1218
1276
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
1219
1277
  account.checkedAt ? h('span', { style: { fontSize: 11, opacity: .55 } }, t('probedAt', { time: clock(account.checkedAt) })) : null,
1220
1278
  h('span', { style: { flex: 1 } }),
1221
- 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')),
1222
1280
  ),
1223
1281
  h('div', { style: { fontSize: 11, opacity: .55, marginTop: 12, lineHeight: 1.6 } }, t('dialogNote')),
1224
1282
  ),
@@ -1234,6 +1292,14 @@ window.__ModuleLoader__.load({
1234
1292
  const [keyDraft, setKeyDraft] = React.useState('');
1235
1293
  const [inspecting, setInspecting] = React.useState(null);
1236
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
+ }, []);
1237
1303
 
1238
1304
  const load = React.useCallback(async (force) => {
1239
1305
  try {
@@ -1275,6 +1341,18 @@ window.__ModuleLoader__.load({
1275
1341
  }
1276
1342
  }, [load]);
1277
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
+
1278
1356
  React.useEffect(() => {
1279
1357
  let alive = true;
1280
1358
  const run = (force) => {
@@ -1324,16 +1402,26 @@ window.__ModuleLoader__.load({
1324
1402
 
1325
1403
  // A vendor badge per model family: the mark and the tint both come from the
1326
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
+
1327
1415
  const VENDORS = [
1328
- [/^deepseek/i, { mark: 'DS', name: 'DeepSeek', color: '#4d6bfe' }],
1416
+ [/^deepseek/i, { mark: 'DS', name: 'DeepSeek', color: '#4d6bfe', logo: 'deepseek' }],
1329
1417
  [/^glm/i, { mark: 'GLM', name: 'Zhipu', color: '#3b6cf6' }],
1330
1418
  [/^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' }],
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' }],
1337
1425
  [/^longcat/i, { mark: 'LC', name: 'LongCat', color: '#f59e0b' }],
1338
1426
  [/^hy\d/i, { mark: 'H', name: 'Hunyuan', color: '#0057ff' }],
1339
1427
  [/^muse/i, { mark: 'M', name: 'Muse', color: '#a855f7' }],
@@ -1341,8 +1429,9 @@ window.__ModuleLoader__.load({
1341
1429
  ];
1342
1430
  const vendorOf = (model) => {
1343
1431
  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' };
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;
1346
1435
  };
1347
1436
 
1348
1437
  const CAP_ICON = { text: '📝', image: '🖼️', video: '🎬', audio: '🎵', pdf: '📄' };
@@ -1443,17 +1532,26 @@ window.__ModuleLoader__.load({
1443
1532
  ),
1444
1533
  router
1445
1534
  ? 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' }); } }, '↓'),
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' }); } }, '↓'),
1451
1540
  h('button', {
1452
- type: 'button', style: btn, disabled: busy,
1453
- onClick: () => { setEditing(editing === account.id ? null : account.id); setKeyDraft(''); },
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
+ },
1454
1552
  }, t('editKey')),
1455
1553
  h('button', {
1456
- type: 'button', style: { ...btn, color: '#d9534f' }, disabled: busy,
1554
+ type: 'button', className: 'ocr-btn', style: { ...btn, color: '#d9534f' }, disabled: busy,
1457
1555
  onClick: () => {
1458
1556
  if (accountList.length <= 1) {
1459
1557
  setActionError(t('keepOne'));
@@ -1477,7 +1575,7 @@ window.__ModuleLoader__.load({
1477
1575
  onChange: (event) => setKeyDraft(event.target.value),
1478
1576
  }),
1479
1577
  h('button', {
1480
- type: 'button', style: btn, disabled: busy || keyDraft.trim().length < 8,
1578
+ type: 'button', className: 'ocr-btn', style: btn, disabled: busy || keyDraft.trim().length < 8,
1481
1579
  onClick: async () => {
1482
1580
  const ok = await act({ action: 'update', id: account.id, key: keyDraft.trim() });
1483
1581
  if (ok) {
@@ -1487,7 +1585,7 @@ window.__ModuleLoader__.load({
1487
1585
  }
1488
1586
  },
1489
1587
  }, t('save')),
1490
- h('button', { type: 'button', style: btn, onClick: () => { setEditing(null); setKeyDraft(''); } }, t('cancel')),
1588
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: () => { setEditing(null); setKeyDraft(''); } }, t('cancel')),
1491
1589
  )
1492
1590
  : null,
1493
1591
  );
@@ -1524,8 +1622,14 @@ window.__ModuleLoader__.load({
1524
1622
  background: `${vendor.color}26`, border: `1px solid ${vendor.color}80`,
1525
1623
  color: vendor.color, fontSize: 11, fontWeight: 700,
1526
1624
  },
1527
- }, vendor.mark),
1528
- h('span', { style: { fontWeight: 600, fontSize: 13 } }, row.model),
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)),
1529
1633
  row.free ? pill(t('unlimited'), '#3c9d5d') : null,
1530
1634
  ),
1531
1635
  chips.length > 0
@@ -1553,14 +1657,21 @@ window.__ModuleLoader__.load({
1553
1657
  const peak = data?.peak;
1554
1658
  const quotaSpent = typeof data?.usage?.monthly?.percent === 'number' ? data.usage.monthly.percent : null;
1555
1659
  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))))
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))))
1559
1670
  : 0;
1560
- const verdict = exhausted
1671
+ const verdict = exhausted && !demo
1561
1672
  ? t('verdictExhausted')
1562
1673
  : allGo ? t('verdictGo', { percent: saving }) : t('verdictOfficial', { percent: saving });
1563
- const verdictColor = exhausted ? '#e0a030' : allGo ? '#3c9d5d' : '#e0a030';
1674
+ const verdictColor = exhausted && !demo ? '#e0a030' : allGo ? '#3c9d5d' : '#e0a030';
1564
1675
  const priceCell = (price, peakFlag, winner) => h('span', {
1565
1676
  style: winner
1566
1677
  ? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
@@ -1574,7 +1685,7 @@ window.__ModuleLoader__.load({
1574
1685
  h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
1575
1686
  state.phase === 'ready' ? h('span', { style: { opacity: .55, fontSize: 12 } }, t('updatedAt', { time: clock(data?.checkedAt) })) : null,
1576
1687
  h('span', { style: { flex: 1 } }),
1577
- 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')),
1578
1689
  ),
1579
1690
  state.phase === 'error' ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, t('loadError', { message: state.error })) : null,
1580
1691
  actionError ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, actionError) : null,
@@ -1594,8 +1705,8 @@ window.__ModuleLoader__.load({
1594
1705
  : null,
1595
1706
  router
1596
1707
  ? tools(
1597
- h('button', { type: 'button', style: btn, disabled: busy, onClick: () => void act({ action: 'probe' }) }, t('probeAll')),
1598
- 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')),
1599
1710
  h('span', { style: { flexBasis: '100%', fontSize: 11, opacity: .55, lineHeight: 1.6 } }, t('resetHint')),
1600
1711
  )
1601
1712
  : null,
@@ -1628,12 +1739,12 @@ window.__ModuleLoader__.load({
1628
1739
  onChange: (event) => setDraft({ ...draft, key: event.target.value }),
1629
1740
  }),
1630
1741
  ),
1631
- h('button', { type: 'button', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
1742
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
1632
1743
  ),
1633
1744
  )
1634
1745
  : null,
1635
1746
 
1636
- peak
1747
+ shown
1637
1748
  ? card(
1638
1749
  h('div', {
1639
1750
  style: {
@@ -1641,23 +1752,27 @@ window.__ModuleLoader__.load({
1641
1752
  color: verdictColor, background: `${verdictColor}22`, border: `1px solid ${verdictColor}80`,
1642
1753
  },
1643
1754
  }, verdict),
1755
+ demo ? h('div', { style: { fontSize: 12, opacity: .7 } }, t('demoNote')) : null,
1644
1756
  h('div', { style: { overflowX: 'auto', cursor: 'grab' }, ...draggable() },
1645
1757
  h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
1646
1758
  h('thead', null,
1647
- h('tr', null, [t('colModel'), t('peakGoPay'), t('peakOfficialPay'), t('peakVerdict')].map((title) => th(title))),
1759
+ h('tr', null, [t('colModel'), t('peakGoPay'), t('peakOfficialPay')].map((title) => th(title))),
1648
1760
  ),
1649
- h('tbody', null, peak.models.map((row) =>
1761
+ h('tbody', null, shown.models.map((row) =>
1650
1762
  h('tr', { key: row.model },
1651
- cell(h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, row.model)),
1763
+ cell(prettyModel(row.model)),
1652
1764
  cell(priceCell(row.go.pay, row.go.factor > 1, row.cheaper === 'go')),
1653
1765
  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
1766
  ),
1658
1767
  )),
1659
1768
  ),
1660
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
+ ),
1661
1776
  )
1662
1777
  : null,
1663
1778
 
@@ -1685,9 +1800,27 @@ window.__ModuleLoader__.load({
1685
1800
  );
1686
1801
  }
1687
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
+
1688
1820
  return {
1689
1821
  inject: ['slots'],
1690
1822
  apply(ctx) {
1823
+ ensureStyles();
1691
1824
  ctx.slots.inject('settings.section', () => ctx.slots.register({
1692
1825
  name: 'settings.section',
1693
1826
  id: 'opencode-router',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-opencode-routing",
3
- "version": "2.2.0",
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
@@ -967,6 +967,18 @@ export function apply(ctx, config = {}) {
967
967
 
968
968
  async function handleApi(request, response) {
969
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
+ }
970
982
  if (request.method === 'POST') {
971
983
  const text = await readBody(request)
972
984
  let body
package/test.mjs CHANGED
@@ -369,6 +369,37 @@ console.log('usage view ok')
369
369
  }
370
370
  console.log('single-account fallback ok')
371
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
+
372
403
  for (const dispose of disposers) await dispose()
373
404
  server.close()
374
405
  console.log('all accounts checks passed')