@aiworker/web 1.22.2 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -3,6 +3,11 @@
3
3
  ## Purpose
4
4
  This module fetches quota and usage signals for supported providers in the web server runtime.
5
5
 
6
+ Node server entrypoints apply `../network-defaults.js` before serving requests,
7
+ including the daemon launched directly through `server/index.js`. Connection
8
+ attempts get 5 seconds without changing address-family selection. The VS Code
9
+ extension applies the same policy in its own process at activation.
10
+
6
11
  ## Entrypoints and structure
7
12
  - `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`.
8
13
  - `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
@@ -18,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
18
23
  | Provider ID | Display name | Module | Auth aliases/keys |
19
24
  | --- | --- | --- | --- |
20
25
  | `claude` | Claude | `providers/claude/` | Claude Code Keychain entry, Claude Code credentials file, OpenCode `auth.json` (`anthropic`, `claude`), `CLAUDE_CODE_OAUTH_TOKEN` |
26
+ | `cline-pass` | ClinePass | `providers/cline-pass.js` | `cline-pass` (API key under `key` or `token`) |
21
27
  | `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
22
28
  | `command-code` | Command Code | `providers/command-code.js` | `command-code` OAuth/API credential in OpenCode `auth.json`, or `COMMAND_CODE_API_KEY` |
23
29
  | `cursor` | Cursor | `providers/cursor.js` | Environment/token files, AiWorker-managed credentials, or explicit one-time Cursor import |
@@ -35,7 +41,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
35
41
  | `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
36
42
  | `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
37
43
  | `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
38
- | `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
44
+ | `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie pasted into Settings (`aid=...; __Secure-session=...` from `ollama.com`), stored under `~/.config/openchamber/quota/` |
39
45
  | `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
40
46
  | `opencode-go` | OpenCode Go | `providers/opencode-go.js` | `opencode-go` API key from OpenCode `auth.json` |
41
47
  | `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
@@ -91,6 +97,16 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
91
97
  - **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
92
98
  - **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
93
99
 
100
+ ## ClinePass quota semantics
101
+
102
+ ClinePass reads `data.limits` from its usage-limits endpoint. Web/Electron and
103
+ VS Code accept only known window types with finite numeric or non-empty numeric
104
+ string percentages. Invalid windows are skipped independently; no usable windows
105
+ is a failed refresh, not zero usage. Both implementations choose a non-empty
106
+ `key`, then `token`, and expose auth/fetch dependencies for focused tests.
107
+ Saved UI provider-visibility lists remain authoritative; installations without a
108
+ saved list include ClinePass through the provider registry.
109
+
94
110
  ## Charm Hyper balance semantics
95
111
 
96
112
  `GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label.
@@ -105,6 +121,10 @@ Web and VS Code accept finite numeric balances and non-empty numeric strings. Mi
105
121
 
106
122
  The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it.
107
123
 
124
+ ## Ollama Cloud settings-page shapes
125
+
126
+ Ollama Cloud authentication uses two cookies (`aid` and `__Secure-session`) pasted together as one single-line Cookie header value. Ollama serves two different `/settings` page shapes and `parseOllamaSettingsHtml` supports both: session/weekly/premium-interaction windows (percent-based plans) and a `monthly` window derived from "Monthly usage: $X of $Y used" (cost-based plans) with a symmetric `$X / $Y` money `valueLabel` that reads correctly in both used/remaining display modes. The "Extra usage" credits block is surfaced as a balance-only `credits_balance` window with a plain money `valueLabel` when present, matching the Codex/DeepSeek credits treatment ("Credits Balance" in the UI); a $0 balance is omitted instead of showing an empty credits row. Keep `packages/web/server/lib/quota/providers/ollama-cloud.js` and `packages/vscode/src/quotaProviders.ts` (`parseOllamaSettingsHtml`) in sync — the VS Code extension duplicates this parsing logic rather than importing it.
127
+
108
128
  ## GitHub Copilot quota semantics
109
129
 
110
130
  GitHub Copilot usage exposes only the `premium_interactions` snapshot as the
