dsh-opencode-routing 2.3.0 → 2.4.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/README.ko.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  [English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · **한국어** · [Русский](README.ru.md)
4
4
 
5
- 이전의 두 플러그인을 하나의 패키지로 합친 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`) 플러그인입니다:
6
- **A→B 자동 페일오버와 쿼터 라우팅을 갖춘 OpenCode 계정 풀**과 **계정별 / 모델별 사용량 대시보드**가 함께 들어 있습니다.
5
+ 이전의 두 플러그인을 하나의 패키지로 합친 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) 플러그인입니다:
6
+ **A→B 자동 페일오버와 쿼터 라우팅을 갖춘 OpenCode 계정 풀**, 그리고 **계정별 / 모델별 사용량 대시보드**가 함께 들어 있습니다.
7
7
  둘 다 "OpenCode 계정 및 사용량"이라는 하나의 Settings 페이지에 있습니다.
8
8
 
9
9
  - **클라이언트**: `settings.section` 하나를 60초마다 갱신하며, 계정 추가·편집·순서 변경·프로브가 가능합니다;
@@ -79,7 +79,7 @@ npm i dsh-opencode-routing
79
79
  문자 배지로 렌더링합니다. "다음 프로브"는 실제 타이머(기본 30분)이고 페이지가 1초마다 다시 렌더링되므로
80
80
  카운트다운됩니다. 페이지를 여는 것은 스냅샷을 읽기만 할 뿐 프로브를 트리거하지 않습니다.
81
81
 
82
- "지금 어느 쪽이 더 저렴한가"는 구독 레버리지에서 파생됩니다: Go는 목록가 기준 $100어치 허용량을 $10에
82
+ "지금 어느 쪽이 더 저렴한가"는 구독 레버리지에서 파생됩니다: Go는 정가 기준 $100어치 허용량을 $10에
83
83
  판매하므로(≈ ¥710) OpenCode는 항상 공식 가격의 10% — 90% 저렴합니다. 실제 데이터는 공식 API에 유리한 적이
84
84
  없으며, 카드 모서리의 버튼으로 샘플 데이터로 전환하면 그러한 경우가 어떻게 보이는지 확인할 수 있습니다.
85
85
 
package/README.md CHANGED
@@ -74,6 +74,12 @@ The official `/inference/go/v1/usage` endpoint only returns rounded rolling / we
74
74
  "查看用量" opens a per-account dialog with the three windows, their reset times and the probe result (it probes that
75
75
  account only and never switches models).
76
76
 
77
+ The peak card reports **both calendars side by side** — the official API and OpenCode — with the current period and how
78
+ long (in hours, one decimal) until it flips. Capability chips on the model cards carry a tooltip explaining what the tag
79
+ means, the header links to the OpenCode Console subscription page, and a floating arrow returns to the top once you have
80
+ scrolled down. Every price cell shows the cached-input, uncached-input and output line, so a cached hit (the green row)
81
+ can be compared directly with what the official API would charge for the same tokens.
82
+
77
83
  "编辑密钥" fetches the account's current key from `GET /opencode-quota-router.json?key=<account id>` and pre-fills the
78
84
  input (a local-only endpoint; the list snapshot only ever carries a mask). Model names follow vendor casing
79
85
  (`deepseek-v4.1-flash` → `DeepSeek V4.1 Flash`); vendors with a published brand mark render as an inline SVG, the rest
package/README.ru.md CHANGED
@@ -58,8 +58,8 @@ npm i dsh-opencode-routing
58
58
  - **Карантин одной модели**: когда лимит исчерпан только у одной модели, а в пуле ещё есть бюджет, карантин
59
59
  применяется только к этой модели (по умолчанию 30 минут); все остальные модели продолжают работать по плану.
60
60
  - **Пул исчерпан целиком**: только тогда маршрут откатывается на свой `fallback` (по умолчанию — аккаунт
61
- DeepSeek), с пробой `GET /inference/go/v1/usage`, не тратящей токены, каждые 30 минут (или при сбросе
62
- скользящего окна), которая автоматически возвращает на план.
61
+ DeepSeek), а каждые 30 минут (или при сбросе скользящего окна) выполняется проба `GET /inference/go/v1/usage`,
62
+ не тратящая токены, которая автоматически возвращает на план.
63
63
  - **Сброс**: «Сбросить метки блокировки» в Settings очищает все метки исчерпания и аутентификации и немедленно
64
64
  возвращает в пул. Аккаунты и ключи не затрагиваются.
65
65
 
@@ -72,8 +72,8 @@ npm i dsh-opencode-routing
72
72
  - **Официальные проценты** (одна строка на аккаунт): прямо из API — реальное использование аккаунта, включая другие
73
73
  клиенты и другие машины.
74
74
  - **Деньги по моделям** (таблица моделей): локальная оценка. Она пересканирует `$DSH_HOME/sessions` за последние
75
- 40 дней и учитывает только события `assistant/message`, у которых provider начинается с `opencode`, по публичным
76
- тарифам (включая 2× пиковое окно DeepSeek). У использования из других инструментов нет локальной записи.
75
+ 40 дней и учитывает только события `assistant/message`, у которых поле provider начинается с `opencode`, по
76
+ публичным тарифам (включая 2× пиковое окно DeepSeek). У использования из других инструментов нет локальной записи.
77
77
 
78
78
  «Посмотреть использование» открывает диалог по аккаунту с тремя окнами, их временами сброса и результатом пробы
79
79
  (проверяется только этот аккаунт, модели при этом не переключаются).
package/client.js CHANGED
@@ -109,9 +109,28 @@ window.__ModuleLoader__.load({
109
109
  peakHoliday: '——今天是中国法定节假日,官方整天算谷,OpenCode 照算峰',
110
110
  colKind: '计费项',
111
111
  peakExhausted: '但本月额度已经用尽,只能走官方。',
112
- peakGoPay: '走 OpenCode 你实付',
113
- peakOfficialPay: '走官方直连你实付',
112
+ peakGoPay: 'OpenCode',
113
+ peakOfficialPay: 'DeepSeek 官方',
114
+ colOpenCode: 'OpenCode',
115
+ colOfficial: 'DeepSeek 官方',
114
116
  peakVerdict: '结论',
117
+ clockTitle: '此刻的峰谷',
118
+ clockPeak: '高峰期',
119
+ clockOff: '低谷期',
120
+ clockFlip: '还剩 {hours} 小时到{next}',
121
+ clockHoliday: '今天是中国法定节假日,官方 API 全天按低谷计',
122
+ colRollingQuota: '5 小时额度',
123
+ capHintText: '纯文本:只吃文字、只出文字。',
124
+ capHintImage: '图像输入:能看截图、图表和 UI 稿(JPEG/PNG/GIF/WebP)。',
125
+ capHintVideo: '视频输入:能直接读 mp4 等视频文件并描述画面内容。',
126
+ capHintAudio: '音频输入:能直接读音频文件。',
127
+ capHintPdf: 'PDF 输入:能直接读 PDF 文档,不用先转成文本。',
128
+ capHintTools: '工具调用:模型可以调用 harness 提供的函数——读文件、跑命令、搜网页、写代码——再按返回结果继续干活。',
129
+ capHintReasoning: '推理:回答前先自己思考一段,难题更稳,但更慢、更费 token。',
130
+ backToTop: '回到顶部',
131
+ subscribe: '订阅 OpenCode',
132
+ copyKey: '复制',
133
+ copied: '已复制',
115
134
  peakGoCheaper: '走 OpenCode 便宜 {percent}%',
116
135
  verdictGo: '走 OpenCode 便宜 {percent}%',
117
136
  verdictOfficial: '走官方直连便宜 {percent}%',
@@ -220,9 +239,28 @@ window.__ModuleLoader__.load({
220
239
  kindOutput: 'output',
221
240
  peakHoliday: ' — today is a Chinese public holiday, so the official API charges off-peak all day while OpenCode still charges peak',
222
241
  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',
242
+ peakGoPay: 'OpenCode',
243
+ peakOfficialPay: 'DeepSeek official',
244
+ colOpenCode: 'OpenCode',
245
+ colOfficial: 'DeepSeek official',
225
246
  peakVerdict: 'Verdict',
247
+ clockTitle: 'Peak / off-peak right now',
248
+ clockPeak: 'peak',
249
+ clockOff: 'off-peak',
250
+ clockFlip: '{hours} h until {next}',
251
+ clockHoliday: 'Chinese public holiday today, so the official API bills off-peak all day',
252
+ colRollingQuota: '5-hour allowance',
253
+ capHintText: 'Text only: plain text in, plain text out.',
254
+ capHintImage: 'Image input: understands screenshots, charts and UI mockups (JPEG/PNG/GIF/WebP).',
255
+ capHintVideo: 'Video input: reads video files such as mp4 and can describe what happens in them.',
256
+ capHintAudio: 'Audio input: reads audio files directly.',
257
+ capHintPdf: 'PDF input: reads PDF documents without converting them to text first.',
258
+ capHintTools: 'Tool calling: the model can call the functions the harness exposes (read files, run commands, search the web, write code) and act on the results.',
259
+ capHintReasoning: 'Reasoning: the model thinks before answering; better on hard tasks, but slower and it burns more tokens.',
260
+ backToTop: 'Back to top',
261
+ subscribe: 'Subscribe to OpenCode',
262
+ copyKey: 'Copy',
263
+ copied: 'Copied',
226
264
  peakGoCheaper: 'OpenCode is {percent}% cheaper',
227
265
  verdictGo: 'OpenCode is {percent}% cheaper',
228
266
  verdictOfficial: 'Official API is {percent}% cheaper',
@@ -331,9 +369,28 @@ window.__ModuleLoader__.load({
331
369
  kindOutput: '輸出',
332
370
  peakHoliday: '——今天是中國法定假日,官方整天算谷,OpenCode 照算峰',
333
371
  peakExhausted: '但本月額度已用盡,只能走官方。',
334
- peakGoPay: '走 OpenCode 你實付',
335
- peakOfficialPay: '走官方直連你實付',
372
+ peakGoPay: 'OpenCode',
373
+ peakOfficialPay: 'DeepSeek 官方',
374
+ colOpenCode: 'OpenCode',
375
+ colOfficial: 'DeepSeek 官方',
336
376
  peakVerdict: '結論',
377
+ clockTitle: '此刻的峰谷',
378
+ clockPeak: '高峰期',
379
+ clockOff: '低谷期',
380
+ clockFlip: '還剩 {hours} 小時到{next}',
381
+ clockHoliday: '今天是中國法定節假日,官方 API 全天按低谷計',
382
+ colRollingQuota: '5 小時額度',
383
+ capHintText: '純文字:只吃文字、只出文字。',
384
+ capHintImage: '圖像輸入:能看截圖、圖表和 UI 稿(JPEG/PNG/GIF/WebP)。',
385
+ capHintVideo: '影片輸入:能直接讀 mp4 等影片檔並描述畫面內容。',
386
+ capHintAudio: '音訊輸入:能直接讀音訊檔。',
387
+ capHintPdf: 'PDF 輸入:能直接讀 PDF 文件,不用先轉成文字。',
388
+ capHintTools: '工具調用:模型可以調用 harness 提供的函式——讀檔、跑指令、搜網頁、寫程式——再依回傳結果繼續做事。',
389
+ capHintReasoning: '推理:回答前先自己想一段,難題更穩,但更慢、更耗 token。',
390
+ backToTop: '回到頂端',
391
+ subscribe: '訂閱 OpenCode',
392
+ copyKey: '複製',
393
+ copied: '已複製',
337
394
  peakGoCheaper: '走 OpenCode 便宜 {percent}%',
338
395
  verdictGo: '走 OpenCode 便宜 {percent}%',
339
396
  verdictOfficial: '走官方直連便宜 {percent}%',
@@ -443,8 +500,8 @@ window.__ModuleLoader__.load({
443
500
  "peakOffPeak": "Nebenzeit",
444
501
  "peakHoliday": " – heute ist ein chinesischer gesetzlicher Feiertag: Die offizielle API rechnet den ganzen Tag Nebenzeit ab, OpenCode weiter Hauptzeit",
445
502
  "peakExhausted": " Das Kontingent für diesen Monat ist aber schon aufgebraucht, es bleibt nur der offizielle Weg.",
446
- "peakGoPay": "Du zahlst über OpenCode",
447
- "peakOfficialPay": "Du zahlst über die offizielle API",
503
+ "peakGoPay": "OpenCode",
504
+ "peakOfficialPay": "DeepSeek offiziell",
448
505
  "peakVerdict": "Fazit",
449
506
  "peakGoCheaper": "OpenCode ist {percent}% günstiger",
450
507
  "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.",
@@ -461,6 +518,25 @@ window.__ModuleLoader__.load({
461
518
  "demoOn": "Fall „offiziell günstiger\" zeigen",
462
519
  "demoOff": "Zurück zu echten Daten",
463
520
  "demoNote": "Beispieldaten: ein Modell, das über OpenCode teurer wäre (real nur bei aufgebrauchtem Kontingent).",
521
+ "colOpenCode": "OpenCode",
522
+ "colOfficial": "DeepSeek offiziell",
523
+ "clockTitle": "Haupt- und Nebenzeit gerade jetzt",
524
+ "clockPeak": "Hauptzeit",
525
+ "clockOff": "Nebenzeit",
526
+ "clockFlip": "noch {hours} Std. bis {next}",
527
+ "clockHoliday": "Heute ist chinesischer gesetzlicher Feiertag, deshalb rechnet die offizielle API den ganzen Tag Nebenzeit ab",
528
+ "colRollingQuota": "5-Stunden-Kontingent",
529
+ "capHintText": "Nur Text: reiner Text rein, reiner Text raus.",
530
+ "capHintImage": "Bildeingabe: versteht Screenshots, Diagramme und UI-Mockups (JPEG/PNG/GIF/WebP).",
531
+ "capHintVideo": "Videoeingabe: liest Videodateien wie mp4 und beschreibt, was darin passiert.",
532
+ "capHintAudio": "Audioeingabe: liest Audiodateien direkt.",
533
+ "capHintPdf": "PDF-Eingabe: liest PDF-Dokumente, ohne sie vorher in Text umzuwandeln.",
534
+ "capHintTools": "Tool-Aufruf: Das Modell kann die Funktionen aufrufen, die das Harness bereitstellt (Dateien lesen, Befehle ausführen, im Web suchen, Code schreiben), und mit den Ergebnissen arbeiten.",
535
+ "capHintReasoning": "Reasoning: Das Modell denkt vor der Antwort nach; besser bei schwierigen Aufgaben, dafür langsamer und mit mehr Tokenverbrauch.",
536
+ "backToTop": "Nach oben",
537
+ "subscribe": "OpenCode abonnieren",
538
+ "copyKey": "Kopieren",
539
+ "copied": "Kopiert",
464
540
  },
465
541
  "es": {
466
542
  "title": "Cuentas y uso de OpenCode",
@@ -554,8 +630,8 @@ window.__ModuleLoader__.load({
554
630
  "peakOffPeak": "hora valle",
555
631
  "peakHoliday": " — hoy es festivo oficial en China: la API oficial cobra tarifa valle todo el día, mientras que OpenCode sigue cobrando pico",
556
632
  "peakExhausted": " Pero la cuota de este mes ya está agotada, así que solo queda la ruta oficial.",
557
- "peakGoPay": "Pagas por OpenCode",
558
- "peakOfficialPay": "Pagas por la API oficial",
633
+ "peakGoPay": "OpenCode",
634
+ "peakOfficialPay": "DeepSeek oficial",
559
635
  "peakVerdict": "Conclusión",
560
636
  "peakGoCheaper": "OpenCode sale {percent}% más barato",
561
637
  "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.",
@@ -572,6 +648,25 @@ window.__ModuleLoader__.load({
572
648
  "demoOn": "Ver el caso de que lo oficial sea más barato",
573
649
  "demoOff": "Volver a los datos reales",
574
650
  "demoNote": "Datos de ejemplo: un modelo que con OpenCode sería más caro (en la realidad solo pasa al agotar la cuota).",
651
+ "colOpenCode": "OpenCode",
652
+ "colOfficial": "DeepSeek oficial",
653
+ "clockTitle": "Hora pico / valle ahora mismo",
654
+ "clockPeak": "pico",
655
+ "clockOff": "valle",
656
+ "clockFlip": "{next} en {hours} h",
657
+ "clockHoliday": "Hoy es festivo oficial en China, así que la API oficial cobra tarifa valle todo el día",
658
+ "colRollingQuota": "Cuota de 5 horas",
659
+ "capHintText": "Solo texto: entra texto plano, sale texto plano.",
660
+ "capHintImage": "Entrada de imagen: entiende capturas, gráficos y maquetas de interfaz (JPEG/PNG/GIF/WebP).",
661
+ "capHintVideo": "Entrada de vídeo: lee archivos de vídeo como mp4 y describe lo que ocurre en ellos.",
662
+ "capHintAudio": "Entrada de audio: lee archivos de audio directamente.",
663
+ "capHintPdf": "Entrada de PDF: lee documentos PDF sin convertirlos antes a texto.",
664
+ "capHintTools": "Llamada a herramientas: el modelo puede llamar a las funciones que expone el harness (leer archivos, ejecutar comandos, buscar en la web, escribir código) y actuar con el resultado.",
665
+ "capHintReasoning": "Razonamiento: el modelo piensa antes de responder; va mejor en tareas difíciles, pero es más lento y gasta más tokens.",
666
+ "backToTop": "Volver arriba",
667
+ "subscribe": "Suscribirse a OpenCode",
668
+ "copyKey": "Copiar",
669
+ "copied": "Copiado",
575
670
  },
576
671
  "fr": {
577
672
  "title": "Comptes et usage OpenCode",
@@ -665,8 +760,8 @@ window.__ModuleLoader__.load({
665
760
  "peakOffPeak": "heures creuses",
666
761
  "peakHoliday": " — aujourd’hui est un jour férié officiel en Chine : l’API officielle facture en heures creuses toute la journée, alors qu’OpenCode reste en heures pleines",
667
762
  "peakExhausted": " Mais le quota de ce mois est déjà épuisé : il ne reste que la route officielle.",
668
- "peakGoPay": "Vous payez via OpenCode",
669
- "peakOfficialPay": "Vous payez via l’API officielle",
763
+ "peakGoPay": "OpenCode",
764
+ "peakOfficialPay": "DeepSeek officiel",
670
765
  "peakVerdict": "Verdict",
671
766
  "peakGoCheaper": "OpenCode coûte {percent}% de moins",
672
767
  "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.",
@@ -683,6 +778,25 @@ window.__ModuleLoader__.load({
683
778
  "demoOn": "Voir le cas où l’officiel est moins cher",
684
779
  "demoOff": "Revenir aux données réelles",
685
780
  "demoNote": "Données d’exemple : un modèle plus cher via OpenCode (en pratique, seulement quota épuisé).",
781
+ "colOpenCode": "OpenCode",
782
+ "colOfficial": "DeepSeek officiel",
783
+ "clockTitle": "Heures pleines / creuses en ce moment",
784
+ "clockPeak": "heures pleines",
785
+ "clockOff": "heures creuses",
786
+ "clockFlip": "{next} dans {hours} h",
787
+ "clockHoliday": "Jour férié officiel en Chine aujourd’hui : l’API officielle facture en heures creuses toute la journée",
788
+ "colRollingQuota": "Quota de 5 heures",
789
+ "capHintText": "Texte seul : du texte simple en entrée, du texte simple en sortie.",
790
+ "capHintImage": "Entrée image : comprend les captures d’écran, les graphiques et les maquettes d’interface (JPEG/PNG/GIF/WebP).",
791
+ "capHintVideo": "Entrée vidéo : lit les fichiers vidéo comme mp4 et décrit ce qui s’y passe.",
792
+ "capHintAudio": "Entrée audio : lit directement les fichiers audio.",
793
+ "capHintPdf": "Entrée PDF : lit les documents PDF sans les convertir d’abord en texte.",
794
+ "capHintTools": "Appel d’outils : le modèle peut appeler les fonctions exposées par le harness (lire des fichiers, lancer des commandes, chercher sur le web, écrire du code) et exploiter les résultats.",
795
+ "capHintReasoning": "Raisonnement : le modèle réfléchit avant de répondre ; plus fort sur les tâches difficiles, mais plus lent et plus gourmand en tokens.",
796
+ "backToTop": "Retour en haut",
797
+ "subscribe": "S’abonner à OpenCode",
798
+ "copyKey": "Copier",
799
+ "copied": "Copié",
686
800
  },
687
801
  "ja": {
688
802
  "title": "OpenCode アカウントと使用量",
@@ -776,8 +890,8 @@ window.__ModuleLoader__.load({
776
890
  "peakOffPeak": "オフピーク",
777
891
  "peakHoliday": " — 今日は中国の祝日なので、一日中オフピーク、OpenCode はピークのままです",
778
892
  "peakExhausted": "ただし今月のクォータはすでに使い切っているため、公式を使うしかありません。",
779
- "peakGoPay": "OpenCode 経由の支払い",
780
- "peakOfficialPay": "公式直結の支払い",
893
+ "peakGoPay": "OpenCode",
894
+ "peakOfficialPay": "DeepSeek 公式",
781
895
  "peakVerdict": "結論",
782
896
  "peakGoCheaper": "OpenCode のほうが {percent}% 安い",
783
897
  "peakFootnote": "出力 100 万トークンあたりに換算(コーディングの請求はほとんどが出力由来)、レートは ¥{fx}/$。ピークとオフピークはクォータの減る速さに影響するだけで、結論は変わりません。クォータの範囲内なら OpenCode 経由のほうが公式直結より常に安く、例外はクォータを使い切った場合と、特定モデルの上限に先に達した場合です。",
@@ -794,6 +908,25 @@ window.__ModuleLoader__.load({
794
908
  "demoOn": "公式が安い場合を表示",
795
909
  "demoOff": "実際のデータに戻す",
796
910
  "demoNote": "サンプル表示:OpenCode 経由のほうが高くなる例(実際は枠を使い切ったときのみ)。",
911
+ "colOpenCode": "OpenCode",
912
+ "colOfficial": "DeepSeek 公式",
913
+ "clockTitle": "現在のピーク / オフピーク",
914
+ "clockPeak": "ピーク",
915
+ "clockOff": "オフピーク",
916
+ "clockFlip": "{next}まであと {hours} 時間",
917
+ "clockHoliday": "今日は中国の祝日なので、公式 API は一日中オフピークの料金になります",
918
+ "colRollingQuota": "5 時間の枠",
919
+ "capHintText": "テキストのみ: プレーンテキストを入力し、プレーンテキストを返します。",
920
+ "capHintImage": "画像入力: スクリーンショット、グラフ、UI モックアップを理解します(JPEG/PNG/GIF/WebP)。",
921
+ "capHintVideo": "動画入力: mp4 などの動画ファイルを読み、中で何が起きているかを説明できます。",
922
+ "capHintAudio": "音声入力: 音声ファイルをそのまま読み取ります。",
923
+ "capHintPdf": "PDF 入力: PDF 文書をテキストに変換せずにそのまま読み取ります。",
924
+ "capHintTools": "ツール呼び出し: harness が公開している関数(ファイル読み取り、コマンド実行、Web 検索、コード記述)を呼び出し、その結果を使って動けます。",
925
+ "capHintReasoning": "推論: 答える前に考えるため難しいタスクに強い反面、遅く、トークンも多く消費します。",
926
+ "backToTop": "トップへ戻る",
927
+ "subscribe": "OpenCode を購読する",
928
+ "copyKey": "コピー",
929
+ "copied": "コピーしました",
797
930
  },
798
931
  "ko": {
799
932
  "title": "OpenCode 계정 및 사용량",
@@ -887,8 +1020,8 @@ window.__ModuleLoader__.load({
887
1020
  "peakOffPeak": "비피크",
888
1021
  "peakHoliday": " — 오늘은 중국 공휴일이라 하루 종일 비피크, OpenCode는 피크 그대로입니다",
889
1022
  "peakExhausted": "다만 이번 달 할당량을 이미 다 썼으므로 공식만 쓸 수 있습니다.",
890
- "peakGoPay": "OpenCode 경유 결제액",
891
- "peakOfficialPay": "공식 직결 결제액",
1023
+ "peakGoPay": "OpenCode",
1024
+ "peakOfficialPay": "DeepSeek 공식",
892
1025
  "peakVerdict": "결론",
893
1026
  "peakGoCheaper": "OpenCode가 {percent}% 더 저렴",
894
1027
  "peakFootnote": "출력 100만 토큰 기준 환산(코딩 요금은 대부분 출력에서 발생), 환율 ¥{fx}/$. 피크·비피크는 할당량이 줄어드는 속도에만 영향을 주고 결론은 바뀌지 않습니다. 할당량 안에서는 OpenCode 경유가 공식 직결보다 항상 저렴하며, 예외는 할당량을 다 쓴 경우와 특정 모델 한도가 먼저 바닥나는 경우입니다.",
@@ -905,6 +1038,25 @@ window.__ModuleLoader__.load({
905
1038
  "demoOn": "공식이 더 저렴한 경우 보기",
906
1039
  "demoOff": "실제 데이터로 돌아가기",
907
1040
  "demoNote": "예시 데이터: OpenCode 경유가 더 비싼 경우(실제로는 한도를 다 썼을 때만).",
1041
+ "colOpenCode": "OpenCode",
1042
+ "colOfficial": "DeepSeek 공식",
1043
+ "clockTitle": "지금 피크 / 비피크",
1044
+ "clockPeak": "피크",
1045
+ "clockOff": "비피크",
1046
+ "clockFlip": "{next}까지 {hours}시간 남음",
1047
+ "clockHoliday": "오늘은 중국 공휴일이라 공식 API는 하루 종일 비피크 요금으로 계산됩니다",
1048
+ "colRollingQuota": "5시간 할당량",
1049
+ "capHintText": "텍스트 전용: 일반 텍스트를 넣으면 일반 텍스트로 답합니다.",
1050
+ "capHintImage": "이미지 입력: 스크린샷, 차트, UI 목업을 이해합니다(JPEG/PNG/GIF/WebP).",
1051
+ "capHintVideo": "영상 입력: mp4 같은 동영상 파일을 읽고 그 안에서 무슨 일이 일어나는지 설명합니다.",
1052
+ "capHintAudio": "오디오 입력: 오디오 파일을 그대로 읽습니다.",
1053
+ "capHintPdf": "PDF 입력: PDF 문서를 텍스트로 변환하지 않고 바로 읽습니다.",
1054
+ "capHintTools": "도구 호출: 모델이 harness가 노출하는 함수(파일 읽기, 명령 실행, 웹 검색, 코드 작성)를 호출하고 그 결과로 작업할 수 있습니다.",
1055
+ "capHintReasoning": "추론: 답하기 전에 생각하므로 어려운 작업에 강하지만 느리고 토큰을 더 씁니다.",
1056
+ "backToTop": "맨 위로",
1057
+ "subscribe": "OpenCode 구독하기",
1058
+ "copyKey": "복사",
1059
+ "copied": "복사됨",
908
1060
  },
909
1061
  "pt": {
910
1062
  "title": "Contas e uso do OpenCode",
@@ -998,8 +1150,8 @@ window.__ModuleLoader__.load({
998
1150
  "peakOffPeak": "fora do pico",
999
1151
  "peakHoliday": " — hoje é feriado nacional na China: a API oficial cobra fora do pico o dia inteiro, enquanto o OpenCode continua cobrando pico",
1000
1152
  "peakExhausted": " Mas a cota deste mês já acabou, então só resta a rota oficial.",
1001
- "peakGoPay": "Você paga pelo OpenCode",
1002
- "peakOfficialPay": "Você paga pela API oficial",
1153
+ "peakGoPay": "OpenCode",
1154
+ "peakOfficialPay": "DeepSeek oficial",
1003
1155
  "peakVerdict": "Veredito",
1004
1156
  "peakGoCheaper": "O OpenCode sai {percent}% mais barato",
1005
1157
  "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.",
@@ -1016,6 +1168,25 @@ window.__ModuleLoader__.load({
1016
1168
  "demoOn": "Ver o caso em que o oficial é mais barato",
1017
1169
  "demoOff": "Voltar aos dados reais",
1018
1170
  "demoNote": "Dados de exemplo: um modelo mais caro via OpenCode (na prática só com a cota esgotada).",
1171
+ "colOpenCode": "OpenCode",
1172
+ "colOfficial": "DeepSeek oficial",
1173
+ "clockTitle": "Pico / fora do pico agora",
1174
+ "clockPeak": "pico",
1175
+ "clockOff": "fora do pico",
1176
+ "clockFlip": "{next} em {hours} h",
1177
+ "clockHoliday": "Hoje é feriado nacional na China, então a API oficial cobra fora do pico o dia inteiro",
1178
+ "colRollingQuota": "Cota de 5 horas",
1179
+ "capHintText": "Só texto: entra texto puro, sai texto puro.",
1180
+ "capHintImage": "Entrada de imagem: entende capturas de tela, gráficos e mockups de interface (JPEG/PNG/GIF/WebP).",
1181
+ "capHintVideo": "Entrada de vídeo: lê arquivos de vídeo como mp4 e descreve o que acontece neles.",
1182
+ "capHintAudio": "Entrada de áudio: lê arquivos de áudio diretamente.",
1183
+ "capHintPdf": "Entrada de PDF: lê documentos PDF sem convertê-los antes para texto.",
1184
+ "capHintTools": "Chamada de ferramentas: o modelo pode chamar as funções que o harness expõe (ler arquivos, rodar comandos, buscar na web, escrever código) e agir com o resultado.",
1185
+ "capHintReasoning": "Raciocínio: o modelo pensa antes de responder; vai melhor em tarefas difíceis, mas é mais lento e gasta mais tokens.",
1186
+ "backToTop": "Voltar ao topo",
1187
+ "subscribe": "Assinar o OpenCode",
1188
+ "copyKey": "Copiar",
1189
+ "copied": "Copiado",
1019
1190
  },
1020
1191
  "ru": {
1021
1192
  "title": "Аккаунты и использование OpenCode",
@@ -1109,8 +1280,8 @@ window.__ModuleLoader__.load({
1109
1280
  "peakOffPeak": "не пик",
1110
1281
  "peakHoliday": "; из-за государственного праздника в Китае он весь день считается вне пика, а OpenCode — по-прежнему в пике",
1111
1282
  "peakExhausted": "Но квота за этот месяц уже израсходована, так что остаётся только официальный API.",
1112
- "peakGoPay": "Вы платите через OpenCode",
1113
- "peakOfficialPay": "Вы платите через официальный API",
1283
+ "peakGoPay": "OpenCode",
1284
+ "peakOfficialPay": "Официальный DeepSeek",
1114
1285
  "peakVerdict": "Итог",
1115
1286
  "peakGoCheaper": "OpenCode дешевле на {percent}%",
1116
1287
  "peakFootnote": "В расчёте на 1 млн выходных токенов (в счёте за код преобладает выход), курс ¥{fx}/$. Пик влияет только на скорость расхода квоты и не меняет вывод: в пределах квоты OpenCode всегда дешевле прямого официального API; исключения — исчерпанная квота или лимит отдельной модели, который закончится раньше.",
@@ -1127,6 +1298,25 @@ window.__ModuleLoader__.load({
1127
1298
  "demoOn": "Показать случай, когда официальный дешевле",
1128
1299
  "demoOff": "Вернуться к реальным данным",
1129
1300
  "demoNote": "Демо-данные: модель, которая через OpenCode дороже (в реальности — только при исчерпанной квоте).",
1301
+ "colOpenCode": "OpenCode",
1302
+ "colOfficial": "Официальный DeepSeek",
1303
+ "clockTitle": "Пик или не пик прямо сейчас",
1304
+ "clockPeak": "пик",
1305
+ "clockOff": "не пик",
1306
+ "clockFlip": "{next} через {hours} ч",
1307
+ "clockHoliday": "Сегодня государственный праздник в Китае, поэтому официальный API весь день считает по цене вне пика",
1308
+ "colRollingQuota": "Квота на 5 часов",
1309
+ "capHintText": "Только текст: на входе обычный текст, на выходе обычный текст.",
1310
+ "capHintImage": "Ввод изображений: понимает скриншоты, графики и макеты интерфейса (JPEG/PNG/GIF/WebP).",
1311
+ "capHintVideo": "Ввод видео: читает видеофайлы вроде mp4 и описывает, что в них происходит.",
1312
+ "capHintAudio": "Ввод аудио: читает аудиофайлы напрямую.",
1313
+ "capHintPdf": "Ввод PDF: читает PDF-документы, не переводя их сначала в текст.",
1314
+ "capHintTools": "Вызов инструментов: модель может вызывать функции, которые предоставляет harness (читать файлы, выполнять команды, искать в интернете, писать код), и действовать по результатам.",
1315
+ "capHintReasoning": "Рассуждение: модель думает перед ответом; лучше справляется со сложными задачами, но отвечает медленнее и тратит больше токенов.",
1316
+ "backToTop": "Наверх",
1317
+ "subscribe": "Оформить подписку на OpenCode",
1318
+ "copyKey": "Копировать",
1319
+ "copied": "Скопировано",
1130
1320
  },
1131
1321
  });
1132
1322
  /* @@EXTRA_LOCALES_END@@ */
@@ -1186,22 +1376,14 @@ window.__ModuleLoader__.load({
1186
1376
  };
1187
1377
 
1188
1378
  // 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
- };
1379
+ // it needs a solid fill and an explicit text colour. It stays light even in a
1380
+ // dark harness theme, matching OpenCode's own white/grey dialogs.
1381
+ const theme = () => ({
1382
+ panel: '#f7f7f8',
1383
+ text: '#1a1a1a',
1384
+ line: 'rgba(0,0,0,.12)',
1385
+ scrim: 'rgba(0,0,0,.38)',
1386
+ });
1205
1387
 
1206
1388
  // Model ids read badly in a table ("deepseek-v4.1-flash"); the page shows the
1207
1389
  // vendor casing plus spaces instead.
@@ -1289,13 +1471,21 @@ window.__ModuleLoader__.load({
1289
1471
 
1290
1472
  const capabilityTags = (row) => {
1291
1473
  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;
1474
+ const hintOf = {
1475
+ text: 'capHintText', image: 'capHintImage', video: 'capHintVideo',
1476
+ audio: 'capHintAudio', pdf: 'capHintPdf',
1477
+ };
1478
+ const tags = inputs.map((kind) => ({
1479
+ key: kind,
1480
+ label: {
1481
+ text: t('capsText'), image: t('capsImage'), video: t('capsVideo'),
1482
+ audio: t('capsAudio'), pdf: t('capsPdf'),
1483
+ }[kind] ?? kind,
1484
+ hint: t(hintOf[kind] ?? 'capHintText'),
1485
+ }));
1486
+ if (row.tools === true) tags.push({ key: 'tools', label: t('capsTools'), hint: t('capHintTools') });
1487
+ if (row.reasoning === true) tags.push({ key: 'reasoning', label: t('capsReasoning'), hint: t('capHintReasoning') });
1488
+ return tags;
1299
1489
  };
1300
1490
 
1301
1491
  function WindowBar({ window, label }) {
@@ -1368,6 +1558,32 @@ window.__ModuleLoader__.load({
1368
1558
  const [inspecting, setInspecting] = React.useState(null);
1369
1559
  const [busy, setBusy] = React.useState(false);
1370
1560
  const [demo, setDemo] = React.useState(false);
1561
+ const [showTop, setShowTop] = React.useState(false);
1562
+ const scroller = React.useRef(null);
1563
+ // The settings pane scrolls, not the window, so the listener is attached in
1564
+ // the capture phase and remembers whichever element actually scrolled.
1565
+ React.useEffect(() => {
1566
+ const onScroll = (event) => {
1567
+ const target = event.target;
1568
+ if (target && target !== document && typeof target.scrollTop === 'number') {
1569
+ scroller.current = target;
1570
+ setShowTop(target.scrollTop > 320);
1571
+ return;
1572
+ }
1573
+ setShowTop((window.scrollY ?? document.documentElement.scrollTop ?? 0) > 320);
1574
+ };
1575
+ document.addEventListener('scroll', onScroll, true);
1576
+ window.addEventListener('scroll', onScroll, { passive: true });
1577
+ return () => {
1578
+ document.removeEventListener('scroll', onScroll, true);
1579
+ window.removeEventListener('scroll', onScroll);
1580
+ };
1581
+ }, []);
1582
+ const toTop = () => {
1583
+ const pane = scroller.current;
1584
+ if (pane && typeof pane.scrollTo === 'function') pane.scrollTo({ top: 0, behavior: 'smooth' });
1585
+ else window.scrollTo({ top: 0, behavior: 'smooth' });
1586
+ };
1371
1587
  // Re-render every second so the "next probe" line counts down instead of
1372
1588
  // freezing at whatever it said when the page opened.
1373
1589
  const [, setTick] = React.useState(0);
@@ -1509,12 +1725,11 @@ window.__ModuleLoader__.load({
1509
1725
  return vendor;
1510
1726
  };
1511
1727
 
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] ?? '•';
1728
+ const CAP_ICON = {
1729
+ text: '📝', image: '🖼️', video: '🎬', audio: '🎵', pdf: '📄',
1730
+ tools: '🛠️', reasoning: '🧠',
1517
1731
  };
1732
+ const capIcon = (key) => CAP_ICON[key] ?? '•';
1518
1733
 
1519
1734
  const routerAccounts = new Map((router?.accounts ?? []).map((account) => [account.id, account]));
1520
1735
  const usageAccounts = data?.accounts ?? [];
@@ -1644,11 +1859,22 @@ window.__ModuleLoader__.load({
1644
1859
  paddingTop: 10, borderTop: '1px solid rgba(127,127,127,.18)',
1645
1860
  },
1646
1861
  },
1647
- h('span', { style: { opacity: .7, fontSize: 12 } }, t('newKeyFor', { ref: account.keyRef ?? '' })),
1648
1862
  h('input', {
1649
- style: { ...input, minWidth: 300 }, type: 'password', placeholder: 'oc_sk_…', value: keyDraft,
1863
+ style: { ...input, minWidth: 340, fontFamily: 'ui-monospace, Consolas, monospace' },
1864
+ type: 'text', spellCheck: false, placeholder: 'oc_sk_…', value: keyDraft,
1650
1865
  onChange: (event) => setKeyDraft(event.target.value),
1651
1866
  }),
1867
+ h('button', {
1868
+ type: 'button', className: 'ocr-btn', style: btn, disabled: keyDraft.length === 0,
1869
+ onClick: async () => {
1870
+ try {
1871
+ await navigator.clipboard.writeText(keyDraft);
1872
+ setNotice(t('copied'));
1873
+ } catch (error) {
1874
+ setActionError(String(error?.message ?? error));
1875
+ }
1876
+ },
1877
+ }, t('copyKey')),
1652
1878
  h('button', {
1653
1879
  type: 'button', className: 'ocr-btn', style: btn, disabled: busy || keyDraft.trim().length < 8,
1654
1880
  onClick: async () => {
@@ -1709,21 +1935,22 @@ window.__ModuleLoader__.load({
1709
1935
  ),
1710
1936
  chips.length > 0
1711
1937
  ? h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1712
- chips.map((label) => h('span', {
1713
- key: label,
1938
+ chips.map((chip) => h('span', {
1939
+ key: chip.key,
1940
+ title: chip.hint,
1714
1941
  style: {
1715
1942
  display: 'inline-flex', alignItems: 'center', gap: 4,
1716
- padding: '2px 8px', borderRadius: 999, fontSize: 11,
1943
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, cursor: 'help',
1717
1944
  border: `1px solid ${vendor.color}59`, background: `${vendor.color}14`, color: 'inherit',
1718
1945
  },
1719
- }, h('span', null, capIcon(label)), h('span', null, label))),
1946
+ }, h('span', null, capIcon(chip.key)), h('span', null, chip.label))),
1720
1947
  )
1721
1948
  : null,
1722
1949
  h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: 12 } },
1723
1950
  stat(t('colRemaining'), row.free ? t('unlimited') : money(row.remaining)),
1724
1951
  stat(t('colMonthlyUsed'), row.free ? t('unlimited') : `${money(row.monthly)} / ${money(row.limit)}`),
1725
1952
  stat(t('colWeeklyUsed'), row.free ? t('unlimited') : money(row.weekly)),
1726
- stat(t('colRolling'), percentText(row.rolling, row.rollingLimit, row.free)),
1953
+ stat(t('colRollingQuota'), percentText(row.rolling, row.rollingLimit, row.free)),
1727
1954
  stat(t('colRequests'), String(row.requests)),
1728
1955
  ),
1729
1956
  );
@@ -1762,7 +1989,30 @@ window.__ModuleLoader__.load({
1762
1989
  style: winner
1763
1990
  ? { fontWeight: 600, color: '#3c9d5d', background: 'rgba(60,157,93,.12)', padding: '1px 6px', borderRadius: 4 }
1764
1991
  : undefined,
1765
- }, `${yuan(price)}(${peakFlag ? t('peakAtPeak') : t('peakOffPeak')})`);
1992
+ }, yuan(price));
1993
+
1994
+ // Both sides run the same wall-clock windows but keep different holiday
1995
+ // calendars, so the page reports them separately with a countdown.
1996
+ const clockInfo = data?.clock ?? null;
1997
+ const periodOf = (side) => (side?.peak ? t('clockPeak') : t('clockOff'));
1998
+ const withPeriod = (label, side) => {
1999
+ if (!side) return label;
2000
+ const period = periodOf(side);
2001
+ // Full-width brackets read correctly in Chinese, but look odd in Latin text.
2002
+ return /^zh/i.test(LANG) ? `${label}(${period})` : `${label} (${period})`;
2003
+ };
2004
+ const clockLine = (label, side) => h('div', {
2005
+ style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, flexWrap: 'wrap' },
2006
+ },
2007
+ h('span', { style: { minWidth: 96, opacity: .75 } }, label),
2008
+ side ? pill(periodOf(side), side.peak ? '#e0a030' : '#3c9d5d') : h('span', { style: { opacity: .5 } }, t('none')),
2009
+ side && typeof side.hours === 'number'
2010
+ ? h('span', { style: { opacity: .65 } }, t('clockFlip', {
2011
+ hours: side.hours.toFixed(1),
2012
+ next: side.peak ? t('clockOff') : t('clockPeak'),
2013
+ }))
2014
+ : null,
2015
+ );
1766
2016
 
1767
2017
  const inspectingAccount = accountList.find((account) => account.id === inspecting) ?? null;
1768
2018
 
@@ -1771,6 +2021,13 @@ window.__ModuleLoader__.load({
1771
2021
  h('div', { style: { fontSize: 16, fontWeight: 600 } }, t('title')),
1772
2022
  state.phase === 'ready' ? h('span', { style: { opacity: .55, fontSize: 12 } }, t('updatedAt', { time: clock(data?.checkedAt) })) : null,
1773
2023
  h('span', { style: { flex: 1 } }),
2024
+ h('a', {
2025
+ href: 'https://opencode.ai/console',
2026
+ target: '_blank',
2027
+ rel: 'noreferrer',
2028
+ className: 'ocr-btn',
2029
+ style: { ...btn, textDecoration: 'none', display: 'inline-block' },
2030
+ }, t('subscribe')),
1774
2031
  h('button', { type: 'button', className: 'ocr-btn', style: btn, disabled: busy, onClick: () => { void load(true); } }, busy ? t('refreshing') : t('refresh')),
1775
2032
  ),
1776
2033
  state.phase === 'error' ? h('div', { style: { color: '#d9534f', fontSize: 13 } }, t('loadError', { message: state.error })) : null,
@@ -1838,11 +2095,25 @@ window.__ModuleLoader__.load({
1838
2095
  color: verdictColor, background: `${verdictColor}22`, border: `1px solid ${verdictColor}80`,
1839
2096
  },
1840
2097
  }, verdict),
2098
+ clockInfo
2099
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
2100
+ h('div', { style: { fontSize: 12, opacity: .6 } }, t('clockTitle')),
2101
+ clockLine(t('colOfficial'), clockInfo.official),
2102
+ clockLine(t('colOpenCode'), clockInfo.opencode),
2103
+ clockInfo.holiday
2104
+ ? h('div', { style: { fontSize: 11, opacity: .55 } }, t('clockHoliday'))
2105
+ : null,
2106
+ )
2107
+ : null,
1841
2108
  demo ? h('div', { style: { fontSize: 12, opacity: .7 } }, t('demoNote')) : null,
1842
2109
  h('div', { style: { overflowX: 'auto', cursor: 'grab' }, ...draggable() },
1843
2110
  h('table', { style: { borderCollapse: 'collapse', width: '100%', fontSize: 12 } },
1844
2111
  h('thead', null,
1845
- h('tr', null, [t('colModel'), t('colKind'), t('peakGoPay'), t('peakOfficialPay')].map((title) => th(title))),
2112
+ h('tr', null, [
2113
+ t('colModel'), t('colKind'),
2114
+ withPeriod(t('colOpenCode'), clockInfo?.opencode),
2115
+ withPeriod(t('colOfficial'), clockInfo?.official),
2116
+ ].map((title) => th(title))),
1846
2117
  ),
1847
2118
  h('tbody', null, shown.models.flatMap((row) =>
1848
2119
  (row.rows ?? [{ kind: 'output', go: row.go, official: row.official, cheaper: row.cheaper, ratio: row.ratio }])
@@ -1895,6 +2166,19 @@ window.__ModuleLoader__.load({
1895
2166
  if (ok) setNotice(t('probedAt', { time: clock(Date.now()) }));
1896
2167
  },
1897
2168
  }),
2169
+
2170
+ showTop
2171
+ ? h('button', {
2172
+ type: 'button', className: 'ocr-btn', title: t('backToTop'),
2173
+ onClick: toTop,
2174
+ style: {
2175
+ position: 'fixed', right: 28, bottom: 28, zIndex: 900,
2176
+ width: 38, height: 38, padding: 0, borderRadius: 999, fontSize: 16, lineHeight: 1,
2177
+ background: 'var(--dsh-surface, rgba(127,127,127,.16))', backdropFilter: 'blur(6px)',
2178
+ boxShadow: '0 6px 18px rgba(0,0,0,.28)',
2179
+ },
2180
+ }, '↑')
2181
+ : null,
1898
2182
  );
1899
2183
  }
1900
2184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-opencode-routing",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "dsh plugin: OpenCode account pool with automatic A-to-B failover and quota routing, plus a per-account / per-model usage dashboard, in one Settings page.",
6
6
  "exports": {
package/test.mjs CHANGED
@@ -14,7 +14,7 @@ import assert from 'node:assert/strict'
14
14
 
15
15
  import * as accounts from './index.js'
16
16
  import { apply as applyRouter, isExhausted, maskKey, nextProbeAt, retryAfter } from './router.js'
17
- import { apply as applyUsage, isChinaHoliday, peakMultiplier, peakSnapshot } from './usage.js'
17
+ import { apply as applyUsage, isChinaHoliday, peakClock, peakMultiplier, peakSnapshot } from './usage.js'
18
18
 
19
19
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
20
20
 
@@ -70,6 +70,24 @@ assert.equal(isExhausted({ rolling: { status: 'ok', percent: 100 } }), true)
70
70
  assert.equal(holiday.models.every((row) => row.cheaper === 'go'), true)
71
71
  const plain = peakSnapshot(7.1, plainPeak, 'go')
72
72
  assert.ok(Math.abs(plain.models[0].ratio - 0.1) < 0.001, 'both peak: exactly the 10x discount')
73
+
74
+ // The page reports both calendars separately with a countdown to the flip.
75
+ const clockHoliday = peakClock(holidayPeak)
76
+ assert.equal(clockHoliday.holiday, true)
77
+ assert.equal(clockHoliday.official.peak, false, 'the official API is off-peak on a holiday')
78
+ assert.equal(clockHoliday.opencode.peak, true, 'OpenCode keeps billing the holiday as peak')
79
+ assert.equal(typeof clockHoliday.opencode.hours, 'number')
80
+ assert.ok(clockHoliday.opencode.hours > 0 && clockHoliday.opencode.hours <= 5, 'the window ends within 5 hours')
81
+
82
+ const clockPlain = peakClock(plainPeak)
83
+ assert.equal(clockPlain.official.peak, true)
84
+ assert.equal(clockPlain.opencode.peak, true)
85
+ assert.ok(clockPlain.opencode.hours >= 1.9 && clockPlain.opencode.hours <= 2.1, 'two hours left in the morning window')
86
+
87
+ const clockQuiet = peakClock(Date.parse('2026-02-24T12:00:00Z')) // Beijing 20:00
88
+ assert.equal(clockQuiet.official.peak, false)
89
+ assert.equal(clockQuiet.opencode.peak, false)
90
+ assert.ok(clockQuiet.opencode.hours > 0, 'a flip always lies ahead')
73
91
  }
74
92
  console.log('pure logic ok')
75
93
 
package/usage.js CHANGED
@@ -292,6 +292,47 @@ export function peakMultiplier(model, at, holidayAware = false) {
292
292
  return PEAK.utcWeekdayHours.some(([from, to]) => hour >= from && hour < to) ? PEAK.factor : 1
293
293
  }
294
294
 
295
+ /** Is `side` billing at peak right now?
296
+ *
297
+ * The two sides keep different calendars: DeepSeek's own API treats Chinese
298
+ * statutory holidays as off-peak, while OpenCode bills by its weekday schedule
299
+ * only (anomalyco/opencode#53071). Both use the same wall-clock windows, so the
300
+ * only difference is the holiday rule. */
301
+ export function peakAt(side, at = Date.now()) {
302
+ const date = new Date(at)
303
+ const day = date.getUTCDay()
304
+ if (day === 0 || day === 6) return false
305
+ const hour = date.getUTCHours()
306
+ if (!PEAK.utcWeekdayHours.some(([from, to]) => hour >= from && hour < to)) return false
307
+ return side === 'official' ? !isChinaHoliday(at) : true
308
+ }
309
+
310
+ /** The peak windows start and end on these hours, so a flip can only happen at
311
+ * one of them — no need to scan every minute. */
312
+ const PEAK_BOUNDARIES = PEAK.utcWeekdayHours.flatMap(([from, to]) => [from, to])
313
+
314
+ /** Peak state for both sides, plus how long until each one flips, in hours with
315
+ * one decimal. */
316
+ export function peakClock(at = Date.now()) {
317
+ const side = (name) => {
318
+ const peak = peakAt(name, at)
319
+ const base = new Date(at)
320
+ let next = null
321
+ for (let day = 0; day < 14 && next === null; day += 1) {
322
+ for (const hour of PEAK_BOUNDARIES) {
323
+ const point = Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() + day, hour, 0, 0, 0)
324
+ if (point <= at) continue
325
+ if (peakAt(name, point) === peak) continue
326
+ next = point
327
+ break
328
+ }
329
+ }
330
+ const hours = next === null ? null : Math.round(((next - at) / HOUR) * 10) / 10
331
+ return { peak, next, hours }
332
+ }
333
+ return { at, holiday: isChinaHoliday(at), official: side('official'), opencode: side('opencode') }
334
+ }
335
+
295
336
  /** What each plan costs per month, in USD. The plan's allowance is metered at
296
337
  * DeepSeek's list price, so the subscription buys its allowance at a fixed
297
338
  * discount — Go pays $10 for $100 of list-priced usage, i.e. 10x. */
@@ -684,6 +725,7 @@ export function apply(ctx, config = {}) {
684
725
  startedAt,
685
726
  windowStarts: starts,
686
727
  holidayAware: cfg.holidayAware,
728
+ clock: peakClock(),
687
729
  peak: peakSnapshot(cfg.cnyPerUsd, Date.now(), detectedPlan ?? cfg.plan, {
688
730
  priceUsd: cfg.planPriceUsd,
689
731
  allowanceUsd: cfg.planAllowanceUsd,