@oar-sh/oar 0.9.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.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Provider-neutral plan-usage contract.
5
+ *
6
+ * "Plan usage" is deliberately NOT the same thing as context usage (see
7
+ * `context-usage-view.mjs`): this describes what a *subscription* has left —
8
+ * credits, request allowances, rate-limit windows and their reset times —
9
+ * while context usage describes one session's token window.
10
+ *
11
+ * Every provider normalizer in `plan-usage-<provider>.mjs` emits the same
12
+ * `{ card: { meters, details, links } }` shape so the browser renderer stays
13
+ * provider-agnostic, and so a provider that can only report *some* of its
14
+ * limits degrades to fewer meters rather than to a special-cased layout.
15
+ */
16
+
17
+ export const PLAN_USAGE_VERSION = 2;
18
+
19
+ /** Where a number came from. The UI badges anything that is not authoritative. */
20
+ export const SOURCE_LIVE = 'live';
21
+ export const SOURCE_CACHE = 'cache';
22
+ export const SOURCE_WORKER = 'worker';
23
+ export const SOURCE_MANUAL = 'manual';
24
+
25
+ /** Card-level availability. `partial` means some meters resolved and some did not. */
26
+ export const STATUS_OK = 'ok';
27
+ export const STATUS_PARTIAL = 'partial';
28
+ export const STATUS_UNAVAILABLE = 'unavailable';
29
+ export const STATUS_NOT_CONFIGURED = 'not-configured';
30
+ export const STATUS_ERROR = 'error';
31
+
32
+ export function toFiniteNumber(value) {
33
+ if (value === null || value === undefined || value === '') return null;
34
+ const numeric = Number(value);
35
+ return Number.isFinite(numeric) ? numeric : null;
36
+ }
37
+
38
+ export function toTrimmedString(value) {
39
+ const text = String(value ?? '').trim();
40
+ return text || null;
41
+ }
42
+
43
+ /** The first candidate that is a finite number, or null when none is. */
44
+ export function pickNumber(...candidates) {
45
+ for (const candidate of candidates) {
46
+ const value = toFiniteNumber(candidate);
47
+ if (value !== null) return value;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Like `pickNumber`, but a negative result reads as "not reported".
54
+ *
55
+ * Token counts, costs, call counts and durations are all non-negative by
56
+ * definition, and every one of these numbers arrives from a WORKER over HTTP —
57
+ * so a negative is either a bug or a poster trying to decrement a ledger.
58
+ * Rejecting is strictly better than booking it.
59
+ *
60
+ * Note the ordering: the first FINITE candidate wins and is then checked, so a
61
+ * negative does not silently fall through to the next candidate — the payload
62
+ * said something about this field and what it said was nonsense.
63
+ */
64
+ export function pickNonNegative(...candidates) {
65
+ const value = pickNumber(...candidates);
66
+ return value === null || value < 0 ? null : value;
67
+ }
68
+
69
+ /** ISO-8601 passthrough that rejects unparseable timestamps instead of rendering "Invalid Date". */
70
+ export function toIsoTimestamp(value) {
71
+ const text = toTrimmedString(value);
72
+ if (!text) return null;
73
+ const parsed = new Date(text);
74
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
75
+ }
76
+
77
+ export function clampPercent(value) {
78
+ const numeric = toFiniteNumber(value);
79
+ if (numeric === null) return null;
80
+ return Math.max(0, Math.min(100, Math.round(numeric * 100) / 100));
81
+ }
82
+
83
+ export function roundCurrency(value) {
84
+ const numeric = toFiniteNumber(value);
85
+ if (numeric === null) return null;
86
+ return Math.round(numeric * 100) / 100;
87
+ }
88
+
89
+ /**
90
+ * Resolve the used/remaining/utilization triangle from whichever two corners a
91
+ * provider actually reports.
92
+ *
93
+ * Providers are inconsistent here: GitHub reports remaining + entitlement,
94
+ * Claude reports utilization only (no denominator at all), and Cursor's manual
95
+ * mode reports used + a user-entered allowance. Deriving the missing corner
96
+ * centrally is what lets one renderer draw all three.
97
+ */
98
+ export function resolveMeterMath({
99
+ used = null,
100
+ allowance = null,
101
+ remaining = null,
102
+ utilization = null,
103
+ } = {}) {
104
+ let usedValue = toFiniteNumber(used);
105
+ let allowanceValue = toFiniteNumber(allowance);
106
+ let remainingValue = toFiniteNumber(remaining);
107
+ let utilizationValue = clampPercent(utilization);
108
+
109
+ if (allowanceValue !== null && allowanceValue < 0) allowanceValue = null;
110
+
111
+ if (usedValue === null && allowanceValue !== null && remainingValue !== null) {
112
+ usedValue = allowanceValue - remainingValue;
113
+ }
114
+ if (remainingValue === null && allowanceValue !== null && usedValue !== null) {
115
+ remainingValue = allowanceValue - usedValue;
116
+ }
117
+ // A utilization-only provider (Claude) still gets a usable bar; the absolute
118
+ // numbers stay null so the UI does not invent a denominator.
119
+ if (utilizationValue === null && allowanceValue !== null && allowanceValue > 0 && usedValue !== null) {
120
+ utilizationValue = clampPercent((usedValue / allowanceValue) * 100);
121
+ }
122
+ if (usedValue === null && allowanceValue !== null && allowanceValue > 0 && utilizationValue !== null) {
123
+ usedValue = (utilizationValue / 100) * allowanceValue;
124
+ if (remainingValue === null) remainingValue = allowanceValue - usedValue;
125
+ }
126
+
127
+ // Overage is real on several plans, so remaining is allowed to go negative
128
+ // while the bar itself clamps at 100%.
129
+ return {
130
+ used: usedValue === null ? null : Math.round(usedValue * 1000) / 1000,
131
+ allowance: allowanceValue,
132
+ remaining: remainingValue === null ? null : Math.round(remainingValue * 1000) / 1000,
133
+ utilization: utilizationValue,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * @param {object} spec
139
+ * @param {string} spec.id stable key, used by tests and DOM ids
140
+ * @param {string} spec.label human label
141
+ * @param {'credits'|'requests'|'usd'|'percent'|'tokens'} [spec.unit]
142
+ * @param {boolean} [spec.unlimited] plan grants unlimited use of this bucket
143
+ * @param {boolean} [spec.estimated] derived/manual rather than provider-authoritative
144
+ * @returns {object|null} null when the meter carries no usable signal at all
145
+ */
146
+ export function buildMeter({
147
+ id,
148
+ label,
149
+ unit = 'credits',
150
+ used = null,
151
+ allowance = null,
152
+ remaining = null,
153
+ utilization = null,
154
+ resetAt = null,
155
+ note = null,
156
+ unlimited = false,
157
+ estimated = false,
158
+ emphasis = 'primary',
159
+ } = {}) {
160
+ const meterId = toTrimmedString(id);
161
+ const meterLabel = toTrimmedString(label);
162
+ if (!meterId || !meterLabel) return null;
163
+
164
+ const math = resolveMeterMath({ used, allowance, remaining, utilization });
165
+ const hasSignal = unlimited
166
+ || math.used !== null
167
+ || math.allowance !== null
168
+ || math.remaining !== null
169
+ || math.utilization !== null;
170
+ if (!hasSignal) return null;
171
+
172
+ return {
173
+ id: meterId,
174
+ label: meterLabel,
175
+ unit,
176
+ unlimited: unlimited === true,
177
+ estimated: estimated === true,
178
+ emphasis: emphasis === 'secondary' ? 'secondary' : 'primary',
179
+ used: math.used,
180
+ allowance: math.allowance,
181
+ remaining: math.remaining,
182
+ utilization: math.utilization,
183
+ resetAt: toIsoTimestamp(resetAt),
184
+ note: toTrimmedString(note),
185
+ };
186
+ }
187
+
188
+ /** A collapsible block of label/value rows shown under the meters. */
189
+ export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
190
+ const sectionId = toTrimmedString(id);
191
+ const sectionLabel = toTrimmedString(label);
192
+ if (!sectionId || !sectionLabel) return null;
193
+ const normalizedRows = (Array.isArray(rows) ? rows : [])
194
+ .map((row) => {
195
+ const rowLabel = toTrimmedString(row?.label);
196
+ const rowValue = row?.value === 0 ? '0' : toTrimmedString(row?.value);
197
+ if (!rowLabel || rowValue === null) return null;
198
+ return { label: rowLabel, value: rowValue, hint: toTrimmedString(row?.hint) };
199
+ })
200
+ .filter(Boolean);
201
+ if (!normalizedRows.length) return null;
202
+ return { id: sectionId, label: sectionLabel, rows: normalizedRows, note: toTrimmedString(note) };
203
+ }
204
+
205
+ export function buildProviderCard({
206
+ provider,
207
+ label,
208
+ status = STATUS_OK,
209
+ planName = null,
210
+ accountLabel = null,
211
+ message = null,
212
+ source = SOURCE_LIVE,
213
+ capturedAt = null,
214
+ stale = false,
215
+ meters = [],
216
+ details = [],
217
+ links = [],
218
+ } = {}) {
219
+ const providerId = toTrimmedString(provider);
220
+ const providerLabel = toTrimmedString(label) || providerId;
221
+ if (!providerId) return null;
222
+
223
+ const normalizedMeters = (Array.isArray(meters) ? meters : []).filter(Boolean);
224
+ const normalizedDetails = (Array.isArray(details) ? details : []).filter(Boolean);
225
+ const normalizedLinks = (Array.isArray(links) ? links : [])
226
+ .map((link) => {
227
+ const linkLabel = toTrimmedString(link?.label);
228
+ const url = toTrimmedString(link?.url);
229
+ // Only absolute http(s) links are emitted; the renderer inserts these
230
+ // into href attributes.
231
+ if (!linkLabel || !url || !/^https:\/\//i.test(url)) return null;
232
+ return { label: linkLabel, url };
233
+ })
234
+ .filter(Boolean);
235
+
236
+ return {
237
+ provider: providerId,
238
+ label: providerLabel,
239
+ status,
240
+ planName: toTrimmedString(planName),
241
+ accountLabel: toTrimmedString(accountLabel),
242
+ message: toTrimmedString(message),
243
+ source,
244
+ capturedAt: toIsoTimestamp(capturedAt),
245
+ stale: stale === true,
246
+ meters: normalizedMeters,
247
+ details: normalizedDetails,
248
+ links: normalizedLinks,
249
+ };
250
+ }
251
+
252
+ export function buildUnavailableCard({
253
+ provider,
254
+ label,
255
+ status = STATUS_UNAVAILABLE,
256
+ message,
257
+ links = [],
258
+ details = [],
259
+ capturedAt = null,
260
+ } = {}) {
261
+ return buildProviderCard({
262
+ provider,
263
+ label,
264
+ status,
265
+ message,
266
+ links,
267
+ details,
268
+ capturedAt,
269
+ meters: [],
270
+ });
271
+ }
@@ -0,0 +1,421 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GitHub Copilot plan usage.
5
+ *
6
+ * Two independent sources are folded together here:
7
+ * - the quota snapshot the relay already fetched for the per-turn usage
8
+ * deltas (fast, always available with the CLI's own token), which carries
9
+ * the allowance/remaining/reset that the meters need; and
10
+ * - the official REST billing usage report (optional, personal scope), which
11
+ * adds per-model/per-product cost detail but no allowance.
12
+ *
13
+ * The billing report is strictly additive: when the token cannot read billing
14
+ * (the common case for a plain `gh auth token`) the card still renders every
15
+ * meter from the quota snapshot.
16
+ */
17
+
18
+ import {
19
+ SOURCE_CACHE,
20
+ SOURCE_LIVE,
21
+ STATUS_ERROR,
22
+ STATUS_OK,
23
+ STATUS_PARTIAL,
24
+ buildDetailSection,
25
+ buildMeter,
26
+ buildProviderCard,
27
+ buildUnavailableCard,
28
+ pickNonNegative,
29
+ roundCurrency,
30
+ toFiniteNumber,
31
+ toTrimmedString,
32
+ } from './plan-usage-contract.mjs';
33
+
34
+ export const COPILOT_PROVIDER_ID = 'github';
35
+ export const COPILOT_LABEL = 'GitHub Copilot';
36
+
37
+ const BILLING_DOCS_URL = 'https://github.com/settings/billing';
38
+
39
+ /**
40
+ * GitHub renamed the metered unit from "premium requests" to "AI credits"
41
+ * without changing the quota-snapshot field names, so the same numbers must be
42
+ * labelled from whatever the payload says the plan actually meters.
43
+ */
44
+ export function resolvePremiumBucketLabel(summary = {}) {
45
+ const explicitUnit = toTrimmedString(summary?.premiumInteractions?.unit)
46
+ || toTrimmedString(summary?.unit);
47
+ if (explicitUnit) {
48
+ if (/credit/i.test(explicitUnit)) return { label: 'AI credits', unit: 'credits' };
49
+ if (/request|interaction/i.test(explicitUnit)) return { label: 'Premium requests', unit: 'requests' };
50
+ }
51
+ // The AI-credit plans meter in units of $0.01, which produces entitlements in
52
+ // the thousands; the legacy premium-request plans are in the hundreds.
53
+ const entitlement = toFiniteNumber(summary?.premiumInteractions?.entitlement);
54
+ if (entitlement !== null && entitlement >= 1000) return { label: 'AI credits', unit: 'credits' };
55
+ return { label: 'Premium requests', unit: 'requests' };
56
+ }
57
+
58
+ function resetTimestamp(summary) {
59
+ const raw = toTrimmedString(summary?.resetDate);
60
+ if (!raw) return null;
61
+ // The quota API reports a bare calendar day; anchoring it to UTC midnight
62
+ // keeps the countdown honest instead of drifting with the viewer's zone.
63
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw}T00:00:00.000Z`;
64
+ return raw;
65
+ }
66
+
67
+ function formatUnits(value, unit) {
68
+ const numeric = toFiniteNumber(value);
69
+ if (numeric === null) return null;
70
+ if (unit === 'usd') return `$${(Math.round(numeric * 100) / 100).toFixed(2)}`;
71
+ return String(Math.round(numeric * 100) / 100);
72
+ }
73
+
74
+ /**
75
+ * Fold the REST billing `usageItems[]` into per-product and per-model totals.
76
+ * Both the AI-credit report (which carries `model`) and the plain usage report
77
+ * (which does not) are accepted.
78
+ */
79
+ export function summarizeBillingUsageItems(items = []) {
80
+ const rows = Array.isArray(items) ? items : [];
81
+ const byProduct = new Map();
82
+ const byModel = new Map();
83
+ let netAmount = 0;
84
+ let grossAmount = 0;
85
+ let discountAmount = 0;
86
+ let netQuantity = 0;
87
+
88
+ for (const item of rows) {
89
+ const net = toFiniteNumber(item?.netAmount) || 0;
90
+ const gross = toFiniteNumber(item?.grossAmount) || 0;
91
+ const discount = toFiniteNumber(item?.discountAmount) || 0;
92
+ // The AI-credit report uses netQuantity; the plain usage report uses quantity.
93
+ const quantity = toFiniteNumber(item?.netQuantity ?? item?.quantity) || 0;
94
+ netAmount += net;
95
+ grossAmount += gross;
96
+ discountAmount += discount;
97
+ netQuantity += quantity;
98
+
99
+ const product = toTrimmedString(item?.product) || 'Other';
100
+ const productEntry = byProduct.get(product) || { net: 0, quantity: 0 };
101
+ productEntry.net += net;
102
+ productEntry.quantity += quantity;
103
+ byProduct.set(product, productEntry);
104
+
105
+ const model = toTrimmedString(item?.model);
106
+ if (model) {
107
+ const modelEntry = byModel.get(model) || { net: 0, quantity: 0 };
108
+ modelEntry.net += net;
109
+ modelEntry.quantity += quantity;
110
+ byModel.set(model, modelEntry);
111
+ }
112
+ }
113
+
114
+ if (!rows.length) return null;
115
+ const sortByNetDesc = (a, b) => b[1].net - a[1].net || b[1].quantity - a[1].quantity;
116
+ return {
117
+ netAmount: roundCurrency(netAmount),
118
+ grossAmount: roundCurrency(grossAmount),
119
+ discountAmount: roundCurrency(discountAmount),
120
+ netQuantity: Math.round(netQuantity * 1000) / 1000,
121
+ byProduct: Array.from(byProduct.entries()).sort(sortByNetDesc)
122
+ .map(([name, entry]) => ({ name, net: roundCurrency(entry.net), quantity: Math.round(entry.quantity * 1000) / 1000 })),
123
+ byModel: Array.from(byModel.entries()).sort(sortByNetDesc)
124
+ .map(([name, entry]) => ({ name, net: roundCurrency(entry.net), quantity: Math.round(entry.quantity * 1000) / 1000 })),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * GitHub sometimes reports `percent_remaining: 0` with no denominator, which
130
+ * must read as "unknown" rather than "fully consumed" — the same guard the
131
+ * per-turn usage snapshots apply.
132
+ */
133
+ function utilizationFromPercentRemaining(bucket = {}) {
134
+ const percentRemaining = toFiniteNumber(bucket?.percentRemaining);
135
+ if (percentRemaining === null) return null;
136
+ const hasDenominatorContext = toFiniteNumber(bucket?.entitlement) !== null
137
+ || toFiniteNumber(bucket?.remaining) !== null;
138
+ if (!hasDenominatorContext && percentRemaining === 0) return null;
139
+ return 100 - percentRemaining;
140
+ }
141
+
142
+ // One AI credit is 1e9 nano-AIU. The SDK's `assistant.usage.totalNanoAiu` is
143
+ // the only place real per-turn spend appears: `cost` on the same event is the
144
+ // premium MULTIPLIER, not money, and there is no `premiumRequests` field.
145
+ const NANO_AIU_PER_CREDIT = 1_000_000_000;
146
+
147
+ /**
148
+ * A single turn costs a small fraction of a credit, so the card's usual
149
+ * 2-decimal rounding would report every turn as "0". Sub-cent turns get more
150
+ * digits instead of being rounded away.
151
+ */
152
+ function formatCredits(nanoAiu) {
153
+ const numeric = toFiniteNumber(nanoAiu);
154
+ if (numeric === null) return null;
155
+ const credits = numeric / NANO_AIU_PER_CREDIT;
156
+ return credits >= 0.01 ? credits.toFixed(2) : credits.toFixed(6);
157
+ }
158
+
159
+ /**
160
+ * The numeric fields the SDK worker reports, declared ONCE.
161
+ *
162
+ * Both the normalizer and its "is there anything usable here?" check read this
163
+ * list, so a field can never be normalized-but-not-accepted. That drift is not
164
+ * hypothetical: `subagentModelCalls` was normalized while the acceptance check
165
+ * listed the other six, so a payload carrying only that field was rejected with
166
+ * a 400 by a route that had already understood it.
167
+ *
168
+ * Each entry reads its own value out of the post's three numeric homes, because
169
+ * they are nested differently and the flat output shape hides that.
170
+ */
171
+ const COPILOT_WORKER_USAGE_NUMBERS = Object.freeze([
172
+ Object.freeze({ key: 'totalNanoAiu', read: (usage) => usage.totalNanoAiu }),
173
+ Object.freeze({ key: 'inputTokens', read: (usage) => usage.inputTokens }),
174
+ Object.freeze({ key: 'outputTokens', read: (usage) => usage.outputTokens }),
175
+ Object.freeze({ key: 'modelCalls', read: (usage) => usage.modelCalls }),
176
+ Object.freeze({ key: 'subagentModelCalls', read: (usage) => usage.subagentModelCalls }),
177
+ Object.freeze({ key: 'contextTokens', read: (usage, contextUsage) => contextUsage.currentTokens }),
178
+ // `account.getQuota()` reads a stale cache and never shows overage; the quota
179
+ // snapshot riding the usage event is the only place it appears.
180
+ Object.freeze({ key: 'cfiOverage', read: (usage, contextUsage, quotas) => quotas.cfi_overage }),
181
+ ]);
182
+
183
+ /**
184
+ * Normalise the SDK worker's per-turn usage post into the snapshot stored under
185
+ * the `copilot-sdk` provider key.
186
+ *
187
+ * Kept deliberately narrow: only the fields the card can show, plus the ids
188
+ * needed to say which turn they came from. Returns null for a payload with
189
+ * nothing usable in it, which is what makes the route's 400 possible.
190
+ *
191
+ * Every count is clamped non-negative. These arrive from a worker over HTTP and
192
+ * are rendered as spend and token totals, so a negative is either a bug or a
193
+ * poster trying to write nonsense onto a shared card; "not reported" is the
194
+ * honest reading of it either way.
195
+ */
196
+ export function normalizeCopilotWorkerUsage(raw) {
197
+ if (!raw || typeof raw !== 'object') return null;
198
+ const usage = raw.usage && typeof raw.usage === 'object' ? raw.usage : {};
199
+ const contextUsage = raw.contextUsage && typeof raw.contextUsage === 'object' ? raw.contextUsage : {};
200
+ const quotaSnapshots = usage.quotaSnapshots && typeof usage.quotaSnapshots === 'object'
201
+ ? usage.quotaSnapshots
202
+ : {};
203
+ const normalized = {
204
+ conversationId: toTrimmedString(raw.conversationId),
205
+ messageId: toTrimmedString(raw.messageId),
206
+ model: toTrimmedString(raw.model),
207
+ capturedAt: toTrimmedString(raw.capturedAt),
208
+ };
209
+ let hasNumbers = false;
210
+ for (const field of COPILOT_WORKER_USAGE_NUMBERS) {
211
+ const value = pickNonNegative(field.read(usage, contextUsage, quotaSnapshots));
212
+ normalized[field.key] = value;
213
+ if (value !== null) hasNumbers = true;
214
+ }
215
+ return hasNumbers ? normalized : null;
216
+ }
217
+
218
+ /**
219
+ * How long a "last SDK worker turn" snapshot stays worth showing.
220
+ *
221
+ * The snapshot is written per turn and never expires on its own, so without a
222
+ * cutoff a card would keep presenting one turn's numbers as current forever —
223
+ * including after the relay is switched back to the extension engine, where no
224
+ * new snapshot will ever arrive to replace it. A week is long enough to survive
225
+ * a holiday and short enough that nothing on the card is quietly ancient.
226
+ */
227
+ export const COPILOT_WORKER_USAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
228
+
229
+ /**
230
+ * "3 hours ago" for a timestamp, falling back to the raw ISO string when it is
231
+ * unparseable — an odd-looking timestamp is still more informative than
232
+ * dropping the age entirely, and it is the shape a reader can report back.
233
+ */
234
+ function formatSnapshotAge(capturedAt, now) {
235
+ const text = toTrimmedString(capturedAt);
236
+ if (!text) return null;
237
+ const parsed = Date.parse(text);
238
+ if (Number.isNaN(parsed)) return text;
239
+ const ageMs = now - parsed;
240
+ // A worker clock slightly ahead of the relay's must not render "-1 minutes".
241
+ if (ageMs < 60_000) return 'just now';
242
+ const minutes = Math.floor(ageMs / 60_000);
243
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
244
+ const hours = Math.floor(minutes / 60);
245
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
246
+ const days = Math.floor(hours / 24);
247
+ return `${days} day${days === 1 ? '' : 's'} ago`;
248
+ }
249
+
250
+ /**
251
+ * The card section for the last SDK-worker turn.
252
+ *
253
+ * Additive by design: the meters above come from the account-level quota API
254
+ * and are already correct for SDK sessions (they spend the same account's
255
+ * allowance), so this only adds the per-turn detail no relay-side source can
256
+ * see. Absent — extension engine, no turn yet, or a snapshot older than
257
+ * `COPILOT_WORKER_USAGE_MAX_AGE_MS` — the card renders exactly as it did before.
258
+ *
259
+ * The section always says WHEN it was captured. These are one turn's numbers,
260
+ * not a running total, and an undated row of token counts sitting under live
261
+ * meters reads as though it were live too.
262
+ */
263
+ export function buildCopilotWorkerUsageSection(usage, { now = Date.now() } = {}) {
264
+ if (!usage || typeof usage !== 'object') return null;
265
+ const capturedAt = toTrimmedString(usage.capturedAt);
266
+ const capturedMs = capturedAt ? Date.parse(capturedAt) : Number.NaN;
267
+ // An unparseable or missing timestamp is NOT treated as expired: the numbers
268
+ // are still real, and the note degrades to whatever the payload said.
269
+ if (!Number.isNaN(capturedMs) && now - capturedMs > COPILOT_WORKER_USAGE_MAX_AGE_MS) return null;
270
+ const rows = [
271
+ { label: 'AI credits', value: formatCredits(usage.totalNanoAiu) },
272
+ { label: 'Input tokens', value: formatUnits(usage.inputTokens) },
273
+ { label: 'Output tokens', value: formatUnits(usage.outputTokens) },
274
+ { label: 'Context tokens', value: formatUnits(usage.contextTokens) },
275
+ { label: 'Model calls', value: formatUnits(usage.modelCalls) },
276
+ { label: 'Overage', value: formatUnits(usage.cfiOverage) },
277
+ ].filter((row) => row.value !== null && row.value !== undefined);
278
+ const model = toTrimmedString(usage.model);
279
+ const age = formatSnapshotAge(capturedAt, now);
280
+ const note = [model ? `Model ${model}` : null, age ? `as of ${age}` : null]
281
+ .filter(Boolean)
282
+ .join(' · ');
283
+ return buildDetailSection({
284
+ id: 'copilot-sdk-last-turn',
285
+ label: 'Last SDK worker turn',
286
+ note: note || null,
287
+ rows,
288
+ });
289
+ }
290
+
291
+ /**
292
+ * @param {object} args
293
+ * @param {object|null} args.summary the quota snapshot from `fetchUsageSummary`
294
+ * @param {object|null} args.billing `{ items, timePeriod, scope, error }` or null
295
+ * @param {object|null} args.workerUsage the SDK worker's last-turn snapshot
296
+ * @param {number} [args.now] clock for ageing `workerUsage`; injected by tests
297
+ */
298
+ export function buildCopilotPlanCard({
299
+ summary = null,
300
+ billing = null,
301
+ workerUsage = null,
302
+ error = null,
303
+ capturedAt = new Date().toISOString(),
304
+ now = Date.now(),
305
+ stale = false,
306
+ } = {}) {
307
+ if (!summary) {
308
+ return buildUnavailableCard({
309
+ provider: COPILOT_PROVIDER_ID,
310
+ label: COPILOT_LABEL,
311
+ status: STATUS_ERROR,
312
+ message: toTrimmedString(error) || 'Copilot quota is unavailable.',
313
+ capturedAt,
314
+ links: [{ label: 'GitHub billing settings', url: BILLING_DOCS_URL }],
315
+ });
316
+ }
317
+
318
+ const resetAt = resetTimestamp(summary);
319
+ const premiumBucket = resolvePremiumBucketLabel(summary);
320
+ const meters = [];
321
+
322
+ const premium = summary?.premiumInteractions || {};
323
+ meters.push(buildMeter({
324
+ id: 'copilot-premium',
325
+ label: premiumBucket.label,
326
+ unit: premiumBucket.unit,
327
+ unlimited: premium?.unlimited === true,
328
+ remaining: premium?.remaining,
329
+ allowance: premium?.entitlement,
330
+ utilization: utilizationFromPercentRemaining(premium),
331
+ resetAt,
332
+ }));
333
+
334
+ const plan = summary?.planQuota || {};
335
+ meters.push(buildMeter({
336
+ id: 'copilot-plan',
337
+ label: 'Plan quota',
338
+ unit: premiumBucket.unit,
339
+ unlimited: plan?.unlimited === true,
340
+ remaining: plan?.remaining,
341
+ allowance: plan?.entitlement,
342
+ utilization: utilizationFromPercentRemaining(plan),
343
+ resetAt,
344
+ }));
345
+
346
+ const chat = summary?.chat || {};
347
+ meters.push(buildMeter({
348
+ id: 'copilot-chat',
349
+ label: 'Chat & completions',
350
+ unit: 'requests',
351
+ unlimited: chat?.unlimited === true,
352
+ remaining: chat?.remaining,
353
+ allowance: chat?.entitlement,
354
+ utilization: utilizationFromPercentRemaining(chat),
355
+ resetAt,
356
+ emphasis: 'secondary',
357
+ note: chat?.unlimited === true ? 'Included with your plan' : null,
358
+ }));
359
+
360
+ const details = [];
361
+ const billingSummary = billing?.items ? summarizeBillingUsageItems(billing.items) : null;
362
+ if (billingSummary) {
363
+ const period = billing?.timePeriod || {};
364
+ const periodLabel = [period?.year, period?.month, period?.day].filter(Boolean).join('-');
365
+ details.push(buildDetailSection({
366
+ id: 'copilot-billing-totals',
367
+ label: 'Billed usage this period',
368
+ note: periodLabel ? `Reporting period ${periodLabel}` : null,
369
+ rows: [
370
+ { label: 'Billed (net)', value: formatUnits(billingSummary.netAmount, 'usd') },
371
+ { label: 'Gross', value: formatUnits(billingSummary.grossAmount, 'usd') },
372
+ { label: 'Included / discounted', value: formatUnits(billingSummary.discountAmount, 'usd') },
373
+ { label: 'Billable quantity', value: formatUnits(billingSummary.netQuantity) },
374
+ ],
375
+ }));
376
+ if (billingSummary.byModel.length) {
377
+ details.push(buildDetailSection({
378
+ id: 'copilot-billing-models',
379
+ label: 'By model',
380
+ rows: billingSummary.byModel.slice(0, 12).map((entry) => ({
381
+ label: entry.name,
382
+ value: formatUnits(entry.net, 'usd') ?? '—',
383
+ hint: `${formatUnits(entry.quantity)} units`,
384
+ })),
385
+ }));
386
+ }
387
+ if (billingSummary.byProduct.length) {
388
+ details.push(buildDetailSection({
389
+ id: 'copilot-billing-products',
390
+ label: 'By product',
391
+ rows: billingSummary.byProduct.slice(0, 12).map((entry) => ({
392
+ label: entry.name,
393
+ value: formatUnits(entry.net, 'usd') ?? '—',
394
+ hint: `${formatUnits(entry.quantity)} units`,
395
+ })),
396
+ }));
397
+ }
398
+ }
399
+
400
+ const workerSection = buildCopilotWorkerUsageSection(workerUsage, { now });
401
+ if (workerSection) details.push(workerSection);
402
+
403
+ const billingError = toTrimmedString(billing?.error);
404
+ const resolvedMeters = meters.filter(Boolean);
405
+ return buildProviderCard({
406
+ provider: COPILOT_PROVIDER_ID,
407
+ label: COPILOT_LABEL,
408
+ status: resolvedMeters.length ? (billingError ? STATUS_PARTIAL : STATUS_OK) : STATUS_PARTIAL,
409
+ planName: toTrimmedString(summary?.plan),
410
+ accountLabel: toTrimmedString(billing?.scope),
411
+ message: billingError
412
+ ? `Detailed billing data unavailable: ${billingError}`
413
+ : null,
414
+ source: stale ? SOURCE_CACHE : SOURCE_LIVE,
415
+ stale,
416
+ capturedAt,
417
+ meters: resolvedMeters,
418
+ details,
419
+ links: [{ label: 'GitHub billing settings', url: BILLING_DOCS_URL }],
420
+ });
421
+ }