@@ -124,6 +144,16 @@ snapshot carries `entitlement`, `remaining`, `unlimited`, and
124
144
  - When entitlement/remaining are unusable, fall back to `100 - percent_remaining`.
125
145
  - Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows.
126
146
 
147
+ ## OpenRouter key semantics
148
+
149
+ OpenRouter quota reads `GET https://openrouter.ai/api/v1/key`, which is documented as callable with any valid API key. `GET /api/v1/credits` is documented as "Management key required" and is not used. Calling `/credits` with a normal inference key has been observed to return HTTP 200 with `{total_credits:0, total_usage:0}` rather than an error; this behavior is not documented and is why the old implementation silently rendered "$0.00 left · $0.00 spent". A `/credits` fallback for unlimited keys would render the same zeros, so unlimited keys report `usage_monthly` instead.
150
+
151
+ The documented `limit`, `limit_remaining`, and `limit_reset` fields are present and null on unlimited keys; null means unlimited, never missing data. For a limited key, window usage is `limit - limit_remaining`, not `usage`: `usage` is all-time and measures a different axis from the current reset window. Pairing `usage` with the current limit produces a wrong number. `limit_remaining` is server-computed and already honors `include_byok_in_limit`, so `byok_*` fields are ignored.
152
+
153
+ Unlimited keys report `usage_monthly` in a `monthly` window with no percent. `limit_reset` is a period string (`daily`, `weekly`, `monthly`, or null), not a timestamp; `resetAt` is derived from the documented midnight-UTC boundaries, with weeks starting Monday. A set `limit` with a null `limit_reset` is a lifetime cap and maps to the `credits` window with no reset.
154
+
155
+ Keep `packages/web/server/lib/quota/providers/openrouter.js` and `packages/vscode/src/quotaProviders.ts` in sync, as with the Kimi and Copilot providers; the VS Code extension duplicates this parsing logic rather than importing the web provider.
156
+
127
157
  ## Notes for contributors
128
158
  - Keep provider IDs stable; clients use them directly.
129
159
  - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
