@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.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- 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
|
+
}
|