dsh-opencode-routing 2.2.0 → 2.3.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 CHANGED
@@ -1,333 +1,354 @@
1
- window.__ModuleLoader__.load({
2
- id: 'dsh-opencode-routing',
3
- factory(require) {
4
- const React = require('react');
5
- const h = React.createElement;
6
-
7
- const USAGE_API = '/opencode-usage.json';
8
- const ROUTER_API = '/opencode-quota-router.json';
9
-
10
- /**
11
- * UI strings. `zh` is the source; every other locale is a translation of the
12
- * same keys. Missing keys fall back to `en`, then to the key itself, so a
13
- * half-finished translation never breaks the page.
14
- */
15
- const DICT = {
16
- zh: {
17
- title: 'OpenCode 账户与用量',
18
- refresh: '刷新',
19
- refreshing: '刷新中…',
20
- updatedAt: '更新于 {time}',
21
- loadError: '读取失败:{message}',
22
- routerTitle: '额度路由',
23
- modePrimary: '正常路由',
24
- modeFallback: '已回退到官方订阅',
25
- modeReason: '原因:{reason}',
26
- activeAccount: '当前账户',
27
- nextProbe: '下次探测',
28
- probeAll: '立即探测',
29
- resetMarks: '重置封禁标记',
30
- resetHint: '清空所有“额度用尽 / 鉴权失败”的标记,并让路由立刻回到 OpenCode 账户池;不改账户,也不改密钥。',
31
- routerMissing: '未检测到额度路由,账户池只读。',
32
- accountsTitle: '账户池',
33
- accountsOrder: '按顺序使用:前面的账户用完才用后面的。',
34
- addAccount: '添加账户',
35
- labelPlaceholder: '备注(可选,例如 小号 A)',
36
- keyPlaceholder: 'oc_sk_…',
37
- addAndProbe: '添加并探测',
38
- save: '保存',
39
- cancel: '取消',
40
- setActive: '设为当前',
41
- disable: '停用',
42
- enable: '启用',
43
- editKey: '改密钥',
44
- remove: '删除',
45
- viewUsage: '查看用量',
46
- keyUpdated: '密钥已更新',
47
- accountAdded: '账户已添加并探测',
48
- shortKey: '密钥不完整:至少 8 个字符',
49
- keepOne: '至少保留一个账户',
50
- notConfigured: '未配置',
51
- readonly: '只读',
52
- newKeyFor: '新密钥写入 {ref}',
53
- noAccounts: '还没有账户。填一个密钥就能开始。',
54
- statusReady: '可用',
55
- statusExhausted: '额度用尽',
56
- statusAuth: '鉴权失败',
57
- statusDisabled: '已停用',
58
- statusMissing: '未配置密钥',
59
- statusError: '读取失败',
60
- statusActive: '当前使用',
61
- statusSpare: '备用',
62
- statusCurrent: '当前',
63
- colIndex: '#',
64
- colAccount: '账户',
65
- colKey: '密钥',
66
- colRolling: '5 小时',
67
- colWeekly: '本周',
68
- colMonthly: '本月',
69
- colStatus: '状态',
70
- colActions: '操作',
71
- colModel: '模型',
72
- colCaps: '能力',
73
- colRequests: '请求',
74
- colTokens: '输入 / 输出 tokens',
75
- colRemaining: '本月剩余',
76
- colMonthlyUsed: '本月用量',
77
- colWeeklyUsed: '本周用量',
78
- modelsTitle: '按模型(本机估算)',
79
- capsText: '纯文本',
80
- capsImage: '图像',
81
- capsVideo: '视频',
82
- capsAudio: '音频',
83
- capsPdf: 'PDF',
84
- capsTools: '工具',
85
- capsReasoning: '推理',
86
- capsUnknown: '未声明',
87
- free: '免费',
88
- unlimited: '无限',
89
- none: '—',
90
- dialogTitle: '{label} 的用量',
91
- dialogNote: '官方接口只给这三个窗口的百分比,没有按模型的明细;按模型的金额只能是本机估算。',
92
- dialogProbe: '重新探测',
93
- close: '关闭',
94
- probedAt: '探测于 {time}',
95
- resetsAt: '重置于 {time}',
96
- usageError: '错误:{message}',
97
- peakTitle: '此刻用哪个更划算',
98
- peakPlan: '你为 OpenCode 的 {plan} 套餐付 ¥{price} /月(${usd}),额度给到 ¥{allowance}(${allowanceUsd})的官方价用量。额度按官方价扣,等于打 {discount} 折——同一笔请求走 OpenCode 你只付官方价的 {percent}%。',
99
- peakNow: '走官方直连照官方价付现。此刻 OpenCode 是{goPeak},官方是{officialPeak}{holiday}。',
100
- peakGoPeak: '峰段(额度烧 2 倍)',
101
- peakGoOff: '谷段',
102
- peakOfficialPeak: '峰段(价格 2 倍)',
103
- peakOfficialOff: '谷段',
104
- peakAtPeak: '峰段',
105
- peakOffPeak: '谷段',
106
- peakHoliday: '——今天是中国法定节假日,官方整天算谷,OpenCode 照算峰',
107
- peakExhausted: '但本月额度已经用尽,只能走官方。',
108
- peakGoPay: '走 OpenCode 你实付',
109
- peakOfficialPay: '走官方直连你实付',
110
- peakVerdict: '结论',
111
- peakGoCheaper: '走 OpenCode 便宜 {percent}%',
112
- verdictGo: '走 OpenCode 便宜 {percent}%',
113
- verdictOfficial: '走官方直连便宜 {percent}%',
114
- verdictExhausted: '本月额度已用尽,只能走官方直连',
115
- peakFootnote: '按每 100 万输出 tokens 折算(编码账单主要来自输出),汇率 ¥{fx}/$。峰谷只影响额度烧多快,不改变结论:额度内走 OpenCode 始终比官方直连便宜;例外是额度用尽,或某个模型的额度先见底。',
116
- footerOfficial: '上面「官方 5 小时 / 本周 / 本月」百分比来自 OpenCode 接口,是账户真实用量,别的客户端或别的机器用了也会算进去。',
117
- footerLocal: '按模型的金额是本机估算:只统计这个 harness 的会话日志,用来拆开看模型分布;其它工具的调用没有本地记录。',
118
- footerPeakRule: '金额按离峰价估算,DeepSeek 模型峰时按 2 倍;免费模型不计费。',
119
- },
120
- en: {
121
- title: 'OpenCode accounts & usage',
122
- refresh: 'Refresh',
123
- refreshing: 'Refreshing…',
124
- updatedAt: 'Updated {time}',
125
- loadError: 'Could not load: {message}',
126
- routerTitle: 'Quota routing',
127
- modePrimary: 'Routing normally',
128
- modeFallback: 'Fell back to the official subscription',
129
- modeReason: 'Reason: {reason}',
130
- activeAccount: 'Active account',
131
- nextProbe: 'Next probe',
132
- probeAll: 'Probe now',
133
- resetMarks: 'Clear blocked marks',
134
- resetHint: 'Clears every "out of allowance / auth failed" mark and sends routing straight back to the OpenCode pool. Accounts and keys are untouched.',
135
- routerMissing: 'No quota router detected — the account pool is read-only.',
136
- accountsTitle: 'Account pool',
137
- accountsOrder: 'Used in order: the next account only takes over once the one above runs dry.',
138
- addAccount: 'Add an account',
139
- labelPlaceholder: 'Label (optional, e.g. alt A)',
140
- keyPlaceholder: 'oc_sk_…',
141
- addAndProbe: 'Add and probe',
142
- save: 'Save',
143
- cancel: 'Cancel',
144
- setActive: 'Make active',
145
- disable: 'Disable',
146
- enable: 'Enable',
147
- editKey: 'Change key',
148
- remove: 'Delete',
149
- viewUsage: 'View usage',
150
- keyUpdated: 'Key updated',
151
- accountAdded: 'Account added and probed',
152
- shortKey: 'Key looks incomplete: at least 8 characters',
153
- keepOne: 'Keep at least one account',
154
- notConfigured: 'Not set',
155
- readonly: 'read-only',
156
- newKeyFor: 'New key is stored in {ref}',
157
- noAccounts: 'No accounts yet. Add a key to begin.',
158
- statusReady: 'Ready',
159
- statusExhausted: 'Out of allowance',
160
- statusAuth: 'Auth failed',
161
- statusDisabled: 'Disabled',
162
- statusMissing: 'No key',
163
- statusError: 'Probe failed',
164
- statusActive: 'In use',
165
- statusSpare: 'Spare',
166
- statusCurrent: 'current',
167
- colIndex: '#',
168
- colAccount: 'Account',
169
- colKey: 'Key',
170
- colRolling: '5 hours',
171
- colWeekly: 'This week',
172
- colMonthly: 'This month',
173
- colStatus: 'Status',
174
- colActions: 'Actions',
175
- colModel: 'Model',
176
- colCaps: 'Modality',
177
- colRequests: 'Requests',
178
- colTokens: 'Input / output tokens',
179
- colRemaining: 'Left this month',
180
- colMonthlyUsed: 'Used this month',
181
- colWeeklyUsed: 'Used this week',
182
- modelsTitle: 'Per model (local estimate)',
183
- capsText: 'text',
184
- capsImage: 'image',
185
- capsVideo: 'video',
186
- capsAudio: 'audio',
187
- capsPdf: 'PDF',
188
- capsTools: 'tools',
189
- capsReasoning: 'reasoning',
190
- capsUnknown: 'undeclared',
191
- free: 'free',
192
- unlimited: 'unlimited',
193
- none: '—',
194
- dialogTitle: 'Usage for {label}',
195
- dialogNote: 'The official endpoint only reports these three windows; it has no per-model breakdown. Per-model money can only be a local estimate.',
196
- dialogProbe: 'Probe again',
197
- close: 'Close',
198
- probedAt: 'Probed {time}',
199
- resetsAt: 'Resets {time}',
200
- usageError: 'Error: {message}',
201
- peakTitle: 'Which route is cheaper right now',
202
- peakPlan: 'You pay ¥{price}/month (${usd}) for OpenCode {plan} and the plan meters ¥{allowance} (${allowanceUsd}) of usage at official prices — a {discount}× discount, so the same request costs you {percent}% of the official price when it goes through OpenCode.',
203
- peakNow: 'The official route bills list price directly. Right now OpenCode is {goPeak} and the official API is {officialPeak}{holiday}.',
204
- peakGoPeak: 'at peak (allowance burns twice as fast)',
205
- peakGoOff: 'off-peak',
206
- peakOfficialPeak: 'at peak (price doubles)',
207
- peakOfficialOff: 'off-peak',
208
- peakAtPeak: 'peak',
209
- peakOffPeak: 'off-peak',
210
- peakHoliday: ' — today is a Chinese public holiday, so the official API charges off-peak all day while OpenCode still charges peak',
211
- peakExhausted: ' But this month’s allowance is already spent, so only the official route is left.',
212
- peakGoPay: 'You pay via OpenCode',
213
- peakOfficialPay: 'You pay via official API',
214
- peakVerdict: 'Verdict',
215
- peakGoCheaper: 'OpenCode is {percent}% cheaper',
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',
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.',
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.',
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.',
222
- footerPeakRule: 'Money is estimated at off-peak prices, doubled during DeepSeek peak hours; free models are not billed.',
223
- },
224
- 'zh-tw': {
225
- title: 'OpenCode 帳號與用量',
226
- refresh: '重新整理',
227
- refreshing: '重新整理中…',
228
- updatedAt: '更新於 {time}',
229
- loadError: '讀取失敗:{message}',
230
- routerTitle: '額度路由',
231
- modePrimary: '正常路由',
232
- modeFallback: '已回退到官方訂閱',
233
- modeReason: '原因:{reason}',
234
- activeAccount: '目前帳號',
235
- nextProbe: '下次探測',
236
- probeAll: '立即探測',
237
- resetMarks: '重設封鎖標記',
238
- resetHint: '清空所有「額度用盡/驗證失敗」標記,並讓路由立刻回到 OpenCode 帳號池;不會改動帳號與金鑰。',
239
- routerMissing: '未偵測到額度路由,帳號池為唯讀。',
240
- accountsTitle: '帳號池',
241
- accountsOrder: '依序使用:前面的帳號用完才會用後面的。',
242
- addAccount: '新增帳號',
243
- labelPlaceholder: '備註(可選,例如 小號 A)',
244
- keyPlaceholder: 'oc_sk_…',
245
- addAndProbe: '新增並探測',
246
- save: '儲存',
247
- cancel: '取消',
248
- setActive: '設為目前',
249
- disable: '停用',
250
- enable: '啟用',
251
- editKey: '更換金鑰',
252
- remove: '刪除',
253
- viewUsage: '查看用量',
254
- keyUpdated: '金鑰已更新',
255
- accountAdded: '帳號已新增並探測',
256
- shortKey: '金鑰不完整:至少 8 個字元',
257
- keepOne: '至少保留一個帳號',
258
- notConfigured: '未設定',
259
- readonly: '唯讀',
260
- newKeyFor: '新金鑰寫入 {ref}',
261
- noAccounts: '還沒有帳號。填入金鑰即可開始。',
262
- statusReady: '可用',
263
- statusExhausted: '額度用盡',
264
- statusAuth: '驗證失敗',
265
- statusDisabled: '已停用',
266
- statusMissing: '未設定金鑰',
267
- statusError: '讀取失敗',
268
- statusActive: '目前使用',
269
- statusSpare: '備用',
270
- statusCurrent: '目前',
271
- colIndex: '#',
272
- colAccount: '帳號',
273
- colKey: '金鑰',
274
- colRolling: '5 小時',
275
- colWeekly: '本週',
276
- colMonthly: '本月',
277
- colStatus: '狀態',
278
- colActions: '操作',
279
- colModel: '模型',
280
- colCaps: '能力',
281
- colRequests: '請求',
282
- colTokens: '輸入 / 輸出 tokens',
283
- colRemaining: '本月剩餘',
284
- colMonthlyUsed: '本月用量',
285
- colWeeklyUsed: '本週用量',
286
- modelsTitle: '依模型(本機估算)',
287
- capsText: '純文字',
288
- capsImage: '圖像',
289
- capsVideo: '影片',
290
- capsAudio: '音訊',
291
- capsPdf: 'PDF',
292
- capsTools: '工具',
293
- capsReasoning: '推理',
294
- capsUnknown: '未宣告',
295
- free: '免費',
296
- unlimited: '無限',
297
- none: '—',
298
- dialogTitle: '{label} 的用量',
299
- dialogNote: '官方介面只提供這三個窗口的百分比,沒有依模型拆分的明細;依模型的金額只能是本機估算。',
300
- dialogProbe: '重新探測',
301
- close: '關閉',
302
- probedAt: '探測於 {time}',
303
- resetsAt: '重置於 {time}',
304
- usageError: '錯誤:{message}',
305
- peakTitle: '此刻用哪個更划算',
306
- peakPlan: '你為 OpenCode 的 {plan} 方案付 ¥{price}/月(${usd}),額度給到 ¥{allowance}(${allowanceUsd})的官方價用量。額度依官方價扣,等於打 {discount} 折——同一筆請求走 OpenCode 你只付官方價的 {percent}%。',
307
- peakNow: '走官方直連照官方價付現。此刻 OpenCode 是{goPeak},官方是{officialPeak}{holiday}。',
308
- peakGoPeak: '峰段(額度消耗 2 倍)',
309
- peakGoOff: '谷段',
310
- peakOfficialPeak: '峰段(價格 2 倍)',
311
- peakOfficialOff: '谷段',
312
- peakAtPeak: '峰段',
313
- peakOffPeak: '谷段',
314
- peakHoliday: '——今天是中國法定假日,官方整天算谷,OpenCode 照算峰',
315
- peakExhausted: '但本月額度已用盡,只能走官方。',
316
- peakGoPay: '走 OpenCode 你實付',
317
- peakOfficialPay: '走官方直連你實付',
318
- peakVerdict: '結論',
319
- peakGoCheaper: '走 OpenCode 便宜 {percent}%',
320
- verdictGo: '走 OpenCode 便宜 {percent}%',
321
- verdictOfficial: '走官方直連便宜 {percent}%',
322
- verdictExhausted: '本月額度已用盡,只能走官方直連',
323
- peakFootnote: '依每 100 萬輸出 tokens 折算(編碼帳單主要來自輸出),匯率 ¥{fx}/$。峰谷只影響額度消耗速度,不改變結論:額度內走 OpenCode 始終比官方直連便宜;例外是額度用盡,或某個模型的額度先用完。',
324
- footerOfficial: '上面「官方 5 小時/本週/本月」百分比來自 OpenCode 介面,是帳號真實用量,其他用戶端或機器用掉的也會算進去。',
325
- footerLocal: '依模型的金額是本機估算:只統計這個 harness 的工作階段日誌,用來拆開看模型分布;其他工具的呼叫沒有本機記錄。',
326
- footerPeakRule: '金額依離峰價估算,DeepSeek 模型峰時按 2 倍;免費模型不計費。',
327
- },
328
- };
329
-
330
-
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-opencode-routing',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+
7
+ const USAGE_API = '/opencode-usage.json';
8
+ const ROUTER_API = '/opencode-quota-router.json';
9
+
10
+ /**
11
+ * UI strings. `zh` is the source; every other locale is a translation of the
12
+ * same keys. Missing keys fall back to `en`, then to the key itself, so a
13
+ * half-finished translation never breaks the page.
14
+ */
15
+ const DICT = {
16
+ zh: {
17
+ title: 'OpenCode 账户与用量',
18
+ refresh: '刷新',
19
+ refreshing: '刷新中…',
20
+ updatedAt: '更新于 {time}',
21
+ loadError: '读取失败:{message}',
22
+ routerTitle: '额度路由',
23
+ modePrimary: '正常路由',
24
+ modeFallback: '已回退到官方订阅',
25
+ modeReason: '原因:{reason}',
26
+ activeAccount: '当前账户',
27
+ nextProbe: '下次探测',
28
+ probeAll: '立即探测',
29
+ resetMarks: '重置封禁标记',
30
+ resetHint: '清空所有“额度用尽 / 鉴权失败”的标记,并让路由立刻回到 OpenCode 账户池;不改账户,也不改密钥。',
31
+ routerMissing: '未检测到额度路由,账户池只读。',
32
+ accountsTitle: '账户池',
33
+ accountsOrder: '按顺序使用:前面的账户用完才用后面的。',
34
+ addAccount: '添加账户',
35
+ labelPlaceholder: '备注(可选,例如 小号 A)',
36
+ keyPlaceholder: 'oc_sk_…',
37
+ addAndProbe: '添加并探测',
38
+ save: '保存',
39
+ cancel: '取消',
40
+ setActive: '设为当前',
41
+ disable: '停用',
42
+ enable: '启用',
43
+ editKey: '编辑密钥',
44
+ remove: '删除',
45
+ viewUsage: '查看用量',
46
+ keyUpdated: '密钥已更新',
47
+ accountAdded: '账户已添加并探测',
48
+ shortKey: '密钥不完整:至少 8 个字符',
49
+ keepOne: '至少保留一个账户',
50
+ notConfigured: '未配置',
51
+ readonly: '只读',
52
+ newKeyFor: '新密钥写入 {ref}',
53
+ noAccounts: '还没有账户。填一个密钥就能开始。',
54
+ statusReady: '可用',
55
+ statusExhausted: '额度用尽',
56
+ statusAuth: '鉴权失败',
57
+ statusDisabled: '已停用',
58
+ statusMissing: '未配置密钥',
59
+ statusError: '读取失败',
60
+ statusActive: '当前使用',
61
+ statusSpare: '备用',
62
+ statusCurrent: '当前',
63
+ colIndex: '#',
64
+ colAccount: '账户',
65
+ colKey: '密钥',
66
+ colRolling: '5 小时',
67
+ colWeekly: '本周',
68
+ colMonthly: '本月',
69
+ colStatus: '状态',
70
+ colActions: '操作',
71
+ colModel: '模型',
72
+ colCaps: '能力',
73
+ colRequests: '请求',
74
+ colTokens: '输入 / 输出 tokens',
75
+ colRemaining: '本月剩余',
76
+ colMonthlyUsed: '本月用量',
77
+ colWeeklyUsed: '本周用量',
78
+ modelsTitle: '按模型(本机估算)',
79
+ capsText: '纯文本',
80
+ capsImage: '图像',
81
+ capsVideo: '视频',
82
+ capsAudio: '音频',
83
+ capsPdf: 'PDF',
84
+ capsTools: '工具',
85
+ capsReasoning: '推理',
86
+ capsUnknown: '未声明',
87
+ free: '免费',
88
+ unlimited: '无限',
89
+ none: '—',
90
+ dialogTitle: '{label} 的用量',
91
+ dialogNote: '官方接口只给这三个窗口的百分比,没有按模型的明细;按模型的金额只能是本机估算。',
92
+ dialogProbe: '重新探测',
93
+ close: '关闭',
94
+ probedAt: '探测于 {time}',
95
+ resetsAt: '重置于 {time}',
96
+ usageError: '错误:{message}',
97
+ peakTitle: '此刻用哪个更划算',
98
+ peakPlan: '你为 OpenCode 的 {plan} 套餐付 ¥{price} /月(${usd}),额度给到 ¥{allowance}(${allowanceUsd})的官方价用量。额度按官方价扣,等于打 {discount} 折——同一笔请求走 OpenCode 你只付官方价的 {percent}%。',
99
+ peakNow: '走官方直连照官方价付现。此刻 OpenCode 是{goPeak},官方是{officialPeak}{holiday}。',
100
+ peakGoPeak: '峰段(额度烧 2 倍)',
101
+ peakGoOff: '谷段',
102
+ peakOfficialPeak: '峰段(价格 2 倍)',
103
+ peakOfficialOff: '谷段',
104
+ peakAtPeak: '高峰期',
105
+ peakOffPeak: '低谷期',
106
+ kindCacheRead: '缓存命中输入',
107
+ kindInput: '缓存未命中输入',
108
+ kindOutput: '输出',
109
+ peakHoliday: '——今天是中国法定节假日,官方整天算谷,OpenCode 照算峰',
110
+ colKind: '计费项',
111
+ peakExhausted: '但本月额度已经用尽,只能走官方。',
112
+ peakGoPay: '走 OpenCode 你实付',
113
+ peakOfficialPay: '走官方直连你实付',
114
+ peakVerdict: '结论',
115
+ peakGoCheaper: '走 OpenCode 便宜 {percent}%',
116
+ verdictGo: '走 OpenCode 便宜 {percent}%',
117
+ verdictOfficial: '走官方直连便宜 {percent}%',
118
+ verdictExhausted: '本月额度已用尽,只能走官方直连',
119
+ demoOn: '看官方更便宜的样子',
120
+ demoOff: '回到真实数据',
121
+ demoNote: '示例数据:假设某个模型走 OpenCode 反而更贵(现实中只有额度用尽才会发生)。',
122
+ peakFootnote: '按每 100 万输出 tokens 折算(编码账单主要来自输出),汇率 ¥{fx}/$。峰谷只影响额度烧多快,不改变结论:额度内走 OpenCode 始终比官方直连便宜;例外是额度用尽,或某个模型的额度先见底。',
123
+ footerOfficial: '上面「官方 5 小时 / 本周 / 本月」百分比来自 OpenCode 接口,是账户真实用量,别的客户端或别的机器用了也会算进去。',
124
+ footerLocal: '按模型的金额是本机估算:只统计这个 harness 的会话日志,用来拆开看模型分布;其它工具的调用没有本地记录。',
125
+ footerPeakRule: '金额按离峰价估算,DeepSeek 模型峰时按 2 倍;免费模型不计费。',
126
+ },
127
+ en: {
128
+ title: 'OpenCode accounts & usage',
129
+ refresh: 'Refresh',
130
+ refreshing: 'Refreshing…',
131
+ updatedAt: 'Updated {time}',
132
+ loadError: 'Could not load: {message}',
133
+ routerTitle: 'Quota routing',
134
+ modePrimary: 'Routing normally',
135
+ modeFallback: 'Fell back to the official subscription',
136
+ modeReason: 'Reason: {reason}',
137
+ activeAccount: 'Active account',
138
+ nextProbe: 'Next probe',
139
+ probeAll: 'Probe now',
140
+ resetMarks: 'Clear blocked marks',
141
+ resetHint: 'Clears every "out of allowance / auth failed" mark and sends routing straight back to the OpenCode pool. Accounts and keys are untouched.',
142
+ routerMissing: 'No quota router detected — the account pool is read-only.',
143
+ accountsTitle: 'Account pool',
144
+ accountsOrder: 'Used in order: the next account only takes over once the one above runs dry.',
145
+ addAccount: 'Add an account',
146
+ labelPlaceholder: 'Label (optional, e.g. alt A)',
147
+ keyPlaceholder: 'oc_sk_…',
148
+ addAndProbe: 'Add and probe',
149
+ save: 'Save',
150
+ cancel: 'Cancel',
151
+ setActive: 'Make active',
152
+ disable: 'Disable',
153
+ enable: 'Enable',
154
+ editKey: 'Edit key',
155
+ remove: 'Delete',
156
+ viewUsage: 'View usage',
157
+ keyUpdated: 'Key updated',
158
+ accountAdded: 'Account added and probed',
159
+ shortKey: 'Key looks incomplete: at least 8 characters',
160
+ keepOne: 'Keep at least one account',
161
+ notConfigured: 'Not set',
162
+ readonly: 'read-only',
163
+ newKeyFor: 'New key is stored in {ref}',
164
+ noAccounts: 'No accounts yet. Add a key to begin.',
165
+ statusReady: 'Ready',
166
+ statusExhausted: 'Out of allowance',
167
+ statusAuth: 'Auth failed',
168
+ statusDisabled: 'Disabled',
169
+ statusMissing: 'No key',
170
+ statusError: 'Probe failed',
171
+ statusActive: 'In use',
172
+ statusSpare: 'Spare',
173
+ statusCurrent: 'current',
174
+ colIndex: '#',
175
+ colAccount: 'Account',
176
+ colKey: 'Key',
177
+ colRolling: '5 hours',
178
+ colWeekly: 'This week',
179
+ colMonthly: 'This month',
180
+ colStatus: 'Status',
181
+ colActions: 'Actions',
182
+ colModel: 'Model',
183
+ colCaps: 'Modality',
184
+ colRequests: 'Requests',
185
+ colTokens: 'Input / output tokens',
186
+ colRemaining: 'Left this month',
187
+ colMonthlyUsed: 'Used this month',
188
+ colWeeklyUsed: 'Used this week',
189
+ modelsTitle: 'Per model (local estimate)',
190
+ capsText: 'text',
191
+ capsImage: 'image',
192
+ capsVideo: 'video',
193
+ capsAudio: 'audio',
194
+ capsPdf: 'PDF',
195
+ capsTools: 'tools',
196
+ capsReasoning: 'reasoning',
197
+ capsUnknown: 'undeclared',
198
+ free: 'free',
199
+ unlimited: 'unlimited',
200
+ none: '—',
201
+ dialogTitle: 'Usage for {label}',
202
+ dialogNote: 'The official endpoint only reports these three windows; it has no per-model breakdown. Per-model money can only be a local estimate.',
203
+ dialogProbe: 'Probe again',
204
+ close: 'Close',
205
+ probedAt: 'Probed {time}',
206
+ resetsAt: 'Resets {time}',
207
+ usageError: 'Error: {message}',
208
+ peakTitle: 'Which route is cheaper right now',
209
+ peakPlan: 'You pay ¥{price}/month (${usd}) for OpenCode {plan} and the plan meters ¥{allowance} (${allowanceUsd}) of usage at official prices — a {discount}× discount, so the same request costs you {percent}% of the official price when it goes through OpenCode.',
210
+ peakNow: 'The official route bills list price directly. Right now OpenCode is {goPeak} and the official API is {officialPeak}{holiday}.',
211
+ peakGoPeak: 'at peak (allowance burns twice as fast)',
212
+ peakGoOff: 'off-peak',
213
+ peakOfficialPeak: 'at peak (price doubles)',
214
+ peakOfficialOff: 'off-peak',
215
+ peakAtPeak: 'peak',
216
+ peakOffPeak: 'off-peak',
217
+ kindCacheRead: 'cached input',
218
+ colKind: 'billing line',
219
+ kindInput: 'uncached input',
220
+ kindOutput: 'output',
221
+ peakHoliday: ' — today is a Chinese public holiday, so the official API charges off-peak all day while OpenCode still charges peak',
222
+ peakExhausted: ' But this month’s allowance is already spent, so only the official route is left.',
223
+ peakGoPay: 'You pay via OpenCode',
224
+ peakOfficialPay: 'You pay via official API',
225
+ peakVerdict: 'Verdict',
226
+ peakGoCheaper: 'OpenCode is {percent}% cheaper',
227
+ verdictGo: 'OpenCode is {percent}% cheaper',
228
+ verdictOfficial: 'Official API is {percent}% cheaper',
229
+ verdictExhausted: 'This month’s allowance is used up, so only the official API is left',
230
+ demoOn: 'Show the official-is-cheaper case',
231
+ demoOff: 'Back to real data',
232
+ demoNote: 'Sample data: pretend one model is more expensive on OpenCode — in practice that only happens once the allowance runs out.',
233
+ 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.',
234
+ 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.',
235
+ 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.',
236
+ footerPeakRule: 'Money is estimated at off-peak prices, doubled during DeepSeek peak hours; free models are not billed.',
237
+ },
238
+ 'zh-tw': {
239
+ title: 'OpenCode 帳號與用量',
240
+ refresh: '重新整理',
241
+ refreshing: '重新整理中…',
242
+ updatedAt: '更新於 {time}',
243
+ loadError: '讀取失敗:{message}',
244
+ routerTitle: '額度路由',
245
+ modePrimary: '正常路由',
246
+ modeFallback: '已回退到官方訂閱',
247
+ modeReason: '原因:{reason}',
248
+ activeAccount: '目前帳號',
249
+ nextProbe: '下次探測',
250
+ probeAll: '立即探測',
251
+ resetMarks: '重設封鎖標記',
252
+ resetHint: '清空所有「額度用盡/驗證失敗」標記,並讓路由立刻回到 OpenCode 帳號池;不會改動帳號與金鑰。',
253
+ routerMissing: '未偵測到額度路由,帳號池為唯讀。',
254
+ accountsTitle: '帳號池',
255
+ accountsOrder: '依序使用:前面的帳號用完才會用後面的。',
256
+ addAccount: '新增帳號',
257
+ labelPlaceholder: '備註(可選,例如 小號 A)',
258
+ keyPlaceholder: 'oc_sk_…',
259
+ addAndProbe: '新增並探測',
260
+ save: '儲存',
261
+ cancel: '取消',
262
+ setActive: '設為目前',
263
+ disable: '停用',
264
+ enable: '啟用',
265
+ editKey: '編輯金鑰',
266
+ remove: '刪除',
267
+ viewUsage: '查看用量',
268
+ keyUpdated: '金鑰已更新',
269
+ accountAdded: '帳號已新增並探測',
270
+ shortKey: '金鑰不完整:至少 8 個字元',
271
+ keepOne: '至少保留一個帳號',
272
+ notConfigured: '未設定',
273
+ readonly: '唯讀',
274
+ newKeyFor: '新金鑰寫入 {ref}',
275
+ noAccounts: '還沒有帳號。填入金鑰即可開始。',
276
+ statusReady: '可用',
277
+ statusExhausted: '額度用盡',
278
+ statusAuth: '驗證失敗',
279
+ statusDisabled: '已停用',
280
+ statusMissing: '未設定金鑰',
281
+ statusError: '讀取失敗',
282
+ statusActive: '目前使用',
283
+ statusSpare: '備用',
284
+ statusCurrent: '目前',
285
+ colIndex: '#',
286
+ colAccount: '帳號',
287
+ colKey: '金鑰',
288
+ colRolling: '5 小時',
289
+ colWeekly: '本週',
290
+ colMonthly: '本月',
291
+ colStatus: '狀態',
292
+ colActions: '操作',
293
+ colModel: '模型',
294
+ colCaps: '能力',
295
+ colRequests: '請求',
296
+ colTokens: '輸入 / 輸出 tokens',
297
+ colRemaining: '本月剩餘',
298
+ colMonthlyUsed: '本月用量',
299
+ colWeeklyUsed: '本週用量',
300
+ modelsTitle: '依模型(本機估算)',
301
+ capsText: '純文字',
302
+ capsImage: '圖像',
303
+ capsVideo: '影片',
304
+ capsAudio: '音訊',
305
+ capsPdf: 'PDF',
306
+ capsTools: '工具',
307
+ capsReasoning: '推理',
308
+ capsUnknown: '未宣告',
309
+ free: '免費',
310
+ unlimited: '無限',
311
+ none: '—',
312
+ dialogTitle: '{label} 的用量',
313
+ dialogNote: '官方介面只提供這三個窗口的百分比,沒有依模型拆分的明細;依模型的金額只能是本機估算。',
314
+ dialogProbe: '重新探測',
315
+ close: '關閉',
316
+ probedAt: '探測於 {time}',
317
+ resetsAt: '重置於 {time}',
318
+ usageError: '錯誤:{message}',
319
+ peakTitle: '此刻用哪個更划算',
320
+ peakPlan: '你為 OpenCode 的 {plan} 方案付 ¥{price}/月(${usd}),額度給到 ¥{allowance}(${allowanceUsd})的官方價用量。額度依官方價扣,等於打 {discount} 折——同一筆請求走 OpenCode 你只付官方價的 {percent}%。',
321
+ peakNow: '走官方直連照官方價付現。此刻 OpenCode 是{goPeak},官方是{officialPeak}{holiday}。',
322
+ peakGoPeak: '峰段(額度消耗 2 倍)',
323
+ peakGoOff: '谷段',
324
+ peakOfficialPeak: '峰段(價格 2 倍)',
325
+ peakOfficialOff: '谷段',
326
+ peakAtPeak: '高峰期',
327
+ peakOffPeak: '低谷期',
328
+ kindCacheRead: '緩存命中輸入',
329
+ colKind: '計費項',
330
+ kindInput: '緩存未命中輸入',
331
+ kindOutput: '輸出',
332
+ peakHoliday: '——今天是中國法定假日,官方整天算谷,OpenCode 照算峰',
333
+ peakExhausted: '但本月額度已用盡,只能走官方。',
334
+ peakGoPay: '走 OpenCode 你實付',
335
+ peakOfficialPay: '走官方直連你實付',
336
+ peakVerdict: '結論',
337
+ peakGoCheaper: '走 OpenCode 便宜 {percent}%',
338
+ verdictGo: '走 OpenCode 便宜 {percent}%',
339
+ verdictOfficial: '走官方直連便宜 {percent}%',
340
+ verdictExhausted: '本月額度已用盡,只能走官方直連',
341
+ demoOn: '看官方更便宜的樣子',
342
+ demoOff: '回到真實資料',
343
+ demoNote: '範例資料:假設某個模型走 OpenCode 反而更貴(現實中只有額度用盡才會發生)。',
344
+ peakFootnote: '依每 100 萬輸出 tokens 折算(編碼帳單主要來自輸出),匯率 ¥{fx}/$。峰谷只影響額度消耗速度,不改變結論:額度內走 OpenCode 始終比官方直連便宜;例外是額度用盡,或某個模型的額度先用完。',
345
+ footerOfficial: '上面「官方 5 小時/本週/本月」百分比來自 OpenCode 介面,是帳號真實用量,其他用戶端或機器用掉的也會算進去。',
346
+ footerLocal: '依模型的金額是本機估算:只統計這個 harness 的工作階段日誌,用來拆開看模型分布;其他工具的呼叫沒有本機記錄。',
347
+ footerPeakRule: '金額依離峰價估算,DeepSeek 模型峰時按 2 倍;免費模型不計費。',
348
+ },
349
+ };
350
+
351
+
331
352
  /* @@EXTRA_LOCALES_BEGIN@@ */
332
353
  Object.assign(DICT, {
333
354
  "de": {
@@ -426,11 +447,20 @@ window.__ModuleLoader__.load({
426
447
  "peakOfficialPay": "Du zahlst über die offizielle API",
427
448
  "peakVerdict": "Fazit",
428
449
  "peakGoCheaper": "OpenCode ist {percent}% günstiger",
429
- "peakOfficialCheaper": "Die offizielle API ist {percent}% günstiger",
430
450
  "peakFootnote": "Gerechnet pro 1 Mio. Ausgabe-Tokens (bei einer Coding-Rechnung dominiert die Ausgabe), bei ¥{fx} je $. Haupt- und Nebenzeit ändern nur, wie schnell das Kontingent verbraucht wird, nicht das Fazit: Innerhalb des Kontingents bleibt OpenCode günstiger als die offizielle API; Ausnahmen sind ein aufgebrauchtes Kontingent oder ein Modell, dessen eigenes Limit zuerst endet.",
431
451
  "footerOfficial": "Die offiziellen Prozente für 5 Stunden / Woche / Monat kommen von der OpenCode-API und beschreiben das ganze Konto – Nutzung über andere Clients oder Rechner zählt mit.",
432
452
  "footerLocal": "Beträge pro Modell sind eine lokale Schätzung: Sie zählen nur das Sitzungsprotokoll dieses Harness, und genau das macht die Verteilung pro Modell sichtbar; Aufrufe anderer Tools hinterlassen lokal keine Spur.",
433
453
  "footerPeakRule": "Beträge sind zum Nebenzeit-Preis geschätzt, bei DeepSeek-Modellen zur Hauptzeit doppelt; kostenlose Modelle werden nicht berechnet.",
454
+ "kindCacheRead": "zwischengespeicherte Eingabe",
455
+ "kindInput": "nicht zwischengespeicherte Eingabe",
456
+ "kindOutput": "Ausgabe",
457
+ "colKind": "Posten",
458
+ "verdictGo": "Über OpenCode {percent}% günstiger",
459
+ "verdictOfficial": "Über die offizielle API {percent}% günstiger",
460
+ "verdictExhausted": "Monatskontingent aufgebraucht – nur die offizielle API bleibt",
461
+ "demoOn": "Fall „offiziell günstiger\" zeigen",
462
+ "demoOff": "Zurück zu echten Daten",
463
+ "demoNote": "Beispieldaten: ein Modell, das über OpenCode teurer wäre (real nur bei aufgebrauchtem Kontingent).",
434
464
  },
435
465
  "es": {
436
466
  "title": "Cuentas y uso de OpenCode",
@@ -528,11 +558,20 @@ window.__ModuleLoader__.load({
528
558
  "peakOfficialPay": "Pagas por la API oficial",
529
559
  "peakVerdict": "Conclusión",
530
560
  "peakGoCheaper": "OpenCode sale {percent}% más barato",
531
- "peakOfficialCheaper": "La API oficial sale {percent}% más barata",
532
561
  "peakFootnote": "Calculado por millón de tokens de salida (en una factura de código manda la salida), a ¥{fx} por dólar. Las horas pico solo cambian la rapidez con que se consume la cuota, no la conclusión: dentro de la cuota, OpenCode siempre sale más barato que la API oficial; las excepciones son que la cuota se agote o que el límite de un modelo concreto se acabe antes.",
533
562
  "footerOfficial": "Los porcentajes oficiales de 5 horas / semana / mes vienen de la API de OpenCode y describen la cuenta completa: el uso desde otros clientes u otras máquinas también cuenta.",
534
563
  "footerLocal": "El importe por modelo es una estimación local: solo cuenta el registro de sesiones de este harness, que es lo que permite ver el reparto por modelo; las llamadas de otras herramientas no dejan rastro local.",
535
564
  "footerPeakRule": "El importe se estima a tarifa valle, duplicada en hora pico para los modelos DeepSeek; los modelos gratuitos no se facturan.",
565
+ "kindCacheRead": "entrada en caché",
566
+ "kindInput": "entrada sin caché",
567
+ "kindOutput": "salida",
568
+ "colKind": "Concepto",
569
+ "verdictGo": "Con OpenCode un {percent}% más barato",
570
+ "verdictOfficial": "Con la API oficial un {percent}% más barato",
571
+ "verdictExhausted": "Cuota mensual agotada: solo queda la API oficial",
572
+ "demoOn": "Ver el caso de que lo oficial sea más barato",
573
+ "demoOff": "Volver a los datos reales",
574
+ "demoNote": "Datos de ejemplo: un modelo que con OpenCode sería más caro (en la realidad solo pasa al agotar la cuota).",
536
575
  },
537
576
  "fr": {
538
577
  "title": "Comptes et usage OpenCode",
@@ -630,11 +669,20 @@ window.__ModuleLoader__.load({
630
669
  "peakOfficialPay": "Vous payez via l’API officielle",
631
670
  "peakVerdict": "Verdict",
632
671
  "peakGoCheaper": "OpenCode coûte {percent}% de moins",
633
- "peakOfficialCheaper": "L’API officielle coûte {percent}% de moins",
634
672
  "peakFootnote": "Pour 1 M de tokens en sortie (la sortie domine une facture de code), au taux de ¥{fx} pour 1 $. Les heures pleines ne changent que la vitesse à laquelle le quota se consomme, pas la conclusion : tant qu’il reste du quota, OpenCode reste moins cher que l’API officielle ; les exceptions sont un quota épuisé ou un modèle dont le plafond tombe en premier.",
635
673
  "footerOfficial": "Les pourcentages officiels 5 heures / semaine / mois viennent de l’API OpenCode et décrivent le compte entier : l’usage d’autres clients ou d’autres machines compte aussi.",
636
674
  "footerLocal": "Le montant par modèle est une estimation locale : seuls les journaux de session de ce harness sont comptés, ce qui permet de voir la répartition par modèle ; les appels des autres outils ne laissent aucune trace locale.",
637
675
  "footerPeakRule": "Le montant est estimé au tarif heures creuses, doublé en heures pleines pour les modèles DeepSeek ; les modèles gratuits ne sont pas facturés.",
676
+ "kindCacheRead": "entrée en cache",
677
+ "kindInput": "entrée hors cache",
678
+ "kindOutput": "sortie",
679
+ "colKind": "Poste",
680
+ "verdictGo": "{percent}% moins cher via OpenCode",
681
+ "verdictOfficial": "{percent}% moins cher via l’API officielle",
682
+ "verdictExhausted": "Quota mensuel épuisé — il ne reste que l’API officielle",
683
+ "demoOn": "Voir le cas où l’officiel est moins cher",
684
+ "demoOff": "Revenir aux données réelles",
685
+ "demoNote": "Données d’exemple : un modèle plus cher via OpenCode (en pratique, seulement quota épuisé).",
638
686
  },
639
687
  "ja": {
640
688
  "title": "OpenCode アカウントと使用量",
@@ -732,11 +780,20 @@ window.__ModuleLoader__.load({
732
780
  "peakOfficialPay": "公式直結の支払い",
733
781
  "peakVerdict": "結論",
734
782
  "peakGoCheaper": "OpenCode のほうが {percent}% 安い",
735
- "peakOfficialCheaper": "公式直結のほうが {percent}% 安い",
736
783
  "peakFootnote": "出力 100 万トークンあたりに換算(コーディングの請求はほとんどが出力由来)、レートは ¥{fx}/$。ピークとオフピークはクォータの減る速さに影響するだけで、結論は変わりません。クォータの範囲内なら OpenCode 経由のほうが公式直結より常に安く、例外はクォータを使い切った場合と、特定モデルの上限に先に達した場合です。",
737
784
  "footerOfficial": "上の「公式 5 時間 / 今週 / 今月」の割合は OpenCode の API が返す実際のアカウント使用量で、他のクライアントや別のマシンからの利用も含まれます。",
738
785
  "footerLocal": "モデル別の金額はローカル推定です。この harness のセッションログだけを集計してモデル別の内訳を出すもので、他のツールからの呼び出しはローカルに記録されません。",
739
786
  "footerPeakRule": "金額はオフピーク価格で見積もり、DeepSeek モデルはピーク時に 2 倍で計算します。無料モデルは課金されません。",
787
+ "kindCacheRead": "キャッシュ済み入力",
788
+ "kindInput": "未キャッシュ入力",
789
+ "kindOutput": "出力",
790
+ "colKind": "課金項目",
791
+ "verdictGo": "OpenCode 経由が {percent}% 安い",
792
+ "verdictOfficial": "公式 API 経由が {percent}% 安い",
793
+ "verdictExhausted": "今月の枠を使い切ったため公式 API のみ",
794
+ "demoOn": "公式が安い場合を表示",
795
+ "demoOff": "実際のデータに戻す",
796
+ "demoNote": "サンプル表示:OpenCode 経由のほうが高くなる例(実際は枠を使い切ったときのみ)。",
740
797
  },
741
798
  "ko": {
742
799
  "title": "OpenCode 계정 및 사용량",
@@ -834,11 +891,20 @@ window.__ModuleLoader__.load({
834
891
  "peakOfficialPay": "공식 직결 결제액",
835
892
  "peakVerdict": "결론",
836
893
  "peakGoCheaper": "OpenCode가 {percent}% 더 저렴",
837
- "peakOfficialCheaper": "공식 직결이 {percent}% 더 저렴",
838
894
  "peakFootnote": "출력 100만 토큰 기준 환산(코딩 요금은 대부분 출력에서 발생), 환율 ¥{fx}/$. 피크·비피크는 할당량이 줄어드는 속도에만 영향을 주고 결론은 바뀌지 않습니다. 할당량 안에서는 OpenCode 경유가 공식 직결보다 항상 저렴하며, 예외는 할당량을 다 쓴 경우와 특정 모델 한도가 먼저 바닥나는 경우입니다.",
839
895
  "footerOfficial": "위의 \"공식 5시간 / 이번 주 / 이번 달\" 비율은 OpenCode API가 주는 실제 계정 사용량이며, 다른 클라이언트나 다른 기기에서 쓴 양도 포함됩니다.",
840
896
  "footerLocal": "모델별 금액은 로컬 추정치입니다. 이 harness의 세션 로그만 집계해 모델별 분포를 보여 주는 것이며, 다른 도구의 호출은 로컬에 기록되지 않습니다.",
841
897
  "footerPeakRule": "금액은 비피크 가격으로 추정하고, DeepSeek 모델은 피크 시간에 2배로 계산합니다. 무료 모델은 과금되지 않습니다.",
898
+ "kindCacheRead": "캐시된 입력",
899
+ "kindInput": "캐시되지 않은 입력",
900
+ "kindOutput": "출력",
901
+ "colKind": "과금 항목",
902
+ "verdictGo": "OpenCode 경유가 {percent}% 저렴",
903
+ "verdictOfficial": "공식 API 경유가 {percent}% 저렴",
904
+ "verdictExhausted": "이번 달 한도를 모두 사용해 공식 API만 가능",
905
+ "demoOn": "공식이 더 저렴한 경우 보기",
906
+ "demoOff": "실제 데이터로 돌아가기",
907
+ "demoNote": "예시 데이터: OpenCode 경유가 더 비싼 경우(실제로는 한도를 다 썼을 때만).",
842
908
  },
843
909
  "pt": {
844
910
  "title": "Contas e uso do OpenCode",
@@ -936,11 +1002,20 @@ window.__ModuleLoader__.load({
936
1002
  "peakOfficialPay": "Você paga pela API oficial",
937
1003
  "peakVerdict": "Veredito",
938
1004
  "peakGoCheaper": "O OpenCode sai {percent}% mais barato",
939
- "peakOfficialCheaper": "A API oficial sai {percent}% mais barata",
940
1005
  "peakFootnote": "Cálculo por 1 M de tokens de saída (numa fatura de código a saída domina), a ¥{fx} por dólar. O pico só muda a velocidade com que a cota queima, não a conclusão: dentro da cota, o OpenCode continua mais barato que a API oficial; as exceções são a cota esgotada ou um modelo cujo limite próprio acaba antes.",
941
1006
  "footerOfficial": "As porcentagens oficiais de 5 horas / semana / mês vêm da API do OpenCode e descrevem a conta inteira: uso de outros clientes ou de outras máquinas também entra na conta.",
942
1007
  "footerLocal": "O valor por modelo é uma estimativa local: conta só o log de sessões deste harness, que é o que permite ver a divisão por modelo; chamadas de outras ferramentas não deixam registro local.",
943
1008
  "footerPeakRule": "O valor é estimado a preço fora do pico, dobrado no horário de pico para os modelos DeepSeek; modelos gratuitos não são cobrados.",
1009
+ "kindCacheRead": "entrada em cache",
1010
+ "kindInput": "entrada sem cache",
1011
+ "kindOutput": "saída",
1012
+ "colKind": "Item",
1013
+ "verdictGo": "{percent}% mais barato via OpenCode",
1014
+ "verdictOfficial": "{percent}% mais barato via API oficial",
1015
+ "verdictExhausted": "Cota mensal esgotada — resta apenas a API oficial",
1016
+ "demoOn": "Ver o caso em que o oficial é mais barato",
1017
+ "demoOff": "Voltar aos dados reais",
1018
+ "demoNote": "Dados de exemplo: um modelo mais caro via OpenCode (na prática só com a cota esgotada).",
944
1019
  },
945
1020
  "ru": {
946
1021
  "title": "Аккаунты и использование OpenCode",
@@ -1038,663 +1113,819 @@ window.__ModuleLoader__.load({
1038
1113
  "peakOfficialPay": "Вы платите через официальный API",
1039
1114
  "peakVerdict": "Итог",
1040
1115
  "peakGoCheaper": "OpenCode дешевле на {percent}%",
1041
- "peakOfficialCheaper": "Официальный API дешевле на {percent}%",
1042
1116
  "peakFootnote": "В расчёте на 1 млн выходных токенов (в счёте за код преобладает выход), курс ¥{fx}/$. Пик влияет только на скорость расхода квоты и не меняет вывод: в пределах квоты OpenCode всегда дешевле прямого официального API; исключения — исчерпанная квота или лимит отдельной модели, который закончится раньше.",
1043
1117
  "footerOfficial": "Проценты «официально 5 часов / неделя / месяц» выше приходят из API OpenCode и отражают реальный расход аккаунта — включая другие клиенты и другие машины.",
1044
1118
  "footerLocal": "Суммы по моделям — локальная оценка: учитывается только журнал сессий этого harness, поэтому разбивка по моделям и видна; вызовы из других инструментов локально не записываются.",
1045
1119
  "footerPeakRule": "Суммы считаются по ценам вне пика, для моделей DeepSeek в часы пика применяется коэффициент 2. Бесплатные модели не тарифицируются.",
1120
+ "kindCacheRead": "вход из кеша",
1121
+ "kindInput": "вход без кеша",
1122
+ "kindOutput": "выход",
1123
+ "colKind": "Статья",
1124
+ "verdictGo": "Через OpenCode дешевле на {percent}%",
1125
+ "verdictOfficial": "Через официальный API дешевле на {percent}%",
1126
+ "verdictExhausted": "Месячная квота исчерпана — остаётся только официальный API",
1127
+ "demoOn": "Показать случай, когда официальный дешевле",
1128
+ "demoOff": "Вернуться к реальным данным",
1129
+ "demoNote": "Демо-данные: модель, которая через OpenCode дороже (в реальности — только при исчерпанной квоте).",
1046
1130
  },
1047
1131
  });
1048
- /* @@EXTRA_LOCALES_END@@ */
1049
-
1050
- const localeOf = (tag) => {
1051
- const value = String(tag || '').toLowerCase();
1052
- if (value.startsWith('zh')) return /tw|hk|mo|hant/.test(value) ? 'zh-tw' : 'zh';
1053
- for (const key of Object.keys(DICT)) if (key !== 'zh' && key !== 'zh-tw' && value.startsWith(key.split('-')[0])) return key;
1054
- return 'en';
1055
- };
1056
- const LANG = localeOf(typeof navigator === 'undefined' ? '' : navigator.language);
1057
- const dict = DICT[LANG] ?? DICT.en;
1058
- const t = (key, vars) => {
1059
- const template = dict[key] ?? DICT.en[key] ?? key;
1060
- if (vars === undefined) return template;
1061
- return template.replace(/\{(\w+)\}/g, (match, name) => (vars[name] === undefined ? match : String(vars[name])));
1062
- };
1063
-
1064
- const money = (value) => {
1065
- if (value === undefined || value === null) return t('none');
1066
- if (value === 'free') return t('free');
1067
- if (value === Infinity) return t('unlimited');
1068
- return value < 1 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`;
1069
- };
1070
- const tokens = (n) => (n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
1071
- const yuan = (value) => {
1072
- if (value === undefined || value === null) return t('none');
1073
- return value >= 1 ? `¥${value.toFixed(2)}` : `¥${value.toFixed(3)}`;
1074
- };
1075
- const percentText = (used, limit, free) => {
1076
- if (free) return t('unlimited');
1077
- if (limit === undefined || limit === null) return t('none');
1078
- if (limit === Infinity || limit === 'free') return t('unlimited');
1079
- if (!limit) return t('none');
1080
- const ratio = (used / limit) * 100;
1081
- return `${ratio < 10 ? ratio.toFixed(1) : ratio.toFixed(0)}%`;
1082
- };
1083
- const official = (window) => {
1084
- const value = window && typeof window.percent === 'number' ? window.percent : null;
1085
- if (value === null) return t('none');
1086
- const color = value >= 100 ? '#d9534f' : value >= 80 ? '#e0a030' : 'inherit';
1087
- return h('span', { style: { color } }, `${value < 10 ? value.toFixed(1) : Math.round(value)}%`);
1088
- };
1089
- const clock = (ms) => (ms ? new Date(ms).toLocaleString() : t('none'));
1090
- const when = (ms) => {
1091
- if (!ms) return t('none');
1092
- 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);
1095
- const relative = LANG === 'zh' || LANG === 'zh-tw'
1096
- ? `${minutes < 60 ? `${minutes} 分钟` : `${Math.round(minutes / 60)} 小时`}后`
1097
- : new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1098
- return relative;
1099
- };
1100
-
1101
- const btn = {
1102
- padding: '5px 12px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
1103
- background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: 12,
1104
- lineHeight: 1.6, whiteSpace: 'nowrap',
1105
- };
1106
- const input = {
1107
- padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
1108
- background: 'transparent', color: 'inherit', font: 'inherit', fontSize: 13,
1109
- };
1110
- const cell = (content, extra) =>
1111
- h('td', { style: { padding: '5px 10px', borderBottom: '1px solid rgba(127,127,127,.22)', whiteSpace: 'nowrap', ...extra } }, content);
1112
- const th = (title) =>
1113
- h('th', {
1114
- key: title,
1115
- style: {
1116
- textAlign: 'left', padding: '6px 10px', fontWeight: 600, whiteSpace: 'nowrap',
1117
- borderBottom: '1px solid rgba(127,127,127,.3)', position: 'sticky', top: 0,
1118
- background: 'var(--dsh-surface, rgba(127,127,127,.08))', backdropFilter: 'blur(4px)',
1119
- },
1120
- }, title);
1121
-
1122
- // Horizontal drag-to-scroll for wide tables: press anywhere in the pane and
1123
- // drag. A press that starts on a control is left alone, so buttons and inputs
1124
- // keep working inside the scroller.
1125
- const draggable = () => ({
1126
- onMouseDown: (event) => {
1127
- if (event.button !== 0) return;
1128
- const tag = event.target && event.target.tagName;
1129
- if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'A') return;
1130
- const pane = event.currentTarget;
1131
- if (pane.scrollWidth <= pane.clientWidth + 1) return;
1132
- const startX = event.clientX;
1133
- const startLeft = pane.scrollLeft;
1134
- const move = (moveEvent) => {
1135
- pane.scrollLeft = startLeft - (moveEvent.clientX - startX);
1136
- };
1137
- const stop = () => {
1138
- pane.style.cursor = 'grab';
1139
- pane.style.userSelect = '';
1140
- window.removeEventListener('mousemove', move);
1141
- window.removeEventListener('mouseup', stop);
1142
- };
1143
- pane.style.cursor = 'grabbing';
1144
- pane.style.userSelect = 'none';
1145
- window.addEventListener('mousemove', move);
1146
- window.addEventListener('mouseup', stop);
1147
- },
1148
- });
1149
-
1150
- const STATUS = () => ({
1151
- ready: [t('statusReady'), '#3c9d5d'],
1152
- exhausted: [t('statusExhausted'), '#e0a030'],
1153
- auth: [t('statusAuth'), '#d9534f'],
1154
- disabled: [t('statusDisabled'), 'rgba(127,127,127,.8)'],
1155
- missing: [t('statusMissing'), '#d9534f'],
1156
- });
1157
-
1158
- const capabilityTags = (row) => {
1159
- const inputs = Array.isArray(row.inputs) && row.inputs.length > 0 ? row.inputs : ['text'];
1160
- const labels = inputs.map((kind) => ({
1161
- text: t('capsText'), image: t('capsImage'), video: t('capsVideo'),
1162
- audio: t('capsAudio'), pdf: t('capsPdf'),
1163
- }[kind] ?? kind));
1164
- if (row.tools === true) labels.push(t('capsTools'));
1165
- if (row.reasoning === true) labels.push(t('capsReasoning'));
1166
- return labels;
1167
- };
1168
-
1169
- function WindowBar({ window, label }) {
1170
- const value = typeof window?.percent === 'number' ? window.percent : null;
1171
- const width = value === null ? 0 : Math.max(2, Math.min(100, value));
1172
- const color = value === null ? 'rgba(127,127,127,.4)' : value >= 100 ? '#d9534f' : value >= 80 ? '#e0a030' : '#3c9d5d';
1173
- return h('div', { style: { marginBottom: 10 } },
1174
- h('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 } },
1175
- h('span', null, label),
1176
- h('span', { style: { fontWeight: 600, color: value !== null && value >= 80 ? color : 'inherit' } },
1177
- value === null ? t('none') : `${value < 10 ? value.toFixed(1) : Math.round(value)}%`),
1178
- ),
1179
- h('div', { style: { height: 6, borderRadius: 999, background: 'rgba(127,127,127,.2)', overflow: 'hidden' } },
1180
- h('div', { style: { width: `${width}%`, height: '100%', background: color, borderRadius: 999 } }),
1181
- ),
1182
- window?.resetsAt
1183
- ? h('div', { style: { fontSize: 11, opacity: .55, marginTop: 3 } }, t('resetsAt', { time: new Date(window.resetsAt).toLocaleString() }))
1184
- : null,
1185
- );
1186
- }
1187
-
1188
- function UsageDialog({ account, busy, onProbe, onClose }) {
1189
- if (!account) return null;
1190
- return h('div', {
1191
- style: {
1192
- position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 1200,
1193
- display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
1194
- },
1195
- onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
1196
- },
1197
- h('div', {
1198
- style: {
1199
- 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)',
1202
- },
1203
- },
1204
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } },
1205
- h('div', { style: { fontWeight: 600, fontSize: 14 } }, t('dialogTitle', { label: account.label })),
1206
- account.active ? h('span', {
1207
- style: { fontSize: 11, padding: '1px 6px', borderRadius: 999, border: '1px solid rgba(60,157,93,.6)', color: '#3c9d5d' },
1208
- }, t('statusCurrent')) : null,
1209
- h('span', { style: { flex: 1 } }),
1210
- h('button', { type: 'button', style: btn, onClick: onClose }, t('close')),
1211
- ),
1212
- h('div', { style: { fontSize: 12, opacity: .6, marginBottom: 12 } },
1213
- account.hint ? h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, account.hint) : t('notConfigured')),
1214
- h(WindowBar, { window: account.usage?.rolling, label: t('colRolling') }),
1215
- h(WindowBar, { window: account.usage?.weekly, label: t('colWeekly') }),
1216
- h(WindowBar, { window: account.usage?.monthly, label: t('colMonthly') }),
1217
- account.usageError ? h('div', { style: { color: '#d9534f', fontSize: 12, marginBottom: 8 } }, t('usageError', { message: account.usageError })) : null,
1218
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
1219
- account.checkedAt ? h('span', { style: { fontSize: 11, opacity: .55 } }, t('probedAt', { time: clock(account.checkedAt) })) : null,
1220
- h('span', { style: { flex: 1 } }),
1221
- h('button', { type: 'button', style: btn, disabled: busy, onClick: onProbe }, t('dialogProbe')),
1222
- ),
1223
- h('div', { style: { fontSize: 11, opacity: .55, marginTop: 12, lineHeight: 1.6 } }, t('dialogNote')),
1224
- ),
1225
- );
1226
- }
1227
-
1228
- function UsageSection() {
1229
- const [state, setState] = React.useState({ phase: 'loading', usage: null, router: null, error: null });
1230
- const [notice, setNotice] = React.useState(null);
1231
- const [actionError, setActionError] = React.useState(null);
1232
- const [draft, setDraft] = React.useState({ label: '', key: '' });
1233
- const [editing, setEditing] = React.useState(null);
1234
- const [keyDraft, setKeyDraft] = React.useState('');
1235
- const [inspecting, setInspecting] = React.useState(null);
1236
- const [busy, setBusy] = React.useState(false);
1237
-
1238
- const load = React.useCallback(async (force) => {
1239
- try {
1240
- const [usageResponse, routerResponse] = await Promise.all([
1241
- fetch(`${USAGE_API}${force ? '?refresh=1' : ''}`, { headers: { accept: 'application/json' } }),
1242
- fetch(ROUTER_API, { headers: { accept: 'application/json' } }).catch(() => null),
1243
- ]);
1244
- if (!usageResponse.ok) throw new Error(`HTTP ${usageResponse.status}`);
1245
- const usage = await usageResponse.json();
1246
- let router = null;
1247
- if (routerResponse && routerResponse.ok) {
1248
- try { router = await routerResponse.json(); } catch { router = null; }
1249
- }
1250
- setState({ phase: 'ready', usage, router, error: null });
1251
- } catch (error) {
1252
- setState((previous) => ({ ...previous, phase: 'error', error: String(error?.message ?? error) }));
1253
- }
1254
- }, []);
1255
-
1256
- const act = React.useCallback(async (payload) => {
1257
- setBusy(true);
1258
- setActionError(null);
1259
- setNotice(null);
1260
- try {
1261
- const response = await fetch(ROUTER_API, {
1262
- method: 'POST',
1263
- headers: { 'content-type': 'application/json', accept: 'application/json' },
1264
- body: JSON.stringify(payload),
1265
- });
1266
- const body = await response.json().catch(() => ({}));
1267
- if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1268
- await load(true);
1269
- return true;
1270
- } catch (error) {
1271
- setActionError(String(error?.message ?? error));
1272
- return false;
1273
- } finally {
1274
- setBusy(false);
1275
- }
1276
- }, [load]);
1277
-
1278
- React.useEffect(() => {
1279
- let alive = true;
1280
- const run = (force) => {
1281
- if (!alive) return;
1282
- void load(force);
1283
- };
1284
- run(false);
1285
- const timer = setInterval(() => run(false), 60000);
1286
- return () => {
1287
- alive = false;
1288
- clearInterval(timer);
1289
- };
1290
- }, [load]);
1291
-
1292
- const data = state.usage;
1293
- const router = state.router;
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
- };
1354
-
1355
- const routerAccounts = new Map((router?.accounts ?? []).map((account) => [account.id, account]));
1356
- const usageAccounts = data?.accounts ?? [];
1357
- const accountIds = router?.accounts?.length
1358
- ? router.accounts.map((account) => account.id)
1359
- : usageAccounts.map((account) => account.id);
1360
- const accountList = accountIds.map((id) => {
1361
- const fromRouter = routerAccounts.get(id);
1362
- const fromUsage = usageAccounts.find((account) => account.id === id);
1363
- return {
1364
- id,
1365
- label: fromRouter?.label ?? fromUsage?.label ?? id,
1366
- hint: fromRouter?.hint ?? fromUsage?.hint ?? null,
1367
- keyRef: fromRouter?.keyRef,
1368
- active: fromRouter?.active ?? fromUsage?.active ?? false,
1369
- enabled: fromRouter?.enabled ?? fromUsage?.enabled ?? true,
1370
- status: fromRouter?.status ?? null,
1371
- exhaustedUntil: fromRouter?.exhaustedUntil ?? null,
1372
- usage: fromRouter?.usage ?? fromUsage?.usage ?? null,
1373
- usageError: fromUsage?.usageError ?? null,
1374
- checkedAt: fromRouter?.checkedAt ?? fromUsage?.checkedAt ?? null,
1375
- configured: fromRouter?.configured ?? fromUsage?.configured ?? false,
1376
- };
1377
- });
1378
-
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) => {
1415
- const [text, color] = statusText[account.status] ?? [
1416
- account.usageError ? t('statusError') : account.enabled === false ? t('statusDisabled') : account.active ? t('statusActive') : t('statusSpare'),
1417
- account.usageError ? '#d9534f' : 'inherit',
1418
- ];
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 } },
1436
- text,
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)),
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')),
1467
- editing === account.id
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')),
1491
- )
1492
- : null,
1493
- );
1494
- });
1495
-
1496
- const addAccount = async () => {
1497
- if (draft.key.trim().length < 8) {
1498
- setActionError(t('shortKey'));
1499
- return;
1500
- }
1501
- const ok = await act({ action: 'add', label: draft.label.trim(), key: draft.key.trim() });
1502
- if (ok) {
1503
- setDraft({ label: '', key: '' });
1504
- setNotice(t('accountAdded'));
1505
- }
1506
- };
1507
-
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', {
1521
- style: {
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,
1526
- },
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
- });
1552
-
1553
- const peak = data?.peak;
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';
1564
- const priceCell = (price, peakFlag, winner) => h('span', {
1565
- style: winner
1566
- ? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
1567
- : undefined,
1568
- }, `${yuan(price)}(${peakFlag ? t('peakAtPeak') : t('peakOffPeak')})`);
1569
-
1570
- const inspectingAccount = accountList.find((account) => account.id === inspecting) ?? null;
1571
-
1572
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
1573
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
1574
- h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
1575
- state.phase === 'ready' ? h('span', { style: { opacity: .55, fontSize: 12 } }, t('updatedAt', { time: clock(data?.checkedAt) })) : null,
1576
- h('span', { style: { flex: 1 } }),
1577
- h('button', { type: 'button', style: btn, disabled: busy, onClick: () => { void load(true); } }, busy ? t('refreshing') : t('refresh')),
1578
- ),
1579
- state.phase === 'error' ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, t('loadError', { message: state.error })) : null,
1580
- actionError ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, actionError) : null,
1581
- notice ? h('div', { style: { color: '#3c9d5d', fontSize: 13 } }, notice) : null,
1582
-
1583
- card(
1584
- cardHead(t('routerTitle'), router
1585
- ? pill(router.mode === 'fallback' ? t('modeFallback') : t('modePrimary'), router.mode === 'fallback' ? '#e0a030' : '#3c9d5d')
1586
- : h('span', { style: { opacity: .7, fontSize: 12 } }, t('routerMissing'))),
1587
- router?.reason ? h('div', { style: { fontSize: 12, opacity: .65 } }, t('modeReason', { reason: reasonText(router.reason) })) : null,
1588
- router
1589
- ? h('div', { style: { display: 'flex', gap: 32, flexWrap: 'wrap' } },
1590
- stat(t('activeAccount'), router.activeLabel ?? t('none')),
1591
- stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) : when(Date.now() + (router.probeIntervalMs ?? 0))),
1592
- stat(t('colStatus'), router.mode === 'fallback' ? t('modeFallback') : t('modePrimary')),
1593
- )
1594
- : null,
1595
- router
1596
- ? 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')),
1599
- h('span', { style: { flexBasis: '100%', fontSize: 11, opacity: .55, lineHeight: 1.6 } }, t('resetHint')),
1600
- )
1601
- : null,
1602
- ),
1603
-
1604
- card(
1605
- cardHead(t('accountsTitle'), h('span', { style: { opacity: .55, fontSize: 12 } }, t('accountsOrder'))),
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')),
1610
- ),
1611
- ),
1612
-
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
- ),
1660
- ),
1661
- )
1662
- : null,
1663
-
1664
- card(
1665
- cardHead(t('modelsTitle')),
1666
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, modelCards),
1667
- ),
1668
-
1669
- h('div', { style: { opacity: .55, fontSize: 12, lineHeight: 1.8 } },
1670
- t('footerOfficial'),
1671
- h('br'),
1672
- t('footerLocal'),
1673
- ),
1674
-
1675
- h(UsageDialog, {
1676
- account: inspectingAccount,
1677
- busy,
1678
- onClose: () => setInspecting(null),
1679
- onProbe: async () => {
1680
- if (!inspectingAccount) return;
1681
- const ok = await act({ action: 'probe', id: inspectingAccount.id });
1682
- if (ok) setNotice(t('probedAt', { time: clock(Date.now()) }));
1683
- },
1684
- }),
1685
- );
1686
- }
1687
-
1688
- return {
1689
- inject: ['slots'],
1690
- apply(ctx) {
1691
- ctx.slots.inject('settings.section', () => ctx.slots.register({
1692
- name: 'settings.section',
1693
- id: 'opencode-router',
1694
- order: 18,
1695
- label: 'OpenCode 路由与用量',
1696
- }, UsageSection));
1697
- },
1698
- };
1699
- },
1700
- });
1132
+ /* @@EXTRA_LOCALES_END@@ */
1133
+
1134
+ const localeOf = (tag) => {
1135
+ const value = String(tag || '').toLowerCase();
1136
+ if (value.startsWith('zh')) return /tw|hk|mo|hant/.test(value) ? 'zh-tw' : 'zh';
1137
+ for (const key of Object.keys(DICT)) if (key !== 'zh' && key !== 'zh-tw' && value.startsWith(key.split('-')[0])) return key;
1138
+ return 'en';
1139
+ };
1140
+ const LANG = localeOf(typeof navigator === 'undefined' ? '' : navigator.language);
1141
+ const dict = DICT[LANG] ?? DICT.en;
1142
+ const t = (key, vars) => {
1143
+ const template = dict[key] ?? DICT.en[key] ?? key;
1144
+ if (vars === undefined) return template;
1145
+ return template.replace(/\{(\w+)\}/g, (match, name) => (vars[name] === undefined ? match : String(vars[name])));
1146
+ };
1147
+
1148
+ const money = (value) => {
1149
+ if (value === undefined || value === null) return t('none');
1150
+ if (value === 'free') return t('free');
1151
+ if (value === Infinity) return t('unlimited');
1152
+ return value < 1 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`;
1153
+ };
1154
+ const tokens = (n) => (n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
1155
+ const yuan = (value) => {
1156
+ if (value === undefined || value === null) return t('none');
1157
+ return value >= 1 ? `¥${value.toFixed(2)}` : `¥${value.toFixed(3)}`;
1158
+ };
1159
+ const percentText = (used, limit, free) => {
1160
+ if (free) return t('unlimited');
1161
+ if (limit === undefined || limit === null) return t('none');
1162
+ if (limit === Infinity || limit === 'free') return t('unlimited');
1163
+ if (!limit) return t('none');
1164
+ const ratio = (used / limit) * 100;
1165
+ return `${ratio < 10 ? ratio.toFixed(1) : ratio.toFixed(0)}%`;
1166
+ };
1167
+ const official = (window) => {
1168
+ const value = window && typeof window.percent === 'number' ? window.percent : null;
1169
+ if (value === null) return t('none');
1170
+ const color = value >= 100 ? '#d9534f' : value >= 80 ? '#e0a030' : 'inherit';
1171
+ return h('span', { style: { color } }, `${value < 10 ? value.toFixed(1) : Math.round(value)}%`);
1172
+ };
1173
+ const clock = (ms) => (ms ? new Date(ms).toLocaleString() : t('none'));
1174
+ const when = (ms) => {
1175
+ if (!ms) return t('none');
1176
+ const delta = ms - Date.now();
1177
+ if (delta <= 0) return LANG === 'zh' || LANG === 'zh-tw' ? '已到期' : 'expired';
1178
+ const minutes = Math.floor(delta / 60000);
1179
+ const seconds = Math.floor((delta % 60000) / 1000);
1180
+ const relative = LANG === 'zh' || LANG === 'zh-tw'
1181
+ ? minutes < 60
1182
+ ? `${minutes} 分 ${String(seconds).padStart(2, '0')} 秒后`
1183
+ : `${Math.round(minutes / 60)} 小时后`
1184
+ : new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1185
+ return relative;
1186
+ };
1187
+
1188
+ // The dialog floats over the page, so it cannot inherit a translucent surface:
1189
+ // it needs a solid fill and an explicit text colour for the current theme.
1190
+ const theme = () => {
1191
+ let dark = false;
1192
+ try {
1193
+ if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches) dark = true;
1194
+ else {
1195
+ const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(getComputedStyle(document.documentElement).backgroundColor ?? '');
1196
+ if (match !== null) dark = (Number(match[1]) + Number(match[2]) + Number(match[3])) / 3 < 128;
1197
+ }
1198
+ } catch {
1199
+ dark = true;
1200
+ }
1201
+ return dark
1202
+ ? { panel: '#202124', text: '#ececec', line: 'rgba(255,255,255,.18)', scrim: 'rgba(0,0,0,.55)' }
1203
+ : { panel: '#ffffff', text: '#1b1b1b', line: 'rgba(0,0,0,.15)', scrim: 'rgba(0,0,0,.35)' };
1204
+ };
1205
+
1206
+ // Model ids read badly in a table ("deepseek-v4.1-flash"); the page shows the
1207
+ // vendor casing plus spaces instead.
1208
+ const MODEL_CASE = [
1209
+ [/^deepseek/i, 'DeepSeek'], [/^glm/i, 'GLM'], [/^gpt/i, 'GPT'], [/^claude/i, 'Claude'],
1210
+ [/^grok/i, 'Grok'], [/^qwen/i, 'Qwen'], [/^kimi/i, 'Kimi'], [/^minimax/i, 'MiniMax'],
1211
+ [/^mimo/i, 'MiMo'], [/^longcat/i, 'LongCat'], [/^hunyuan/i, 'Hunyuan'], [/^hy\d/i, 'Hunyuan'],
1212
+ [/^muse/i, 'Muse'],
1213
+ ];
1214
+ const prettyModel = (id) => {
1215
+ const text = String(id ?? '');
1216
+ let head = '';
1217
+ let rest = text;
1218
+ for (const [pattern, name] of MODEL_CASE) {
1219
+ if (!pattern.test(text)) continue;
1220
+ head = name;
1221
+ rest = text.replace(pattern, '');
1222
+ break;
1223
+ }
1224
+ const tail = rest
1225
+ .replace(/[-_]+/g, ' ')
1226
+ .split(' ')
1227
+ .filter(Boolean)
1228
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1229
+ .join(' ');
1230
+ return [head, tail].filter(Boolean).join(' ');
1231
+ };
1232
+
1233
+ const btn = {
1234
+ padding: '5px 12px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
1235
+ background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: 12,
1236
+ lineHeight: 1.6, whiteSpace: 'nowrap',
1237
+ };
1238
+ const input = {
1239
+ padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(127,127,127,.45)',
1240
+ background: 'transparent', color: 'inherit', font: 'inherit', fontSize: 13,
1241
+ };
1242
+ const cell = (content, extra) =>
1243
+ h('td', { style: { padding: '5px 10px', borderBottom: '1px solid rgba(127,127,127,.22)', whiteSpace: 'nowrap', ...extra } }, content);
1244
+ const th = (title) =>
1245
+ h('th', {
1246
+ key: title,
1247
+ style: {
1248
+ textAlign: 'left', padding: '6px 10px', fontWeight: 600, whiteSpace: 'nowrap',
1249
+ borderBottom: '1px solid rgba(127,127,127,.3)', position: 'sticky', top: 0,
1250
+ background: 'var(--dsh-surface, rgba(127,127,127,.08))', backdropFilter: 'blur(4px)',
1251
+ },
1252
+ }, title);
1253
+
1254
+ // Horizontal drag-to-scroll for wide tables: press anywhere in the pane and
1255
+ // drag. A press that starts on a control is left alone, so buttons and inputs
1256
+ // keep working inside the scroller.
1257
+ const draggable = () => ({
1258
+ onMouseDown: (event) => {
1259
+ if (event.button !== 0) return;
1260
+ const tag = event.target && event.target.tagName;
1261
+ if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'A') return;
1262
+ const pane = event.currentTarget;
1263
+ if (pane.scrollWidth <= pane.clientWidth + 1) return;
1264
+ const startX = event.clientX;
1265
+ const startLeft = pane.scrollLeft;
1266
+ const move = (moveEvent) => {
1267
+ pane.scrollLeft = startLeft - (moveEvent.clientX - startX);
1268
+ };
1269
+ const stop = () => {
1270
+ pane.style.cursor = 'grab';
1271
+ pane.style.userSelect = '';
1272
+ window.removeEventListener('mousemove', move);
1273
+ window.removeEventListener('mouseup', stop);
1274
+ };
1275
+ pane.style.cursor = 'grabbing';
1276
+ pane.style.userSelect = 'none';
1277
+ window.addEventListener('mousemove', move);
1278
+ window.addEventListener('mouseup', stop);
1279
+ },
1280
+ });
1281
+
1282
+ const STATUS = () => ({
1283
+ ready: [t('statusReady'), '#3c9d5d'],
1284
+ exhausted: [t('statusExhausted'), '#e0a030'],
1285
+ auth: [t('statusAuth'), '#d9534f'],
1286
+ disabled: [t('statusDisabled'), 'rgba(127,127,127,.8)'],
1287
+ missing: [t('statusMissing'), '#d9534f'],
1288
+ });
1289
+
1290
+ const capabilityTags = (row) => {
1291
+ const inputs = Array.isArray(row.inputs) && row.inputs.length > 0 ? row.inputs : ['text'];
1292
+ const labels = inputs.map((kind) => ({
1293
+ text: t('capsText'), image: t('capsImage'), video: t('capsVideo'),
1294
+ audio: t('capsAudio'), pdf: t('capsPdf'),
1295
+ }[kind] ?? kind));
1296
+ if (row.tools === true) labels.push(t('capsTools'));
1297
+ if (row.reasoning === true) labels.push(t('capsReasoning'));
1298
+ return labels;
1299
+ };
1300
+
1301
+ function WindowBar({ window, label }) {
1302
+ const value = typeof window?.percent === 'number' ? window.percent : null;
1303
+ const width = value === null ? 0 : Math.max(2, Math.min(100, value));
1304
+ const color = value === null ? 'rgba(127,127,127,.4)' : value >= 100 ? '#d9534f' : value >= 80 ? '#e0a030' : '#3c9d5d';
1305
+ return h('div', { style: { marginBottom: 10 } },
1306
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 } },
1307
+ h('span', null, label),
1308
+ h('span', { style: { fontWeight: 600, color: value !== null && value >= 80 ? color : 'inherit' } },
1309
+ value === null ? t('none') : `${value < 10 ? value.toFixed(1) : Math.round(value)}%`),
1310
+ ),
1311
+ h('div', { style: { height: 6, borderRadius: 999, background: 'rgba(127,127,127,.2)', overflow: 'hidden' } },
1312
+ h('div', { style: { width: `${width}%`, height: '100%', background: color, borderRadius: 999 } }),
1313
+ ),
1314
+ window?.resetsAt
1315
+ ? h('div', { style: { fontSize: 11, opacity: .55, marginTop: 3 } }, t('resetsAt', { time: new Date(window.resetsAt).toLocaleString() }))
1316
+ : null,
1317
+ );
1318
+ }
1319
+
1320
+ function UsageDialog({ account, busy, onProbe, onClose }) {
1321
+ if (!account) return null;
1322
+ const skin = theme();
1323
+ return h('div', {
1324
+ style: {
1325
+ position: 'fixed', inset: 0, background: skin.scrim, zIndex: 1200,
1326
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
1327
+ },
1328
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
1329
+ },
1330
+ h('div', {
1331
+ style: {
1332
+ minWidth: 380, maxWidth: 520, width: '100%', padding: '16px 18px', borderRadius: 10,
1333
+ background: skin.panel, color: skin.text, opacity: 1,
1334
+ border: `1px solid ${skin.line}`, boxShadow: '0 18px 48px rgba(0,0,0,.45)',
1335
+ },
1336
+ },
1337
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } },
1338
+ h('div', { style: { fontWeight: 600, fontSize: 14 } }, t('dialogTitle', { label: account.label })),
1339
+ account.active ? h('span', {
1340
+ style: { fontSize: 11, padding: '1px 6px', borderRadius: 999, border: '1px solid rgba(60,157,93,.6)', color: '#3c9d5d' },
1341
+ }, t('statusCurrent')) : null,
1342
+ h('span', { style: { flex: 1 } }),
1343
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: onClose }, t('close')),
1344
+ ),
1345
+ h('div', { style: { fontSize: 12, opacity: .6, marginBottom: 12 } },
1346
+ account.hint ? h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace' } }, account.hint) : t('notConfigured')),
1347
+ h(WindowBar, { window: account.usage?.rolling, label: t('colRolling') }),
1348
+ h(WindowBar, { window: account.usage?.weekly, label: t('colWeekly') }),
1349
+ h(WindowBar, { window: account.usage?.monthly, label: t('colMonthly') }),
1350
+ account.usageError ? h('div', { style: { color: '#d9534f', fontSize: 12, marginBottom: 8 } }, t('usageError', { message: account.usageError })) : null,
1351
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
1352
+ account.checkedAt ? h('span', { style: { fontSize: 11, opacity: .55 } }, t('probedAt', { time: clock(account.checkedAt) })) : null,
1353
+ h('span', { style: { flex: 1 } }),
1354
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: onProbe }, t('dialogProbe')),
1355
+ ),
1356
+ h('div', { style: { fontSize: 11, opacity: .55, marginTop: 12, lineHeight: 1.6 } }, t('dialogNote')),
1357
+ ),
1358
+ );
1359
+ }
1360
+
1361
+ function UsageSection() {
1362
+ const [state, setState] = React.useState({ phase: 'loading', usage: null, router: null, error: null });
1363
+ const [notice, setNotice] = React.useState(null);
1364
+ const [actionError, setActionError] = React.useState(null);
1365
+ const [draft, setDraft] = React.useState({ label: '', key: '' });
1366
+ const [editing, setEditing] = React.useState(null);
1367
+ const [keyDraft, setKeyDraft] = React.useState('');
1368
+ const [inspecting, setInspecting] = React.useState(null);
1369
+ const [busy, setBusy] = React.useState(false);
1370
+ const [demo, setDemo] = React.useState(false);
1371
+ // Re-render every second so the "next probe" line counts down instead of
1372
+ // freezing at whatever it said when the page opened.
1373
+ const [, setTick] = React.useState(0);
1374
+ React.useEffect(() => {
1375
+ const timer = setInterval(() => setTick((value) => value + 1), 1000);
1376
+ return () => clearInterval(timer);
1377
+ }, []);
1378
+
1379
+ const load = React.useCallback(async (force) => {
1380
+ try {
1381
+ const [usageResponse, routerResponse] = await Promise.all([
1382
+ fetch(`${USAGE_API}${force ? '?refresh=1' : ''}`, { headers: { accept: 'application/json' } }),
1383
+ fetch(ROUTER_API, { headers: { accept: 'application/json' } }).catch(() => null),
1384
+ ]);
1385
+ if (!usageResponse.ok) throw new Error(`HTTP ${usageResponse.status}`);
1386
+ const usage = await usageResponse.json();
1387
+ let router = null;
1388
+ if (routerResponse && routerResponse.ok) {
1389
+ try { router = await routerResponse.json(); } catch { router = null; }
1390
+ }
1391
+ setState({ phase: 'ready', usage, router, error: null });
1392
+ } catch (error) {
1393
+ setState((previous) => ({ ...previous, phase: 'error', error: String(error?.message ?? error) }));
1394
+ }
1395
+ }, []);
1396
+
1397
+ const act = React.useCallback(async (payload) => {
1398
+ setBusy(true);
1399
+ setActionError(null);
1400
+ setNotice(null);
1401
+ try {
1402
+ const response = await fetch(ROUTER_API, {
1403
+ method: 'POST',
1404
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1405
+ body: JSON.stringify(payload),
1406
+ });
1407
+ const body = await response.json().catch(() => ({}));
1408
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1409
+ await load(true);
1410
+ return true;
1411
+ } catch (error) {
1412
+ setActionError(String(error?.message ?? error));
1413
+ return false;
1414
+ } finally {
1415
+ setBusy(false);
1416
+ }
1417
+ }, [load]);
1418
+
1419
+ // Editing a key starts from the stored value, so the input is pre-filled.
1420
+ const revealKey = React.useCallback(async (id) => {
1421
+ try {
1422
+ const response = await fetch(`${ROUTER_API}?key=${encodeURIComponent(id)}`, { headers: { accept: 'application/json' } });
1423
+ const body = await response.json().catch(() => ({}));
1424
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1425
+ if (typeof body?.key === 'string') setKeyDraft(body.key);
1426
+ } catch (error) {
1427
+ setActionError(String(error?.message ?? error));
1428
+ }
1429
+ }, []);
1430
+
1431
+ React.useEffect(() => {
1432
+ let alive = true;
1433
+ const run = (force) => {
1434
+ if (!alive) return;
1435
+ void load(force);
1436
+ };
1437
+ run(false);
1438
+ const timer = setInterval(() => run(false), 60000);
1439
+ return () => {
1440
+ alive = false;
1441
+ clearInterval(timer);
1442
+ };
1443
+ }, [load]);
1444
+
1445
+ const data = state.usage;
1446
+ const router = state.router;
1447
+ const statusText = STATUS();
1448
+ const chinese = LANG === 'zh' || LANG === 'zh-tw';
1449
+
1450
+ // The router stores machine reasons ("allowance recovered"); the page says
1451
+ // what actually happened instead of echoing the enum.
1452
+ const reasonText = (reason) => {
1453
+ const raw = String(reason ?? '');
1454
+ const pick = (zh, en) => (chinese ? zh : en);
1455
+ const rules = [
1456
+ [/^allowance recovered/i, pick('额度已恢复,已切回 OpenCode 账户池', 'allowance recovered, back on the OpenCode pool')],
1457
+ [/^reset in Settings/i, pick('在设置里重置了封禁标记', 'marks were reset in Settings')],
1458
+ [/^selected in Settings/i, pick('在设置里手动设为当前账户', 'selected by hand in Settings')],
1459
+ [/^first account added/i, pick('首个账户已启用', 'the first account was enabled')],
1460
+ [/^initial$/i, pick('初始状态', 'initial')],
1461
+ [/^loaded$/i, pick('从状态文件恢复', 'restored from the state file')],
1462
+ [/^failover: (\S+) unavailable/i, pick('上一个账户不可用,已自动换下一个', 'the previous account went unavailable, switched to the next one')],
1463
+ [/^account failover after (\w+)/i, pick('鉴权失败($1),已换下一个账户', 'authentication failed ($1), switched to the next account')],
1464
+ [/^quota failover from (\S+)/i, pick('$1 额度用尽,已换下一个账户', '$1 ran out of allowance, switched to the next account')],
1465
+ [/^every OpenCode account is out of allowance/i, pick('所有账户额度都用完了,暂时走官方直连', 'every account is out of allowance, using the official API for now')],
1466
+ [/^OpenCode authentication failed \((\w+)\)/i, pick('OpenCode 鉴权失败($1),暂时走官方直连', 'OpenCode authentication failed ($1), using the official API for now')],
1467
+ [/^OpenCode quota window exhausted/i, pick('本窗口额度用尽,暂时走官方直连', 'this allowance window is used up, using the official API for now')],
1468
+ [/^OpenCode quota exhausted/i, pick('额度用尽,暂时走官方直连', 'out of allowance, using the official API for now')],
1469
+ [/^active account (\S+) removed/i, pick('当前账户被删除,已自动换下一个', 'the active account was deleted, switched to the next one')],
1470
+ [/^auth \((.+)\)/i, pick('鉴权失败:$1', 'authentication failed: $1')],
1471
+ ];
1472
+ for (const [pattern, text] of rules) {
1473
+ if (pattern.test(raw)) return raw.replace(pattern, text);
1474
+ }
1475
+ return raw;
1476
+ };
1477
+
1478
+ // A vendor badge per model family: the mark and the tint both come from the
1479
+ // family, so a card's gradient matches its avatar.
1480
+ const LOGOS = {
1481
+ 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',
1482
+ 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',
1483
+ 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',
1484
+ 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',
1485
+ 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',
1486
+ 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',
1487
+ 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',
1488
+ };
1489
+
1490
+ const VENDORS = [
1491
+ [/^deepseek/i, { mark: 'DS', name: 'DeepSeek', color: '#4d6bfe', logo: 'deepseek' }],
1492
+ [/^glm/i, { mark: 'GLM', name: 'Zhipu', color: '#3b6cf6' }],
1493
+ [/^gpt/i, { mark: 'GPT', name: 'OpenAI', color: '#10a37f' }],
1494
+ [/^claude/i, { mark: 'C', name: 'Anthropic', color: '#d97757', logo: 'claude' }],
1495
+ [/^grok/i, { mark: 'X', name: 'xAI', color: '#64748b', logo: 'grok' }],
1496
+ [/^qwen/i, { mark: 'Q', name: 'Qwen', color: '#615ced', logo: 'qwen' }],
1497
+ [/^kimi/i, { mark: 'K', name: 'Moonshot', color: '#8b5cf6', logo: 'kimi' }],
1498
+ [/^minimax/i, { mark: 'MM', name: 'MiniMax', color: '#ff6b00', logo: 'minimax' }],
1499
+ [/^mimo/i, { mark: 'Mi', name: 'MiMo', color: '#ff6900', logo: 'mimo' }],
1500
+ [/^longcat/i, { mark: 'LC', name: 'LongCat', color: '#f59e0b' }],
1501
+ [/^hy\d/i, { mark: 'H', name: 'Hunyuan', color: '#0057ff' }],
1502
+ [/^muse/i, { mark: 'M', name: 'Muse', color: '#a855f7' }],
1503
+ [/free$/i, { mark: '∞', name: 'Free', color: '#3c9d5d' }],
1504
+ ];
1505
+ const vendorOf = (model) => {
1506
+ const hit = VENDORS.find(([pattern]) => pattern.test(model));
1507
+ const vendor = hit ? { ...hit[1] } : { mark: String(model).slice(0, 2).toUpperCase(), name: model, color: '#6b7280' };
1508
+ if (vendor.logo && LOGOS[vendor.logo]) vendor.path = LOGOS[vendor.logo];
1509
+ return vendor;
1510
+ };
1511
+
1512
+ const CAP_ICON = { text: '📝', image: '🖼️', video: '🎬', audio: '🎵', pdf: '📄' };
1513
+ const capIcon = (label) => {
1514
+ if (label === t('capsTools')) return '🛠️';
1515
+ if (label === t('capsReasoning')) return '🧠';
1516
+ return CAP_ICON[label] ?? '•';
1517
+ };
1518
+
1519
+ const routerAccounts = new Map((router?.accounts ?? []).map((account) => [account.id, account]));
1520
+ const usageAccounts = data?.accounts ?? [];
1521
+ const accountIds = router?.accounts?.length
1522
+ ? router.accounts.map((account) => account.id)
1523
+ : usageAccounts.map((account) => account.id);
1524
+ const accountList = accountIds.map((id) => {
1525
+ const fromRouter = routerAccounts.get(id);
1526
+ const fromUsage = usageAccounts.find((account) => account.id === id);
1527
+ return {
1528
+ id,
1529
+ label: fromRouter?.label ?? fromUsage?.label ?? id,
1530
+ hint: fromRouter?.hint ?? fromUsage?.hint ?? null,
1531
+ keyRef: fromRouter?.keyRef,
1532
+ active: fromRouter?.active ?? fromUsage?.active ?? false,
1533
+ enabled: fromRouter?.enabled ?? fromUsage?.enabled ?? true,
1534
+ status: fromRouter?.status ?? null,
1535
+ exhaustedUntil: fromRouter?.exhaustedUntil ?? null,
1536
+ usage: fromRouter?.usage ?? fromUsage?.usage ?? null,
1537
+ usageError: fromUsage?.usageError ?? null,
1538
+ checkedAt: fromRouter?.checkedAt ?? fromUsage?.checkedAt ?? null,
1539
+ configured: fromRouter?.configured ?? fromUsage?.configured ?? false,
1540
+ };
1541
+ });
1542
+
1543
+ // Layout primitives: every block is a card with its own padding and gap, so
1544
+ // buttons never sit flush against each other or against the table edges.
1545
+ const card = (...children) => h('div', {
1546
+ style: {
1547
+ padding: '14px 16px', border: '1px solid rgba(127,127,127,.25)', borderRadius: 10,
1548
+ background: 'var(--dsh-surface, rgba(127,127,127,.04))',
1549
+ display: 'flex', flexDirection: 'column', gap: 12,
1550
+ },
1551
+ }, ...children);
1552
+
1553
+ const cardHead = (text, extra) => h('div', {
1554
+ style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
1555
+ },
1556
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, text),
1557
+ extra ?? null,
1558
+ );
1559
+
1560
+ const stat = (label, value) => h('div', {
1561
+ style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 96 },
1562
+ },
1563
+ h('span', { style: { fontSize: 11, opacity: .55 } }, label),
1564
+ h('span', { style: { fontSize: 13, fontWeight: 500 } }, value),
1565
+ );
1566
+
1567
+ const tools = (...children) => h('div', {
1568
+ style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
1569
+ }, ...children);
1570
+
1571
+ const pill = (text, color) => h('span', {
1572
+ style: {
1573
+ fontSize: 11, padding: '1px 8px', borderRadius: 999,
1574
+ border: `1px solid ${color}99`, color,
1575
+ },
1576
+ }, text);
1577
+
1578
+ const accountCards = accountList.map((account, index) => {
1579
+ const [text, color] = statusText[account.status] ?? [
1580
+ account.usageError ? t('statusError') : account.enabled === false ? t('statusDisabled') : account.active ? t('statusActive') : t('statusSpare'),
1581
+ account.usageError ? '#d9534f' : 'inherit',
1582
+ ];
1583
+ return h('div', {
1584
+ key: account.id,
1585
+ style: {
1586
+ display: 'flex', flexDirection: 'column', gap: 12, padding: '12px 14px', borderRadius: 10,
1587
+ border: `1px solid ${account.active ? 'rgba(60,157,93,.45)' : 'rgba(127,127,127,.22)'}`,
1588
+ background: account.active ? 'rgba(60,157,93,.06)' : 'rgba(127,127,127,.03)',
1589
+ },
1590
+ },
1591
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1592
+ h('span', { style: { opacity: .45, fontSize: 11 } }, `#${index + 1}`),
1593
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, account.label),
1594
+ account.active ? pill(t('statusCurrent'), '#3c9d5d') : null,
1595
+ h('span', {
1596
+ style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 12, opacity: account.hint ? .65 : .45 },
1597
+ }, account.hint ?? t('notConfigured')),
1598
+ h('span', { style: { flex: 1 } }),
1599
+ h('span', { style: { color, fontSize: 12 } },
1600
+ text,
1601
+ account.exhaustedUntil ? ` · ${clock(account.exhaustedUntil)}` : ''),
1602
+ ),
1603
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 12 } },
1604
+ stat(t('colRolling'), official(account.usage?.rolling)),
1605
+ stat(t('colWeekly'), official(account.usage?.weekly)),
1606
+ stat(t('colMonthly'), official(account.usage?.monthly)),
1607
+ ),
1608
+ router
1609
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', paddingTop: 2 } },
1610
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: () => setInspecting(account.id) }, t('viewUsage')),
1611
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy || account.active, onClick: () => void act({ action: 'activate', id: account.id }) }, t('setActive')),
1612
+ 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')),
1613
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'up' }); } }, '↑'),
1614
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void act({ action: 'move', id: account.id, direction: 'down' }); } }, '↓'),
1615
+ h('button', {
1616
+ type: 'button', className: 'ocr-btn', style: btn, disabled: busy,
1617
+ onClick: () => {
1618
+ if (editing === account.id) {
1619
+ setEditing(null);
1620
+ setKeyDraft('');
1621
+ return;
1622
+ }
1623
+ setEditing(account.id);
1624
+ setKeyDraft('');
1625
+ void revealKey(account.id);
1626
+ },
1627
+ }, t('editKey')),
1628
+ h('button', {
1629
+ type: 'button', className: 'ocr-btn', style: { ...btn, color: '#d9534f' }, disabled: busy,
1630
+ onClick: () => {
1631
+ if (accountList.length <= 1) {
1632
+ setActionError(t('keepOne'));
1633
+ return;
1634
+ }
1635
+ void act({ action: 'remove', id: account.id });
1636
+ },
1637
+ }, t('remove')),
1638
+ )
1639
+ : h('span', { style: { opacity: .5, fontSize: 12 } }, t('readonly')),
1640
+ editing === account.id
1641
+ ? h('div', {
1642
+ style: {
1643
+ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
1644
+ paddingTop: 10, borderTop: '1px solid rgba(127,127,127,.18)',
1645
+ },
1646
+ },
1647
+ h('span', { style: { opacity: .7, fontSize: 12 } }, t('newKeyFor', { ref: account.keyRef ?? '' })),
1648
+ h('input', {
1649
+ style: { ...input, minWidth: 300 }, type: 'password', placeholder: 'oc_sk_…', value: keyDraft,
1650
+ onChange: (event) => setKeyDraft(event.target.value),
1651
+ }),
1652
+ h('button', {
1653
+ type: 'button', className: 'ocr-btn', style: btn, disabled: busy || keyDraft.trim().length < 8,
1654
+ onClick: async () => {
1655
+ const ok = await act({ action: 'update', id: account.id, key: keyDraft.trim() });
1656
+ if (ok) {
1657
+ setEditing(null);
1658
+ setKeyDraft('');
1659
+ setNotice(t('keyUpdated'));
1660
+ }
1661
+ },
1662
+ }, t('save')),
1663
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, onClick: () => { setEditing(null); setKeyDraft(''); } }, t('cancel')),
1664
+ )
1665
+ : null,
1666
+ );
1667
+ });
1668
+
1669
+ const addAccount = async () => {
1670
+ if (draft.key.trim().length < 8) {
1671
+ setActionError(t('shortKey'));
1672
+ return;
1673
+ }
1674
+ const ok = await act({ action: 'add', label: draft.label.trim(), key: draft.key.trim() });
1675
+ if (ok) {
1676
+ setDraft({ label: '', key: '' });
1677
+ setNotice(t('accountAdded'));
1678
+ }
1679
+ };
1680
+
1681
+ const modelCards = (data?.models ?? []).map((row) => {
1682
+ const vendor = vendorOf(row.model);
1683
+ const chips = capabilityTags(row);
1684
+ return h('div', {
1685
+ key: row.model,
1686
+ style: {
1687
+ display: 'flex', flexDirection: 'column', gap: 12, padding: '14px 16px', borderRadius: 10,
1688
+ border: `1px solid ${vendor.color}3d`,
1689
+ background: `linear-gradient(135deg, ${vendor.color}2e, ${vendor.color}0f 55%, rgba(127,127,127,.03))`,
1690
+ },
1691
+ },
1692
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1693
+ h('span', {
1694
+ style: {
1695
+ width: 30, height: 30, borderRadius: '50%', flex: 'none',
1696
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1697
+ background: `${vendor.color}26`, border: `1px solid ${vendor.color}80`,
1698
+ color: vendor.color, fontSize: 11, fontWeight: 700,
1699
+ },
1700
+ },
1701
+ vendor.path
1702
+ ? h('svg', { viewBox: '0 0 24 24', width: 17, height: 17, role: 'img', 'aria-label': vendor.name },
1703
+ h('path', { d: vendor.path, fill: vendor.color }),
1704
+ )
1705
+ : vendor.mark,
1706
+ ),
1707
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, prettyModel(row.model)),
1708
+ row.free ? pill(t('unlimited'), '#3c9d5d') : null,
1709
+ ),
1710
+ chips.length > 0
1711
+ ? h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1712
+ chips.map((label) => h('span', {
1713
+ key: label,
1714
+ style: {
1715
+ display: 'inline-flex', alignItems: 'center', gap: 4,
1716
+ padding: '2px 8px', borderRadius: 999, fontSize: 11,
1717
+ border: `1px solid ${vendor.color}59`, background: `${vendor.color}14`, color: 'inherit',
1718
+ },
1719
+ }, h('span', null, capIcon(label)), h('span', null, label))),
1720
+ )
1721
+ : null,
1722
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: 12 } },
1723
+ stat(t('colRemaining'), row.free ? t('unlimited') : money(row.remaining)),
1724
+ stat(t('colMonthlyUsed'), row.free ? t('unlimited') : `${money(row.monthly)} / ${money(row.limit)}`),
1725
+ stat(t('colWeeklyUsed'), row.free ? t('unlimited') : money(row.weekly)),
1726
+ stat(t('colRolling'), percentText(row.rolling, row.rollingLimit, row.free)),
1727
+ stat(t('colRequests'), String(row.requests)),
1728
+ ),
1729
+ );
1730
+ });
1731
+
1732
+ const peak = data?.peak;
1733
+ const quotaSpent = typeof data?.usage?.monthly?.percent === 'number' ? data.usage.monthly.percent : null;
1734
+ const exhausted = quotaSpent !== null && quotaSpent >= 100;
1735
+ // The real numbers can never favour the official API (a $10 plan carries
1736
+ // $100 of list price), so the page can also show what that case looks like.
1737
+ const demoPeak = {
1738
+ plan: peak?.plan ?? { leverage: 10 },
1739
+ models: [{
1740
+ model: 'deepseek-v4-pro',
1741
+ rows: [
1742
+ { kind: 'cacheRead', go: { pay: 0.015, factor: 1 }, official: { pay: 0.15, factor: 1 }, cheaper: 'go', ratio: 0.1 },
1743
+ { kind: 'input', go: { pay: 0.45, factor: 1 }, official: { pay: 4.5, factor: 1 }, cheaper: 'go', ratio: 0.1 },
1744
+ { kind: 'output', go: { pay: 1.35, factor: 1 }, official: { pay: 13.5, factor: 1 }, cheaper: 'go', ratio: 0.1 },
1745
+ ],
1746
+ go: { pay: 13.5, factor: 1 },
1747
+ official: { pay: 9, factor: 1 },
1748
+ cheaper: 'official',
1749
+ ratio: 1.5,
1750
+ }],
1751
+ };
1752
+ const shown = demo ? demoPeak : peak;
1753
+ const allGo = shown ? shown.models.every((row) => row.cheaper === 'go') : false;
1754
+ const saving = shown && shown.models.length > 0
1755
+ ? Math.round(Math.max(...shown.models.map((row) => (row.cheaper === 'go' ? (1 - row.ratio) * 100 : (row.ratio - 1) * 100))))
1756
+ : 0;
1757
+ const verdict = exhausted && !demo
1758
+ ? t('verdictExhausted')
1759
+ : allGo ? t('verdictGo', { percent: saving }) : t('verdictOfficial', { percent: saving });
1760
+ const verdictColor = exhausted && !demo ? '#e0a030' : allGo ? '#3c9d5d' : '#e0a030';
1761
+ const priceCell = (price, peakFlag, winner) => h('span', {
1762
+ style: winner
1763
+ ? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
1764
+ : undefined,
1765
+ }, `${yuan(price)}(${peakFlag ? t('peakAtPeak') : t('peakOffPeak')})`);
1766
+
1767
+ const inspectingAccount = accountList.find((account) => account.id === inspecting) ?? null;
1768
+
1769
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
1770
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
1771
+ h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
1772
+ state.phase === 'ready' ? h('span', { style: { opacity: .55, fontSize: 12 } }, t('updatedAt', { time: clock(data?.checkedAt) })) : null,
1773
+ h('span', { style: { flex: 1 } }),
1774
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void load(true); } }, busy ? t('refreshing') : t('refresh')),
1775
+ ),
1776
+ state.phase === 'error' ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, t('loadError', { message: state.error })) : null,
1777
+ actionError ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, actionError) : null,
1778
+ notice ? h('div', { style: { color: '#3c9d5d', fontSize: 13 } }, notice) : null,
1779
+
1780
+ card(
1781
+ cardHead(t('routerTitle'), router
1782
+ ? pill(router.mode === 'fallback' ? t('modeFallback') : t('modePrimary'), router.mode === 'fallback' ? '#e0a030' : '#3c9d5d')
1783
+ : h('span', { style: { opacity: .7, fontSize: 12 } }, t('routerMissing'))),
1784
+ router?.reason ? h('div', { style: { fontSize: 12, opacity: .65 } }, t('modeReason', { reason: reasonText(router.reason) })) : null,
1785
+ router
1786
+ ? h('div', { style: { display: 'flex', gap: 32, flexWrap: 'wrap' } },
1787
+ stat(t('activeAccount'), router.activeLabel ?? t('none')),
1788
+ stat(t('nextProbe'), router.nextProbeAt ? when(router.nextProbeAt) : when(Date.now() + (router.probeIntervalMs ?? 0))),
1789
+ stat(t('colStatus'), router.mode === 'fallback' ? t('modeFallback') : t('modePrimary')),
1790
+ )
1791
+ : null,
1792
+ router
1793
+ ? tools(
1794
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => void act({ action: 'probe' }) }, t('probeAll')),
1795
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => void act({ action: 'reset' }) }, t('resetMarks')),
1796
+ h('span', { style: { flexBasis: '100%', fontSize: 11, opacity: .55, lineHeight: 1.6 } }, t('resetHint')),
1797
+ )
1798
+ : null,
1799
+ ),
1800
+
1801
+ card(
1802
+ cardHead(t('accountsTitle'), h('span', { style: { opacity: .55, fontSize: 12 } }, t('accountsOrder'))),
1803
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1804
+ accountCards.length > 0
1805
+ ? accountCards
1806
+ : h('div', { style: { opacity: .6, fontSize: 12 } }, t('noAccounts')),
1807
+ ),
1808
+ ),
1809
+
1810
+ router
1811
+ ? card(
1812
+ cardHead(t('addAccount')),
1813
+ h('div', { style: { display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' } },
1814
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
1815
+ t('colAccount'),
1816
+ h('input', {
1817
+ style: { ...input, minWidth: 200 }, placeholder: t('labelPlaceholder'), value: draft.label,
1818
+ onChange: (event) => setDraft({ ...draft, label: event.target.value }),
1819
+ }),
1820
+ ),
1821
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11, opacity: .8 } },
1822
+ t('colKey'),
1823
+ h('input', {
1824
+ style: { ...input, minWidth: 320 }, type: 'password', placeholder: t('keyPlaceholder'), value: draft.key,
1825
+ onChange: (event) => setDraft({ ...draft, key: event.target.value }),
1826
+ }),
1827
+ ),
1828
+ h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy || draft.key.trim().length < 8, onClick: () => void addAccount() }, t('addAndProbe')),
1829
+ ),
1830
+ )
1831
+ : null,
1832
+
1833
+ shown
1834
+ ? card(
1835
+ h('div', {
1836
+ style: {
1837
+ padding: '16px 18px', borderRadius: 10, fontSize: 18, fontWeight: 700, lineHeight: 1.5,
1838
+ color: verdictColor, background: `${verdictColor}22`, border: `1px solid ${verdictColor}80`,
1839
+ },
1840
+ }, verdict),
1841
+ demo ? h('div', { style: { fontSize: 12, opacity: .7 } }, t('demoNote')) : null,
1842
+ h('div', { style: { overflowX: 'auto', cursor: 'grab' }, ...draggable() },
1843
+ h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
1844
+ h('thead', null,
1845
+ h('tr', null, [t('colModel'), t('colKind'), t('peakGoPay'), t('peakOfficialPay')].map((title) => th(title))),
1846
+ ),
1847
+ h('tbody', null, shown.models.flatMap((row) =>
1848
+ (row.rows ?? [{ kind: 'output', go: row.go, official: row.official, cheaper: row.cheaper, ratio: row.ratio }])
1849
+ .map((line, index) =>
1850
+ h('tr', {
1851
+ key: `${row.model}-${line.kind}`,
1852
+ // The cached-input line is the one that actually lands on
1853
+ // most requests, so it is highlighted instead of buried.
1854
+ style: line.kind === 'cacheRead'
1855
+ ? { background: 'rgba(60,157,93,.12)' }
1856
+ : index === 0 ? { borderTop: '1px solid rgba(127,127,127,.22)' } : undefined,
1857
+ },
1858
+ cell(index === 0 ? prettyModel(row.model) : '', { fontWeight: index === 0 ? 600 : 400 }),
1859
+ cell(t(`kind${line.kind[0].toUpperCase()}${line.kind.slice(1)}`),
1860
+ line.kind === 'cacheRead' ? { fontWeight: 600, color: '#3c9d5d' } : { opacity: .75 }),
1861
+ cell(priceCell(line.go.pay, line.go.factor > 1, line.cheaper === 'go')),
1862
+ cell(priceCell(line.official.pay, line.official.factor > 1, line.cheaper === 'official')),
1863
+ ),
1864
+ ),
1865
+ )),
1866
+ ),
1867
+ ),
1868
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
1869
+ h('button', {
1870
+ type: 'button', className: 'ocr-btn', style: btn,
1871
+ onClick: () => setDemo(!demo),
1872
+ }, demo ? t('demoOff') : t('demoOn')),
1873
+ ),
1874
+ )
1875
+ : null,
1876
+
1877
+ card(
1878
+ cardHead(t('modelsTitle')),
1879
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, modelCards),
1880
+ ),
1881
+
1882
+ h('div', { style: { opacity: .55, fontSize: 12, lineHeight: 1.8 } },
1883
+ t('footerOfficial'),
1884
+ h('br'),
1885
+ t('footerLocal'),
1886
+ ),
1887
+
1888
+ h(UsageDialog, {
1889
+ account: inspectingAccount,
1890
+ busy,
1891
+ onClose: () => setInspecting(null),
1892
+ onProbe: async () => {
1893
+ if (!inspectingAccount) return;
1894
+ const ok = await act({ action: 'probe', id: inspectingAccount.id });
1895
+ if (ok) setNotice(t('probedAt', { time: clock(Date.now()) }));
1896
+ },
1897
+ }),
1898
+ );
1899
+ }
1900
+
1901
+ // Hover and press feedback cannot live in an inline style object, so the
1902
+ // bundle ships one small stylesheet for its buttons.
1903
+ const CSS = [
1904
+ '.ocr-btn { transition: background .12s ease, border-color .12s ease, transform .06s ease; }',
1905
+ '.ocr-btn:hover:not(:disabled) { background: rgba(127,127,127,.16); border-color: rgba(127,127,127,.75); }',
1906
+ '.ocr-btn:active:not(:disabled) { transform: translateY(1px); background: rgba(127,127,127,.26); }',
1907
+ '.ocr-btn:disabled { opacity: .45; cursor: default; }',
1908
+ ].join('\n');
1909
+ const ensureStyles = () => {
1910
+ if (typeof document === 'undefined') return;
1911
+ if (document.getElementById('ocr-styles') !== null) return;
1912
+ const style = document.createElement('style');
1913
+ style.id = 'ocr-styles';
1914
+ style.textContent = CSS;
1915
+ document.head.appendChild(style);
1916
+ };
1917
+
1918
+ return {
1919
+ inject: ['slots'],
1920
+ apply(ctx) {
1921
+ ensureStyles();
1922
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1923
+ name: 'settings.section',
1924
+ id: 'opencode-router',
1925
+ order: 18,
1926
+ label: 'OpenCode 路由与用量',
1927
+ }, UsageSection));
1928
+ },
1929
+ };
1930
+ },
1931
+ });