@@ -0,0 +1,125 @@
1
+ import { readAuthFile } from '../../opencode/auth.js';
2
+ import {
3
+ getAuthEntry,
4
+ normalizeAuthEntry,
5
+ asObject,
6
+ asNonEmptyString,
7
+ buildResult,
8
+ toUsageWindow,
9
+ toNumber,
10
+ toTimestamp
11
+ } from '../utils/index.js';
12
+
13
+ export const providerId = 'cline-pass';
14
+ export const providerName = 'ClinePass';
15
+ export const aliases = ['cline-pass'];
16
+ const CLINE_USAGE_URL = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits';
17
+
18
+ // Cline reports a rolling five-hour window, a rolling weekly window, and a
19
+ // calendar-month limit. Each window carries its duration so consumers can rank
20
+ // limits by how soon they run out; the calendar month has no fixed duration.
21
+ const WINDOW_KINDS = new Map([
22
+ ['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }],
23
+ ['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }],
24
+ ['monthly', { key: 'monthly', windowSeconds: null }]
25
+ ]);
26
+
27
+ const getApiKey = (auth) => {
28
+ const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
29
+ return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
30
+ };
31
+
32
+ export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
33
+
34
+ export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
35
+ const apiKey = getApiKey(readAuth());
36
+
37
+ if (!apiKey) {
38
+ return buildResult({
39
+ providerId,
40
+ providerName,
41
+ ok: false,
42
+ configured: false,
43
+ error: 'Not configured'
44
+ });
45
+ }
46
+
47
+ const timeoutSignal = AbortSignal.timeout(15_000);
48
+
49
+ try {
50
+ const response = await fetchImpl(CLINE_USAGE_URL, {
51
+ method: 'GET',
52
+ headers: {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ 'Accept-Encoding': 'identity'
55
+ },
56
+ signal: timeoutSignal
57
+ });
58
+
59
+ if (!response.ok) {
60
+ return buildResult({
61
+ providerId,
62
+ providerName,
63
+ ok: false,
64
+ configured: true,
65
+ error: response.status === 401
66
+ ? 'Session expired — please re-authenticate with ClinePass'
67
+ : `API error: ${response.status}`
68
+ });
69
+ }
70
+
71
+ const payload = asObject(await response.json());
72
+ const data = asObject(payload?.data);
73
+ const limits = Array.isArray(data?.limits) ? data.limits : [];
74
+
75
+ const windows = {};
76
+ for (const item of limits) {
77
+ const limit = asObject(item);
78
+ if (!limit) continue;
79
+ const kind = WINDOW_KINDS.get(asNonEmptyString(limit.type));
80
+ if (!kind) continue;
81
+ const usedPercent = toNumber(asNonEmptyString(limit.percentUsed)
82
+ ?? (Number.isFinite(limit.percentUsed) ? limit.percentUsed : null));
83
+ if (usedPercent === null) continue;
84
+ windows[kind.key] = toUsageWindow({
85
+ usedPercent,
86
+ windowSeconds: kind.windowSeconds,
87
+ resetAt: toTimestamp(limit.resetsAt)
88
+ });
89
+ }
90
+
91
+ if (Object.keys(windows).length === 0) {
92
+ return buildResult({
93
+ providerId,
94
+ providerName,
95
+ ok: false,
96
+ configured: true,
97
+ error: 'No quota data in response'
98
+ });
99
+ }
100
+
101
+ return buildResult({
102
+ providerId,
103
+ providerName,
104
+ ok: true,
105
+ configured: true,
106
+ usage: { windows }
107
+ });
108
+ } catch (error) {
109
+ const isTimeout = error instanceof DOMException && (
110
+ error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
111
+ );
112
+ const isParseError = error instanceof SyntaxError;
113
+ return buildResult({
114
+ providerId,
115
+ providerName,
116
+ ok: false,
117
+ configured: true,
118
+ error: isTimeout
119
+ ? 'Request timed out'
120
+ : isParseError
121
+ ? 'Invalid response from provider'
122
+ : (error instanceof Error ? error.message : 'Request failed')
123
+ });
124
+ }
125
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fetchQuota, isConfigured } from './cline-pass.js';
3
+
4
+ const readAuth = () => ({ 'cline-pass': { key: 'test-token' } });
5
+
6
+ // Response shape verified by the contributor against the live ClinePass API.
7
+ const documentedPayload = {
8
+ data: { limits: [
9
+ { type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' },
10
+ { type: 'weekly', percentUsed: 17 },
11
+ { type: 'monthly', percentUsed: 8 },
12
+ ] },
13
+ success: true,
14
+ };
15
+
16
+ describe('ClinePass quota provider', () => {
17
+ it('maps the documented windows and sends credentials only in headers', async () => {
18
+ const result = await fetchQuota({ readAuth, fetchImpl: async (url, options) => {
19
+ expect(url).toBe('https://api.cline.bot/api/v1/users/me/plan/usage-limits');
20
+ expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
21
+ expect(options.signal).toBeInstanceOf(AbortSignal);
22
+ return Response.json(documentedPayload);
23
+ } });
24
+ expect(result.ok).toBe(true);
25
+ expect(result.providerId).toBe('cline-pass');
26
+ expect(Object.keys(result.usage.windows)).toEqual(['5h', 'weekly', 'monthly']);
27
+ expect(result.usage.windows['5h'].usedPercent).toBe(43);
28
+ expect(result.usage.windows['5h'].remainingPercent).toBe(57);
29
+ expect(result.usage.windows['5h'].windowSeconds).toBe(18_000);
30
+ expect(result.usage.windows['5h'].resetAt).toBe(Date.parse('2026-09-08T17:00:44.598174595Z'));
31
+ expect(result.usage.windows.weekly.usedPercent).toBe(17);
32
+ expect(result.usage.windows.weekly.windowSeconds).toBe(604_800);
33
+ expect(result.usage.windows.monthly.usedPercent).toBe(8);
34
+ expect(result.usage.windows.monthly.windowSeconds).toBeNull();
35
+ expect(JSON.stringify(result)).not.toContain('test-token');
36
+ });
37
+
38
+ it.each([0, '0', '51'])('accepts finite percentage %s', async (percentUsed) => {
39
+ const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) });
40
+ expect(result.ok).toBe(true);
41
+ expect(result.usage.windows.weekly.usedPercent).toBe(Number(percentUsed));
42
+ });
43
+
44
+ it.each([
45
+ { type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 },
46
+ { type: '__proto__', percentUsed: 5 }, { type: 'quarterly', percentUsed: 5 },
47
+ { type: 'weekly', percentUsed: '' }, { type: 'weekly', percentUsed: ' ' },
48
+ { type: 'weekly', percentUsed: null }, { type: 'weekly', percentUsed: true },
49
+ { type: 'weekly', percentUsed: 'NaN' }, null,
50
+ ])('ignores malformed windows without discarding valid siblings: %j', async (limit) => {
51
+ const invalid = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) });
52
+ expect(invalid.ok).toBe(false);
53
+ expect(invalid.configured).toBe(true);
54
+ expect(invalid.usage).toBeNull();
55
+ const mixed = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) });
56
+ expect(mixed.ok).toBe(true);
57
+ expect(Object.keys(mixed.usage.windows)).toEqual(['monthly']);
58
+ });
59
+
60
+ it.each([null, [], {}, { data: null }, { data: { limits: [] } }])('rejects empty or malformed payload %j', async (payload) => {
61
+ const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
62
+ expect(result.ok).toBe(false);
63
+ expect(result.usage).toBeNull();
64
+ expect(result.error).toBe('No quota data in response');
65
+ });
66
+
67
+ it.each([{ key: '' }, { key: ' ' }, { key: 42 }, {}])('uses a usable token when the key is malformed: %j', async (entry) => {
68
+ const auth = { 'cline-pass': { ...entry, token: 'test-token' } };
69
+ expect(isConfigured(auth)).toBe(true);
70
+ const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async (_url, options) => {
71
+ expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
72
+ return Response.json(documentedPayload);
73
+ } });
74
+ expect(result.ok).toBe(true);
75
+ });
76
+
77
+ it.each([{}, { 'cline-pass': { key: '' } }, { 'cline-pass': { key: 42 } }])('does not fetch without usable credentials: %j', async (auth) => {
78
+ expect(isConfigured(auth)).toBe(false);
79
+ const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async () => { throw new Error('Unexpected fetch'); } });
80
+ expect(result.configured).toBe(false);
81
+ expect(result.error).toBe('Not configured');
82
+ });
83
+
84
+ it.each([[401, 'Session expired — please re-authenticate with ClinePass'], [503, 'API error: 503']])('reports HTTP %s', async (status, error) => {
85
+ const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
86
+ expect(result.ok).toBe(false);
87
+ expect(result.usage).toBeNull();
88
+ expect(result.error).toBe(error);
89
+ });
90
+
91
+ it('reports invalid JSON', async () => {
92
+ const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
93
+ expect(result.error).toBe('Invalid response from provider');
94
+ });
95
+
96
+ it('recognizes the timeout exception from AbortSignal.timeout', async () => {
97
+ const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } });
98
+ expect(result.ok).toBe(false);
99
+ expect(result.configured).toBe(true);
100
+ expect(result.usage).toBeNull();
101
+ expect(result.error).toBe('Request timed out');
102
+ });
103
+ });
@@ -8,6 +8,7 @@
8
8
  import { buildResult } from '../utils/index.js';
9
9
 
10
10
  import * as claude from './claude/index.js';
11
+ import * as clinePass from './cline-pass.js';
11
12
  import * as codex from './codex.js';
12
13
  import * as copilot from './copilot.js';
13
14
  import * as crof from './crof.js';
@@ -37,6 +38,12 @@ const registry = {
37
38
  isConfigured: claude.isConfigured,
38
39
  fetchQuota: claude.fetchQuota
39
40
  },
41
+ 'cline-pass': {
42
+ providerId: clinePass.providerId,
43
+ providerName: clinePass.providerName,
44
+ isConfigured: clinePass.isConfigured,
45
+ fetchQuota: clinePass.fetchQuota
46
+ },
40
47
  codex: {
41
48
  providerId: codex.providerId,
42
49
  providerName: codex.providerName,
@@ -74,7 +74,6 @@ export const fetchQuota = async () => {
74
74
  const subscription = payload?.subscription ?? null;
75
75
  const inOverage = Boolean(subscription?.in_overage);
76
76
  const allowance = payload?.key?.allowance ?? null;
77
- const keyName = payload?.key?.name ?? null;
78
77
  const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd);
79
78
 
80
79
  const windows = {};
@@ -116,19 +115,17 @@ export const fetchQuota = async () => {
116
115
  : (spent !== null && effectiveLimit !== null && effectiveLimit > 0
117
116
  ? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100))
118
117
  : null);
119
- // Window title is the localized period label (daily/weekly/monthly); key
120
- // name is attached via valueLabel for identification (wafer precedent).
118
+ // Window title is the localized period label (daily/weekly/monthly); the
119
+ // usage value stays a percent so the UI's display-mode toggle applies.
121
120
  const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month')
122
121
  ? (period === 'month' ? 'monthly' : period)
123
122
  : 'billing_cycle';
124
- const labelName = asNonEmptyString(keyName);
125
123
  const resetAt = toTimestamp(allowance.reset_at);
126
124
  const windowSeconds = period ? periodToWindowSeconds(period) : null;
127
125
  windows[periodKey] = toUsageWindow({
128
126
  usedPercent,
129
127
  windowSeconds,
130
- resetAt,
131
- ...(labelName ? { valueLabel: labelName } : {})
128
+ resetAt
132
129
  });
133
130
  } else if (creditsRemaining !== null) {
134
131
  windows.credits_balance = toUsageWindow({
@@ -89,7 +89,7 @@ describe('NeuralWatt quota provider', () => {
89
89
  expect(result.usage.windows.credits_balance.valueLabel).toBe('$32.68');
90
90
  });
91
91
 
92
- it('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => {
92
+ it('surfaces subscription and allowance windows (allowance keyed by period, percent value)', async () => {
93
93
  const payload = {
94
94
  ...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
95
95
  balance: { credits_remaining_usd: 200 },
@@ -107,11 +107,11 @@ describe('NeuralWatt quota provider', () => {
107
107
  expect(subWindow.usedPercent).toBeCloseTo((13.9023 / 20.0) * 100, 4);
108
108
 
109
109
  // Allowance window is keyed by the localized period label ("monthly");
110
- // key name flows through valueLabel for identification.
110
+ // the usage value stays a percent — no key-name valueLabel.
111
111
  const allowWindow = result.usage.windows.monthly;
112
112
  expect(allowWindow).toBeDefined();
113
113
  expect(allowWindow.usedPercent).toBe(25);
114
- expect(allowWindow.valueLabel).toBe('Prod');
114
+ expect(allowWindow.valueLabel).toBeUndefined();
115
115
  expect(allowWindow.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z'));
116
116
 
117
117
  // credits_balance suppressed because allowance is present
@@ -137,7 +137,7 @@ describe('NeuralWatt quota provider', () => {
137
137
  expect(window.usedPercent).toBeCloseTo((25 / 55) * 100, 4);
138
138
  expect(window.windowSeconds).toBe(30 * 86400);
139
139
  expect(window.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z'));
140
- expect(window.valueLabel).toBe('prod-key');
140
+ expect(window.valueLabel).toBeUndefined();
141
141
  expect(result.usage.windows.credits_balance).toBeUndefined();
142
142
  });
143
143
 
@@ -176,7 +176,7 @@ describe('NeuralWatt quota provider', () => {
176
176
  expect(window).toBeDefined();
177
177
  expect(window.windowSeconds).toBe(604800);
178
178
  expect(window.resetAt).toBe(Date.parse('2026-07-04T00:00:00Z'));
179
- expect(window.valueLabel).toBe('Prod');
179
+ expect(window.valueLabel).toBeUndefined();
180
180
  });
181
181
 
182
182
  it('uses daily as the allowance key when period is daily', async () => {
@@ -216,7 +216,7 @@ describe('NeuralWatt quota provider', () => {
216
216
  expect(window.usedPercent).toBe(25);
217
217
  });
218
218
 
219
- it('marks blocked allowance as 100% with valueLabel set', async () => {
219
+ it('marks blocked allowance as 100% with percent value', async () => {
220
220
  const payload = {
221
221
  balance: { credits_remaining_usd: 30 },
222
222
  subscription: null,
@@ -231,7 +231,7 @@ describe('NeuralWatt quota provider', () => {
231
231
 
232
232
  const window = result.usage.windows.monthly;
233
233
  expect(window.usedPercent).toBe(100);
234
- expect(window.valueLabel).toBe('sample');
234
+ expect(window.valueLabel).toBeUndefined();
235
235
  });
236
236
 
237
237
  it('falls back to credits_balance when neither subscription nor allowance exists', async () => {
@@ -35,6 +35,37 @@ export const parseOllamaSettingsHtml = (html) => {
35
35
  valueLabel: `${used ?? 0} / ${total ?? 0}`
36
36
  });
37
37
  }
38
+ // Cost-based plans render "Monthly usage" with a dollar amount instead of
39
+ // session/weekly/premium windows; support both page shapes.
40
+ const monthlyMatch = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i);
41
+ if (monthlyMatch) {
42
+ const used = toNumber(monthlyMatch[1].replace(/,/g, ''));
43
+ const total = toNumber(monthlyMatch[2].replace(/,/g, ''));
44
+ const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null;
45
+ windows.monthly = toUsageWindow({
46
+ usedPercent,
47
+ windowSeconds: null,
48
+ resetAt: null,
49
+ valueLabel: `$${monthlyMatch[1]} / $${monthlyMatch[2]}`
50
+ });
51
+ }
52
+ // "Extra usage" credits block (visible when credits/auto-reload is enabled):
53
+ // a balance, not a percent. Anchor on "Balance remaining" — nearby "Add $5"
54
+ // and auto-reload copy also contain dollar amounts. Surfaced with the
55
+ // credits_balance key and OpenAI-style plain money label (the UI renders
56
+ // it as "Credits Balance"); a $0 balance is omitted rather than shown.
57
+ const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i);
58
+ if (balanceMatch) {
59
+ const balance = toNumber(balanceMatch[1].replace(/,/g, ''));
60
+ if (balance !== 0) {
61
+ windows.credits_balance = toUsageWindow({
62
+ usedPercent: null,
63
+ windowSeconds: null,
64
+ resetAt: null,
65
+ valueLabel: `$${balanceMatch[1]}`
66
+ });
67
+ }
68
+ }
38
69
  return windows;
39
70
  };
40
71
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { fetchOllamaCloudUsage } from './ollama-cloud.js';
2
+ import { parseOllamaSettingsHtml, fetchOllamaCloudUsage } from './ollama-cloud.js';
3
3
 
4
4
  describe('Ollama Cloud quota provider', () => {
5
5
  it('rejects redirects without forwarding credentials', async () => {
@@ -9,4 +9,36 @@ describe('Ollama Cloud quota provider', () => {
9
9
  it('rejects successful pages without usage data', async () => {
10
10
  await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('<html></html>'))).rejects.toThrow('could not be parsed');
11
11
  });
12
+
13
+ it('parses session/weekly/premium windows', () => {
14
+ const windows = parseOllamaSettingsHtml('<p>Session usage: 42%</p><p>Weekly usage: 7%</p><p>Premium 120 / 1000</p>');
15
+ expect(windows.session.usedPercent).toBe(42);
16
+ expect(windows.weekly.usedPercent).toBe(7);
17
+ expect(windows.premium.usedPercent).toBe(12);
18
+ expect(windows.premium.valueLabel).toBe('120 / 1000');
19
+ });
20
+
21
+ it('parses the cost-based monthly usage shape', () => {
22
+ const html = '<span class="text-sm">Monthly usage</span>\n <span class="text-sm "\n >$4.39 of $60 used</span\n >';
23
+ const windows = parseOllamaSettingsHtml(html);
24
+ expect(windows.monthly.usedPercent).toBeCloseTo(7.3, 1);
25
+ expect(windows.monthly.valueLabel).toBe('$4.39 / $60');
26
+ expect(windows.monthly.remainingPercent).toBeCloseTo(92.7, 1);
27
+ });
28
+
29
+ it('parses the extra-usage credits balance', () => {
30
+ const html = '<div><span>Balance remaining</span><span>$12.50</span></div><button>Add $5</button>';
31
+ const windows = parseOllamaSettingsHtml(html);
32
+ expect(windows.credits_balance.usedPercent).toBeNull();
33
+ expect(windows.credits_balance.valueLabel).toBe('$12.50');
34
+ });
35
+
36
+ it('omits the credits balance when it is zero', () => {
37
+ const html = '<div><span>Balance remaining</span><span>$0.00</span></div>';
38
+ expect(parseOllamaSettingsHtml(html).credits_balance).toBeUndefined();
39
+ });
40
+
41
+ it('ignores nearby dollar amounts that are not the balance', () => {
42
+ expect(parseOllamaSettingsHtml('<p>Add $5 to your balance when it hits $0</p>').credits_balance).toBeUndefined();
43
+ });
12
44
  });
@@ -25,7 +25,7 @@ export const parseOpenCodeGoUsage = (payload) => {
25
25
  if (typeof resetAt !== 'string' || !Number.isFinite(new Date(resetAt).getTime())) continue;
26
26
  windows[key] = toUsageWindow({
27
27
  usedPercent: Math.min(100, Math.max(0, usedPercent)),
28
- resetAt,
28
+ resetAt: new Date(resetAt).getTime(),
29
29
  windowSeconds: null,
30
30
  });
31
31
  }
@@ -27,11 +27,36 @@ describe('OpenCode Go quota provider', () => {
27
27
  it('parses partial API usage windows', () => {
28
28
  const windows = parseOpenCodeGoUsage({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' }, weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' } } });
29
29
  expect(windows['5h'].usedPercent).toBe(25);
30
- expect(windows['5h'].resetAt).toBe('2026-08-12T12:00:00.000Z');
30
+ expect(windows['5h'].resetAt).toBe(Date.parse('2026-08-12T12:00:00.000Z'));
31
31
  expect(windows.weekly.usedPercent).toBe(40);
32
32
  expect(windows.monthly).toBeUndefined();
33
33
  });
34
34
 
35
+ it('returns numeric reset timestamps for all usage windows', async () => {
36
+ const resetsAt = '2026-10-01T00:00:00.000Z';
37
+ const windows = await fetchOpenCodeGoUsage('test-key', async () => new Response(JSON.stringify({
38
+ usage: {
39
+ rolling: { percent: 25, resetsAt },
40
+ weekly: { percent: 40, resetsAt },
41
+ monthly: { percent: 60, resetsAt },
42
+ },
43
+ })));
44
+ expect(Object.keys(windows)).toEqual(['5h', 'weekly', 'monthly']);
45
+ for (const window of Object.values(windows)) {
46
+ expect(window.resetAt).toBe(Date.parse(resetsAt));
47
+ }
48
+ });
49
+
50
+ it('preserves valid windows when another reset date is invalid', () => {
51
+ const windows = parseOpenCodeGoUsage({ usage: {
52
+ rolling: { percent: 25, resetsAt: 'invalid' },
53
+ weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' },
54
+ monthly: { percent: 60, resetsAt: null },
55
+ } });
56
+ expect(Object.keys(windows)).toEqual(['weekly']);
57
+ expect(windows.weekly.resetAt).toBe(Date.parse('2026-08-19T12:00:00.000Z'));
58
+ });
59
+
35
60
  it('does not expose credentials in authentication errors', async () => {
36
61
  await expect(fetchOpenCodeGoUsage('secret', async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
37
62
  });