@adecore/agents 0.0.1 → 0.17.0-beta.2
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/LICENSE +110 -0
- package/README.md +48 -0
- package/dist/async.d.ts +2 -0
- package/dist/async.js +31 -0
- package/dist/chat/attachment-store.d.ts +10 -0
- package/dist/chat/attachment-store.js +93 -0
- package/dist/chat/backend.d.ts +214 -0
- package/dist/chat/backend.js +1 -0
- package/dist/chat/background-work.d.ts +8 -0
- package/dist/chat/background-work.js +33 -0
- package/dist/chat/bookmark-store.d.ts +25 -0
- package/dist/chat/bookmark-store.js +141 -0
- package/dist/chat/chat-core.d.ts +311 -0
- package/dist/chat/chat-core.js +1025 -0
- package/dist/chat/chat-log.d.ts +33 -0
- package/dist/chat/chat-log.js +163 -0
- package/dist/chat/chat-process.d.ts +46 -0
- package/dist/chat/chat-process.js +190 -0
- package/dist/chat/chat-session.d.ts +209 -0
- package/dist/chat/chat-session.js +1511 -0
- package/dist/chat/chat-store.d.ts +37 -0
- package/dist/chat/chat-store.js +202 -0
- package/dist/chat/chat-title.d.ts +10 -0
- package/dist/chat/chat-title.js +74 -0
- package/dist/chat/claude-backend.d.ts +35 -0
- package/dist/chat/claude-backend.js +214 -0
- package/dist/chat/claude-protocol.d.ts +41 -0
- package/dist/chat/claude-protocol.js +642 -0
- package/dist/chat/claude-title.d.ts +11 -0
- package/dist/chat/claude-title.js +97 -0
- package/dist/chat/claude-transcript.d.ts +28 -0
- package/dist/chat/claude-transcript.js +211 -0
- package/dist/chat/codex-backend.d.ts +51 -0
- package/dist/chat/codex-backend.js +364 -0
- package/dist/chat/codex-protocol.d.ts +56 -0
- package/dist/chat/codex-protocol.js +767 -0
- package/dist/chat/codex-thread.d.ts +32 -0
- package/dist/chat/codex-thread.js +131 -0
- package/dist/chat/codex-transport.d.ts +42 -0
- package/dist/chat/codex-transport.js +188 -0
- package/dist/chat/composer-preferences.d.ts +15 -0
- package/dist/chat/composer-preferences.js +46 -0
- package/dist/chat/context-breakdown.d.ts +2 -0
- package/dist/chat/context-breakdown.js +74 -0
- package/dist/chat/delta-coalescer.d.ts +12 -0
- package/dist/chat/delta-coalescer.js +55 -0
- package/dist/chat/errors.d.ts +4 -0
- package/dist/chat/errors.js +4 -0
- package/dist/chat/fake-claude.d.ts +3 -0
- package/dist/chat/fake-claude.js +982 -0
- package/dist/chat/fake-cli.d.ts +25 -0
- package/dist/chat/fake-cli.js +157 -0
- package/dist/chat/fake-codex.d.ts +10 -0
- package/dist/chat/fake-codex.js +502 -0
- package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/dist/chat/input.d.ts +19 -0
- package/dist/chat/input.js +71 -0
- package/dist/chat/limit-resume.d.ts +11 -0
- package/dist/chat/limit-resume.js +68 -0
- package/dist/chat/projector.d.ts +60 -0
- package/dist/chat/projector.js +860 -0
- package/dist/chat/request-summary.d.ts +3 -0
- package/dist/chat/request-summary.js +143 -0
- package/dist/chat/subagent-projection.d.ts +8 -0
- package/dist/chat/subagent-projection.js +40 -0
- package/dist/chat/subagent-reader.d.ts +66 -0
- package/dist/chat/subagent-reader.js +396 -0
- package/dist/chat/subagent-settlement.d.ts +6 -0
- package/dist/chat/subagent-settlement.js +103 -0
- package/dist/chat/thread.d.ts +30 -0
- package/dist/chat/thread.js +147 -0
- package/dist/chat/wake-chat.d.ts +20 -0
- package/dist/chat/wake-chat.js +27 -0
- package/dist/client-sinks.d.ts +9 -0
- package/dist/client-sinks.js +34 -0
- package/dist/coded-error.d.ts +5 -0
- package/dist/coded-error.js +17 -0
- package/dist/context/argv.d.ts +11 -0
- package/dist/context/argv.js +58 -0
- package/dist/context/refusal.d.ts +9 -0
- package/dist/context/refusal.js +38 -0
- package/dist/context/verb.d.ts +85 -0
- package/dist/context/verb.js +233 -0
- package/dist/error-text.d.ts +5 -0
- package/dist/error-text.js +24 -0
- package/dist/events.d.ts +12 -0
- package/dist/events.js +1 -0
- package/dist/fs.d.ts +9 -0
- package/dist/fs.js +107 -0
- package/dist/host/agent-host.d.ts +28 -0
- package/dist/host/agent-host.js +105 -0
- package/dist/host/environment.d.ts +7 -0
- package/dist/host/environment.js +22 -0
- package/dist/host/handlers.d.ts +30 -0
- package/dist/host/handlers.js +111 -0
- package/dist/host/memory-port.d.ts +2 -0
- package/dist/host/memory-port.js +25 -0
- package/dist/host/wiring.d.ts +40 -0
- package/dist/host/wiring.js +120 -0
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.js +134 -0
- package/dist/messages/deliver-message.d.ts +21 -0
- package/dist/messages/deliver-message.js +42 -0
- package/dist/messages/deliver-notice.d.ts +25 -0
- package/dist/messages/deliver-notice.js +75 -0
- package/dist/messages/notice-notes.d.ts +15 -0
- package/dist/messages/notice-notes.js +26 -0
- package/dist/messages/notice-store.d.ts +30 -0
- package/dist/messages/notice-store.js +120 -0
- package/dist/modes.d.ts +11 -0
- package/dist/modes.js +28 -0
- package/dist/outbox/manual-clock.d.ts +10 -0
- package/dist/outbox/manual-clock.js +25 -0
- package/dist/outbox/outbox-worker.d.ts +44 -0
- package/dist/outbox/outbox-worker.js +173 -0
- package/dist/outbox/outbox.d.ts +37 -0
- package/dist/outbox/outbox.js +76 -0
- package/dist/providers/accounts/accounts.d.ts +20 -0
- package/dist/providers/accounts/accounts.js +158 -0
- package/dist/providers/accounts/launch.d.ts +17 -0
- package/dist/providers/accounts/launch.js +30 -0
- package/dist/providers/accounts/service.d.ts +87 -0
- package/dist/providers/accounts/service.js +538 -0
- package/dist/providers/accounts/shadow-home.d.ts +8 -0
- package/dist/providers/accounts/shadow-home.js +70 -0
- package/dist/providers/accounts/status.d.ts +12 -0
- package/dist/providers/accounts/status.js +85 -0
- package/dist/providers/accounts/store.d.ts +14 -0
- package/dist/providers/accounts/store.js +112 -0
- package/dist/providers/accounts/test-accounts.d.ts +12 -0
- package/dist/providers/accounts/test-accounts.js +38 -0
- package/dist/providers/accounts/variables.d.ts +27 -0
- package/dist/providers/accounts/variables.js +94 -0
- package/dist/providers/catalog.d.ts +17 -0
- package/dist/providers/catalog.js +76 -0
- package/dist/providers/claude-models.json +114 -0
- package/dist/providers/claude-provider.d.ts +4 -0
- package/dist/providers/claude-provider.js +29 -0
- package/dist/providers/claude.d.ts +15 -0
- package/dist/providers/claude.js +105 -0
- package/dist/providers/codex-models.json +135 -0
- package/dist/providers/codex-provider.d.ts +4 -0
- package/dist/providers/codex-provider.js +27 -0
- package/dist/providers/codex-rules.d.ts +9 -0
- package/dist/providers/codex-rules.js +35 -0
- package/dist/providers/codex.d.ts +14 -0
- package/dist/providers/codex.js +52 -0
- package/dist/providers/detect.d.ts +6 -0
- package/dist/providers/detect.js +30 -0
- package/dist/providers/provider.d.ts +25 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/registry.d.ts +28 -0
- package/dist/providers/registry.js +118 -0
- package/dist/record-directory.d.ts +27 -0
- package/dist/record-directory.js +114 -0
- package/dist/run-process.d.ts +13 -0
- package/dist/run-process.js +42 -0
- package/dist/serializer.d.ts +9 -0
- package/dist/serializer.js +34 -0
- package/dist/skills.d.ts +21 -0
- package/dist/skills.js +174 -0
- package/dist/tasks/background-limit.d.ts +16 -0
- package/dist/tasks/background-limit.js +55 -0
- package/dist/tasks/end-children.d.ts +34 -0
- package/dist/tasks/end-children.js +53 -0
- package/dist/tasks/give-task.d.ts +14 -0
- package/dist/tasks/give-task.js +33 -0
- package/dist/tasks/task-coordinator.d.ts +52 -0
- package/dist/tasks/task-coordinator.js +323 -0
- package/dist/tasks/task-store.d.ts +49 -0
- package/dist/tasks/task-store.js +197 -0
- package/dist/tasks/task-work.d.ts +47 -0
- package/dist/tasks/task-work.js +28 -0
- package/dist/tasks/waiting-child.d.ts +51 -0
- package/dist/tasks/waiting-child.js +143 -0
- package/dist/tasks/wake-parent.d.ts +19 -0
- package/dist/tasks/wake-parent.js +109 -0
- package/dist/tasks/wiring.d.ts +51 -0
- package/dist/tasks/wiring.js +151 -0
- package/dist/title-file.d.ts +2 -0
- package/dist/title-file.js +55 -0
- package/dist/usage/accounts.d.ts +15 -0
- package/dist/usage/accounts.js +64 -0
- package/dist/usage/aggregate.d.ts +12 -0
- package/dist/usage/aggregate.js +154 -0
- package/dist/usage/exchange.d.ts +16 -0
- package/dist/usage/exchange.js +84 -0
- package/dist/usage/index-file.d.ts +15 -0
- package/dist/usage/index-file.js +123 -0
- package/dist/usage/limits/monitor.d.ts +50 -0
- package/dist/usage/limits/monitor.js +179 -0
- package/dist/usage/limits/normalize.d.ts +21 -0
- package/dist/usage/limits/normalize.js +160 -0
- package/dist/usage/limits/probe.d.ts +9 -0
- package/dist/usage/limits/probe.js +113 -0
- package/dist/usage/prices-snapshot.json +1071 -0
- package/dist/usage/pricing.d.ts +34 -0
- package/dist/usage/pricing.js +186 -0
- package/dist/usage/projects.d.ts +17 -0
- package/dist/usage/projects.js +49 -0
- package/dist/usage/readers/claude.d.ts +3 -0
- package/dist/usage/readers/claude.js +59 -0
- package/dist/usage/readers/codex.d.ts +23 -0
- package/dist/usage/readers/codex.js +155 -0
- package/dist/usage/record.d.ts +15 -0
- package/dist/usage/record.js +39 -0
- package/dist/usage/roots.d.ts +12 -0
- package/dist/usage/roots.js +29 -0
- package/dist/usage/scanner.d.ts +30 -0
- package/dist/usage/scanner.js +234 -0
- package/dist/usage/usage-service.d.ts +35 -0
- package/dist/usage/usage-service.js +130 -0
- package/dist/watch-seam.d.ts +29 -0
- package/dist/watch-seam.js +78 -0
- package/dist/watch-test-helpers.d.ts +28 -0
- package/dist/watch-test-helpers.js +86 -0
- package/package.json +42 -3
- package/src/async.ts +40 -0
- package/src/chat/attachment-store.ts +108 -0
- package/src/chat/backend.ts +199 -0
- package/src/chat/background-work.ts +42 -0
- package/src/chat/bookmark-store.ts +166 -0
- package/src/chat/chat-core.ts +1228 -0
- package/src/chat/chat-log.ts +187 -0
- package/src/chat/chat-process.ts +237 -0
- package/src/chat/chat-session.ts +1723 -0
- package/src/chat/chat-store.ts +261 -0
- package/src/chat/chat-title.ts +90 -0
- package/src/chat/claude-backend.ts +235 -0
- package/src/chat/claude-protocol.ts +684 -0
- package/src/chat/claude-title.ts +106 -0
- package/src/chat/claude-transcript.ts +233 -0
- package/src/chat/codex-backend.ts +397 -0
- package/src/chat/codex-protocol.ts +872 -0
- package/src/chat/codex-thread.ts +162 -0
- package/src/chat/codex-transport.ts +224 -0
- package/src/chat/composer-preferences.ts +67 -0
- package/src/chat/context-breakdown.ts +80 -0
- package/src/chat/delta-coalescer.ts +62 -0
- package/src/chat/errors.ts +25 -0
- package/src/chat/fake-claude.ts +1006 -0
- package/src/chat/fake-cli.ts +199 -0
- package/src/chat/fake-codex.ts +534 -0
- package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/src/chat/input.ts +89 -0
- package/src/chat/limit-resume.ts +74 -0
- package/src/chat/projector.ts +969 -0
- package/src/chat/request-summary.ts +168 -0
- package/src/chat/subagent-projection.ts +45 -0
- package/src/chat/subagent-reader.ts +469 -0
- package/src/chat/subagent-settlement.ts +111 -0
- package/src/chat/thread.ts +167 -0
- package/src/chat/wake-chat.ts +45 -0
- package/src/client-sinks.ts +39 -0
- package/src/coded-error.ts +18 -0
- package/src/context/argv.ts +61 -0
- package/src/context/refusal.ts +50 -0
- package/src/context/verb.ts +392 -0
- package/src/error-text.ts +28 -0
- package/src/events.ts +9 -0
- package/src/fs.ts +118 -0
- package/src/host/agent-host.ts +124 -0
- package/src/host/environment.ts +31 -0
- package/src/host/handlers.ts +142 -0
- package/src/host/memory-port.ts +27 -0
- package/src/host/wiring.ts +164 -0
- package/src/lineage.ts +159 -0
- package/src/messages/deliver-message.ts +61 -0
- package/src/messages/deliver-notice.ts +120 -0
- package/src/messages/notice-notes.ts +34 -0
- package/src/messages/notice-store.ts +142 -0
- package/src/modes.ts +38 -0
- package/src/outbox/manual-clock.ts +31 -0
- package/src/outbox/outbox-worker.ts +214 -0
- package/src/outbox/outbox.ts +106 -0
- package/src/providers/accounts/accounts.ts +192 -0
- package/src/providers/accounts/launch.ts +55 -0
- package/src/providers/accounts/service.ts +632 -0
- package/src/providers/accounts/shadow-home.ts +85 -0
- package/src/providers/accounts/status.ts +106 -0
- package/src/providers/accounts/store.ts +158 -0
- package/src/providers/accounts/test-accounts.ts +48 -0
- package/src/providers/accounts/variables.ts +132 -0
- package/src/providers/catalog.ts +90 -0
- package/src/providers/claude-models.json +114 -0
- package/src/providers/claude-provider.ts +32 -0
- package/src/providers/claude.ts +123 -0
- package/src/providers/codex-models.json +135 -0
- package/src/providers/codex-provider.ts +30 -0
- package/src/providers/codex-rules.ts +54 -0
- package/src/providers/codex.ts +69 -0
- package/src/providers/detect.ts +40 -0
- package/src/providers/provider.ts +46 -0
- package/src/providers/registry.ts +150 -0
- package/src/record-directory.ts +136 -0
- package/src/run-process.ts +61 -0
- package/src/serializer.ts +41 -0
- package/src/skills.ts +199 -0
- package/src/tasks/background-limit.ts +72 -0
- package/src/tasks/end-children.ts +100 -0
- package/src/tasks/give-task.ts +52 -0
- package/src/tasks/task-coordinator.ts +384 -0
- package/src/tasks/task-store.ts +236 -0
- package/src/tasks/task-work.ts +62 -0
- package/src/tasks/waiting-child.ts +203 -0
- package/src/tasks/wake-parent.ts +142 -0
- package/src/tasks/wiring.ts +231 -0
- package/src/title-file.ts +57 -0
- package/src/usage/accounts.ts +86 -0
- package/src/usage/aggregate.ts +220 -0
- package/src/usage/exchange.ts +93 -0
- package/src/usage/index-file.ts +178 -0
- package/src/usage/limits/monitor.ts +239 -0
- package/src/usage/limits/normalize.ts +188 -0
- package/src/usage/limits/probe.ts +120 -0
- package/src/usage/prices-snapshot.json +1071 -0
- package/src/usage/pricing.ts +236 -0
- package/src/usage/projects.ts +65 -0
- package/src/usage/readers/claude.ts +60 -0
- package/src/usage/readers/codex.ts +196 -0
- package/src/usage/record.ts +60 -0
- package/src/usage/roots.ts +39 -0
- package/src/usage/scanner.ts +265 -0
- package/src/usage/usage-service.ts +161 -0
- package/src/watch-seam.ts +111 -0
- package/src/watch-test-helpers.ts +102 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { UsageBucket, UsageModel, UsageProject, UsageSummaryPayload } from '@adecore/agent-contracts';
|
|
2
|
+
import { type PriceBook } from './pricing.ts';
|
|
3
|
+
import { type KnownProject } from './projects.ts';
|
|
4
|
+
import type { UsageRecord } from './record.ts';
|
|
5
|
+
export interface Aggregation {
|
|
6
|
+
buckets: UsageBucket[];
|
|
7
|
+
models: UsageModel[];
|
|
8
|
+
projects: UsageProject[];
|
|
9
|
+
sessions: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function accountOfRecord(record: UsageRecord): string;
|
|
12
|
+
export declare function aggregate(records: readonly UsageRecord[], payload: UsageSummaryPayload, prices: PriceBook, known: readonly KnownProject[]): Promise<Aggregation>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { addTotals, EMPTY_TOTALS, totalTokensOf } from '@adecore/agent-contracts';
|
|
2
|
+
import { cacheSavingsOf, costOf } from './pricing.js';
|
|
3
|
+
import { ProjectResolver } from './projects.js';
|
|
4
|
+
/*
|
|
5
|
+
* The calendar day a moment falls in, as the viewer's clock reads it. The host may stand in
|
|
6
|
+
* another zone than the person looking, so the zone travels with the request and every boundary
|
|
7
|
+
* here is local wall clock, never UTC.
|
|
8
|
+
*/
|
|
9
|
+
function slotFormatter(timeZone, resolution) {
|
|
10
|
+
const options = resolution === 'hour'
|
|
11
|
+
? { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' }
|
|
12
|
+
: { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' };
|
|
13
|
+
try {
|
|
14
|
+
return new Intl.DateTimeFormat('en-CA', options);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// A zone this machine does not know degrades to UTC rather than failing the whole request.
|
|
18
|
+
return new Intl.DateTimeFormat('en-CA', { ...options, timeZone: 'UTC' });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/* `2026-09-10` for a day, `2026-09-10T14` for an hour: the day is always the first ten characters. */
|
|
22
|
+
function slotOf(format, timestampMs, resolution) {
|
|
23
|
+
const parts = format.formatToParts(new Date(timestampMs));
|
|
24
|
+
const at = (type) => parts.find((part) => part.type === type)?.value ?? '';
|
|
25
|
+
const day = `${at('year')}-${at('month')}-${at('day')}`;
|
|
26
|
+
return resolution === 'hour' ? `${day}T${at('hour')}` : day;
|
|
27
|
+
}
|
|
28
|
+
function emptyTotals() {
|
|
29
|
+
return { ...EMPTY_TOTALS };
|
|
30
|
+
}
|
|
31
|
+
/* The account a record was made under, the default one under its CLI's kind. */
|
|
32
|
+
export function accountOfRecord(record) {
|
|
33
|
+
return record.account ?? record.provider;
|
|
34
|
+
}
|
|
35
|
+
/*
|
|
36
|
+
* Every record of the period folded three ways at once: per slot for the chart, per model for the
|
|
37
|
+
* breakdown and its price basis, and per folder for the projects. The three cannot be derived from
|
|
38
|
+
* one another without a bucket per slot times model times project, which is a table nobody reads.
|
|
39
|
+
*/
|
|
40
|
+
export async function aggregate(records, payload, prices, known) {
|
|
41
|
+
const format = slotFormatter(payload.timeZone, payload.resolution);
|
|
42
|
+
const buckets = new Map();
|
|
43
|
+
const models = new Map();
|
|
44
|
+
/* The basis of the first record of a model prices the whole row: a model has one price. */
|
|
45
|
+
const modelBasis = new Map();
|
|
46
|
+
const projects = new Map();
|
|
47
|
+
const sessions = new Set();
|
|
48
|
+
const resolver = new ProjectResolver(known);
|
|
49
|
+
const folders = new Map();
|
|
50
|
+
// A filter splits buckets and models per account; without one they fold the way they did before accounts.
|
|
51
|
+
const only = payload.accounts === undefined ? null : new Set(payload.accounts);
|
|
52
|
+
for (const record of records) {
|
|
53
|
+
const account = only === null ? null : accountOfRecord(record);
|
|
54
|
+
if (only !== null && !only.has(account)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const slot = slotOf(format, record.timestampMs, payload.resolution);
|
|
58
|
+
const day = slot.slice(0, 10);
|
|
59
|
+
if (day < payload.from || day > payload.to) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const { price, basis, pricedAs } = prices.look(record.model);
|
|
63
|
+
const cost = price === null ? 0 : costOf(record.totals, price);
|
|
64
|
+
const bucketKey = `${slot}\0${record.provider}\0${record.model}\0${account ?? ''}`;
|
|
65
|
+
let bucket = buckets.get(bucketKey);
|
|
66
|
+
if (bucket === undefined) {
|
|
67
|
+
bucket = {
|
|
68
|
+
slot,
|
|
69
|
+
provider: record.provider,
|
|
70
|
+
model: record.model,
|
|
71
|
+
account,
|
|
72
|
+
totals: emptyTotals(),
|
|
73
|
+
costUsd: 0,
|
|
74
|
+
priced: false,
|
|
75
|
+
cacheSavingsUsd: 0,
|
|
76
|
+
sessions: new Set()
|
|
77
|
+
};
|
|
78
|
+
buckets.set(bucketKey, bucket);
|
|
79
|
+
}
|
|
80
|
+
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
81
|
+
bucket.costUsd += cost;
|
|
82
|
+
bucket.priced ||= price !== null;
|
|
83
|
+
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
84
|
+
if (record.sessionId !== '') {
|
|
85
|
+
bucket.sessions.add(record.sessionId);
|
|
86
|
+
sessions.add(`${record.provider}\0${record.sessionId}`);
|
|
87
|
+
}
|
|
88
|
+
const modelKey = `${record.provider}\0${record.model}\0${account ?? ''}`;
|
|
89
|
+
let model = models.get(modelKey);
|
|
90
|
+
if (model === undefined) {
|
|
91
|
+
model = { provider: record.provider, model: record.model, account, totals: emptyTotals(), costUsd: 0, priced: false };
|
|
92
|
+
models.set(modelKey, model);
|
|
93
|
+
modelBasis.set(modelKey, { basis, pricedAs });
|
|
94
|
+
}
|
|
95
|
+
model.totals = addTotals(model.totals, record.totals);
|
|
96
|
+
model.costUsd += cost;
|
|
97
|
+
model.priced ||= price !== null;
|
|
98
|
+
let folder = folders.get(record.cwd);
|
|
99
|
+
if (folder === undefined) {
|
|
100
|
+
const resolved = await resolver.resolve(record.cwd);
|
|
101
|
+
folder = resolved.folder;
|
|
102
|
+
folders.set(record.cwd, folder);
|
|
103
|
+
if (!projects.has(folder)) {
|
|
104
|
+
projects.set(folder, { folder, name: resolved.name, projectId: resolved.projectId, byProvider: new Map(), totals: emptyTotals(), costUsd: 0 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const project = projects.get(folder);
|
|
108
|
+
project.totals = addTotals(project.totals, record.totals);
|
|
109
|
+
project.costUsd += cost;
|
|
110
|
+
const share = project.byProvider.get(record.provider) ?? { costUsd: 0, tokens: 0 };
|
|
111
|
+
share.costUsd += cost;
|
|
112
|
+
share.tokens += totalTokensOf(record.totals);
|
|
113
|
+
project.byProvider.set(record.provider, share);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
buckets: [...buckets.values()]
|
|
117
|
+
.map((bucket) => ({
|
|
118
|
+
slot: bucket.slot,
|
|
119
|
+
provider: bucket.provider,
|
|
120
|
+
model: bucket.model,
|
|
121
|
+
...(bucket.account === null ? {} : { account: bucket.account }),
|
|
122
|
+
totals: bucket.totals,
|
|
123
|
+
costUsd: bucket.priced ? bucket.costUsd : null,
|
|
124
|
+
cacheSavingsUsd: bucket.cacheSavingsUsd,
|
|
125
|
+
sessions: bucket.sessions.size
|
|
126
|
+
}))
|
|
127
|
+
.sort((a, b) => a.slot.localeCompare(b.slot) ||
|
|
128
|
+
a.provider.localeCompare(b.provider) ||
|
|
129
|
+
a.model.localeCompare(b.model) ||
|
|
130
|
+
(a.account ?? '').localeCompare(b.account ?? '')),
|
|
131
|
+
models: [...models.entries()]
|
|
132
|
+
.map(([key, model]) => ({
|
|
133
|
+
provider: model.provider,
|
|
134
|
+
model: model.model,
|
|
135
|
+
...(model.account === null ? {} : { account: model.account }),
|
|
136
|
+
totals: model.totals,
|
|
137
|
+
costUsd: model.priced ? model.costUsd : null,
|
|
138
|
+
priceBasis: modelBasis.get(key)?.basis ?? 'unknown',
|
|
139
|
+
pricedAs: modelBasis.get(key)?.pricedAs ?? null
|
|
140
|
+
}))
|
|
141
|
+
.sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)),
|
|
142
|
+
projects: [...projects.values()]
|
|
143
|
+
.map((project) => ({
|
|
144
|
+
folder: project.folder,
|
|
145
|
+
name: project.name,
|
|
146
|
+
projectId: project.projectId,
|
|
147
|
+
byProvider: Object.fromEntries(project.byProvider),
|
|
148
|
+
totals: project.totals,
|
|
149
|
+
costUsd: project.costUsd
|
|
150
|
+
}))
|
|
151
|
+
.sort((a, b) => b.costUsd - a.costUsd),
|
|
152
|
+
sessions: sessions.size
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { UsageRate } from '@adecore/agent-contracts';
|
|
2
|
+
export declare const FRANKFURTER_URL = "https://api.frankfurter.app/latest";
|
|
3
|
+
export declare const TARGET_CURRENCY = "EUR";
|
|
4
|
+
export declare function parseRate(document: unknown, currency: string): Omit<UsageRate, 'fetchedAt'> | null;
|
|
5
|
+
export declare class ExchangeRates {
|
|
6
|
+
private readonly home;
|
|
7
|
+
private readonly allowFetch;
|
|
8
|
+
private rate;
|
|
9
|
+
private loadedFromDisk;
|
|
10
|
+
private inFlight;
|
|
11
|
+
constructor(home: string, allowFetch?: boolean);
|
|
12
|
+
get current(): UsageRate | null;
|
|
13
|
+
ensure(): Promise<void>;
|
|
14
|
+
private get file();
|
|
15
|
+
private refresh;
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isNotFound, writeAtomic } from '../fs.js';
|
|
4
|
+
import { errorText } from '../error-text.js';
|
|
5
|
+
export const FRANKFURTER_URL = 'https://api.frankfurter.app/latest';
|
|
6
|
+
/* The bank publishes one rate a day, so a rate a day old is the rate. */
|
|
7
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
9
|
+
/* The one currency the page offers next to dollars. Every price the host knows is in dollars. */
|
|
10
|
+
export const TARGET_CURRENCY = 'EUR';
|
|
11
|
+
export function parseRate(document, currency) {
|
|
12
|
+
const body = typeof document === 'object' && document !== null ? document : null;
|
|
13
|
+
const rates = body === null || typeof body.rates !== 'object' || body.rates === null ? null : body.rates;
|
|
14
|
+
const rate = rates === null ? undefined : rates[currency];
|
|
15
|
+
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0 || typeof body?.date !== 'string') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return { currency, rate, date: body.date };
|
|
19
|
+
}
|
|
20
|
+
/*
|
|
21
|
+
* What a dollar is worth in euros, asked of the ECB through frankfurter.app once a day and kept on
|
|
22
|
+
* disk between runs. It is the same shape as the price table: the page works without it and simply
|
|
23
|
+
* stays in dollars, so nothing here is allowed to hold up a scan or fail one.
|
|
24
|
+
*/
|
|
25
|
+
export class ExchangeRates {
|
|
26
|
+
home;
|
|
27
|
+
allowFetch;
|
|
28
|
+
rate = null;
|
|
29
|
+
loadedFromDisk = false;
|
|
30
|
+
inFlight = null;
|
|
31
|
+
constructor(home, allowFetch = true) {
|
|
32
|
+
this.home = home;
|
|
33
|
+
this.allowFetch = allowFetch;
|
|
34
|
+
}
|
|
35
|
+
get current() {
|
|
36
|
+
return this.rate;
|
|
37
|
+
}
|
|
38
|
+
/* Refreshes at most one rate at a time; a second caller waits for the first one's answer. */
|
|
39
|
+
ensure() {
|
|
40
|
+
this.inFlight ??= this.refresh().finally(() => {
|
|
41
|
+
this.inFlight = null;
|
|
42
|
+
});
|
|
43
|
+
return this.inFlight;
|
|
44
|
+
}
|
|
45
|
+
get file() {
|
|
46
|
+
return join(this.home, 'usage', 'exchange-rate.json');
|
|
47
|
+
}
|
|
48
|
+
async refresh() {
|
|
49
|
+
if (!this.loadedFromDisk) {
|
|
50
|
+
this.loadedFromDisk = true;
|
|
51
|
+
try {
|
|
52
|
+
const stored = JSON.parse(await readFile(this.file, 'utf8'));
|
|
53
|
+
if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
|
|
54
|
+
this.rate = stored;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (!isNotFound(e)) {
|
|
59
|
+
console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`frankfurter.app answered ${response.status}`);
|
|
70
|
+
}
|
|
71
|
+
const parsed = parseRate(await response.json(), TARGET_CURRENCY);
|
|
72
|
+
if (parsed === null) {
|
|
73
|
+
throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
|
|
74
|
+
}
|
|
75
|
+
this.rate = { ...parsed, fetchedAt: Date.now() };
|
|
76
|
+
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
77
|
+
await writeAtomic(this.file, JSON.stringify(this.rate));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
// Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
|
|
81
|
+
console.warn('Could not refresh the exchange rate:', errorText(e));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { UsageRecord } from './record.ts';
|
|
3
|
+
import type { CodexParserState } from './readers/codex.ts';
|
|
4
|
+
export interface IndexedFile {
|
|
5
|
+
provider: UsageProvider;
|
|
6
|
+
size: number;
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
offset: number;
|
|
9
|
+
records: UsageRecord[];
|
|
10
|
+
tail: UsageRecord[];
|
|
11
|
+
codex: CodexParserState | null;
|
|
12
|
+
}
|
|
13
|
+
export type UsageIndex = Map<string, IndexedFile>;
|
|
14
|
+
export declare function encodeIndex(index: UsageIndex): string;
|
|
15
|
+
export declare function decodeIndex(text: string): UsageIndex;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
|
|
2
|
+
const INDEX_VERSION = 2;
|
|
3
|
+
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
4
|
+
const ACCOUNTLESS_VERSION = 1;
|
|
5
|
+
class Interner {
|
|
6
|
+
values = [];
|
|
7
|
+
at = new Map();
|
|
8
|
+
index(value) {
|
|
9
|
+
const seen = this.at.get(value);
|
|
10
|
+
if (seen !== undefined) {
|
|
11
|
+
return seen;
|
|
12
|
+
}
|
|
13
|
+
const next = this.values.length;
|
|
14
|
+
this.values.push(value);
|
|
15
|
+
this.at.set(value, next);
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function encodeIndex(index) {
|
|
20
|
+
const models = new Interner();
|
|
21
|
+
const sessions = new Interner();
|
|
22
|
+
const folders = new Interner();
|
|
23
|
+
const accounts = new Interner();
|
|
24
|
+
const files = {};
|
|
25
|
+
const row = (record) => [
|
|
26
|
+
record.timestampMs,
|
|
27
|
+
models.index(record.model),
|
|
28
|
+
sessions.index(record.sessionId),
|
|
29
|
+
folders.index(record.cwd),
|
|
30
|
+
record.totals.calls,
|
|
31
|
+
record.totals.input,
|
|
32
|
+
record.totals.cacheRead,
|
|
33
|
+
record.totals.cacheWrite,
|
|
34
|
+
record.totals.cacheWrite1h,
|
|
35
|
+
record.totals.output,
|
|
36
|
+
record.totals.reasoning,
|
|
37
|
+
record.dedupeKey ?? 0,
|
|
38
|
+
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
39
|
+
];
|
|
40
|
+
for (const [path, file] of index) {
|
|
41
|
+
files[path] = { p: file.provider, s: file.size, m: file.mtimeMs, o: file.offset, c: file.codex, r: file.records.map(row), t: file.tail.map(row) };
|
|
42
|
+
}
|
|
43
|
+
const document = {
|
|
44
|
+
version: INDEX_VERSION,
|
|
45
|
+
models: models.values,
|
|
46
|
+
sessions: sessions.values,
|
|
47
|
+
folders: folders.values,
|
|
48
|
+
accounts: accounts.values,
|
|
49
|
+
files
|
|
50
|
+
};
|
|
51
|
+
return JSON.stringify(document);
|
|
52
|
+
}
|
|
53
|
+
function isRow(row, version) {
|
|
54
|
+
return (Array.isArray(row) &&
|
|
55
|
+
row.length === (version === ACCOUNTLESS_VERSION ? 12 : 13) &&
|
|
56
|
+
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
57
|
+
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
58
|
+
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number'));
|
|
59
|
+
}
|
|
60
|
+
/*
|
|
61
|
+
* A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
|
|
62
|
+
* so anything unexpected drops that file's entry rather than being repaired.
|
|
63
|
+
*/
|
|
64
|
+
export function decodeIndex(text) {
|
|
65
|
+
const index = new Map();
|
|
66
|
+
let document;
|
|
67
|
+
try {
|
|
68
|
+
document = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return index;
|
|
72
|
+
}
|
|
73
|
+
const version = document?.version;
|
|
74
|
+
if ((version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
|
|
75
|
+
!Array.isArray(document.models) ||
|
|
76
|
+
!Array.isArray(document.sessions) ||
|
|
77
|
+
!Array.isArray(document.folders)) {
|
|
78
|
+
return index;
|
|
79
|
+
}
|
|
80
|
+
const accounts = Array.isArray(document.accounts) ? document.accounts : [];
|
|
81
|
+
const name = (table, at) => (typeof table[at] === 'string' ? table[at] : null);
|
|
82
|
+
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
83
|
+
if (file.p !== 'claude' && file.p !== 'codex') {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let broken = false;
|
|
90
|
+
const decode = (rows) => {
|
|
91
|
+
const records = [];
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const valid = isRow(row, version);
|
|
94
|
+
const model = valid ? name(document.models, row[1]) : null;
|
|
95
|
+
const sessionId = valid ? name(document.sessions, row[2]) : null;
|
|
96
|
+
const cwd = valid ? name(document.folders, row[3]) : null;
|
|
97
|
+
const accountAt = valid ? (row[12] ?? 0) : 0;
|
|
98
|
+
const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
|
|
99
|
+
if (!valid || model === null || sessionId === null || cwd === null || account === null) {
|
|
100
|
+
broken = true;
|
|
101
|
+
return records;
|
|
102
|
+
}
|
|
103
|
+
records.push({
|
|
104
|
+
provider: file.p,
|
|
105
|
+
timestampMs: row[0],
|
|
106
|
+
model,
|
|
107
|
+
sessionId,
|
|
108
|
+
cwd,
|
|
109
|
+
...(account === undefined ? {} : { account }),
|
|
110
|
+
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
111
|
+
dedupeKey: row[11] === 0 ? null : row[11]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return records;
|
|
115
|
+
};
|
|
116
|
+
const records = decode(file.r);
|
|
117
|
+
const tail = decode(Array.isArray(file.t) ? file.t : []);
|
|
118
|
+
if (!broken) {
|
|
119
|
+
index.set(path, { provider: file.p, size: file.s, mtimeMs: file.m, offset: file.o, records, tail, codex: file.c ?? null });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return index;
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { ProviderRegistry } from '../../providers/registry.ts';
|
|
3
|
+
import type { AgentSink } from '../../events.ts';
|
|
4
|
+
import { type LimitsUpdate } from './normalize.ts';
|
|
5
|
+
import type { CodexClientInfo } from '../../chat/codex-transport.ts';
|
|
6
|
+
import { type ProbeResult } from './probe.ts';
|
|
7
|
+
export type ProbeEnv = Record<string, string>;
|
|
8
|
+
export interface LimitAccount {
|
|
9
|
+
id: string;
|
|
10
|
+
kind: UsageProvider;
|
|
11
|
+
label: string;
|
|
12
|
+
color?: string;
|
|
13
|
+
isDefault: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface LimitAccounts {
|
|
16
|
+
list(): LimitAccount[];
|
|
17
|
+
envFor(kind: UsageProvider, id: string): ProbeEnv;
|
|
18
|
+
lastUsedAt(id: string): number | null;
|
|
19
|
+
}
|
|
20
|
+
export interface UsageMonitorOptions {
|
|
21
|
+
providers: ProviderRegistry;
|
|
22
|
+
accounts?: LimitAccounts;
|
|
23
|
+
probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
client?: CodexClientInfo;
|
|
26
|
+
}
|
|
27
|
+
export declare class UsageMonitor {
|
|
28
|
+
private readonly registry;
|
|
29
|
+
private readonly accounts;
|
|
30
|
+
private readonly probe;
|
|
31
|
+
private readonly now;
|
|
32
|
+
private readonly sinks;
|
|
33
|
+
private readonly states;
|
|
34
|
+
private inFlight;
|
|
35
|
+
private timer;
|
|
36
|
+
constructor(options: UsageMonitorOptions);
|
|
37
|
+
subscribe(clientId: string, sink: AgentSink): () => void;
|
|
38
|
+
snapshot(): UsageLimitsSnapshot;
|
|
39
|
+
start(): void;
|
|
40
|
+
stop(): void;
|
|
41
|
+
refresh(force: boolean): Promise<void>;
|
|
42
|
+
accountsChanged(): void;
|
|
43
|
+
applyLive(update: LimitsUpdate): void;
|
|
44
|
+
private listed;
|
|
45
|
+
private stateOf;
|
|
46
|
+
private due;
|
|
47
|
+
private runAll;
|
|
48
|
+
private read;
|
|
49
|
+
private emit;
|
|
50
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { definedEnv } from '../../providers/accounts/launch.js';
|
|
2
|
+
import { mergeWindows } from './normalize.js';
|
|
3
|
+
import { probeClaude, probeCodex } from './probe.js';
|
|
4
|
+
import { ClientSinks } from '../../client-sinks.js';
|
|
5
|
+
import { errorText } from '../../error-text.js';
|
|
6
|
+
/* Often enough that a bar is never far behind, rarely enough that two CLIs are not started all day. */
|
|
7
|
+
const PROBE_INTERVAL_MS = 5 * 60_000;
|
|
8
|
+
const MAX_BACKOFF_MS = 60 * 60_000;
|
|
9
|
+
/* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
|
|
10
|
+
const RECENT_USE_MS = 24 * 60 * 60_000;
|
|
11
|
+
const PROVIDERS = ['claude', 'codex'];
|
|
12
|
+
function blank(kind) {
|
|
13
|
+
return {
|
|
14
|
+
kind,
|
|
15
|
+
plan: null,
|
|
16
|
+
checkedAt: 0,
|
|
17
|
+
source: 'probe',
|
|
18
|
+
windows: [],
|
|
19
|
+
cost: null,
|
|
20
|
+
unavailable: null
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/*
|
|
24
|
+
* What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
|
|
25
|
+
* which starts the CLI, asks and ends it, and the events a running turn streams in between, which
|
|
26
|
+
* cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
|
|
27
|
+
* hold their own login and answer the question themselves.
|
|
28
|
+
*/
|
|
29
|
+
export class UsageMonitor {
|
|
30
|
+
registry;
|
|
31
|
+
accounts;
|
|
32
|
+
probe;
|
|
33
|
+
now;
|
|
34
|
+
sinks = new ClientSinks();
|
|
35
|
+
// By account id, so the default account of a CLI is under the CLI's kind.
|
|
36
|
+
states = new Map();
|
|
37
|
+
inFlight = null;
|
|
38
|
+
timer = null;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.registry = options.providers;
|
|
41
|
+
this.accounts = options.accounts ?? null;
|
|
42
|
+
this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
|
|
43
|
+
this.now = options.now ?? Date.now;
|
|
44
|
+
for (const kind of PROVIDERS) {
|
|
45
|
+
this.stateOf(kind, kind);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
subscribe(clientId, sink) {
|
|
49
|
+
return this.sinks.subscribe(clientId, sink);
|
|
50
|
+
}
|
|
51
|
+
/* Per CLI its default account first, in the shape an entry had before accounts, then its other accounts. */
|
|
52
|
+
snapshot() {
|
|
53
|
+
const providers = this.listed().map(({ id, kind, label, color }) => {
|
|
54
|
+
const published = this.stateOf(kind, id).published;
|
|
55
|
+
return this.accounts === null ? published : { ...published, account: { id, label, ...(color === undefined ? {} : { color }) } };
|
|
56
|
+
});
|
|
57
|
+
return { providers };
|
|
58
|
+
}
|
|
59
|
+
/* Starts the five minute pass. The first read runs at once, so a fresh host has numbers. */
|
|
60
|
+
start() {
|
|
61
|
+
this.timer ??= setInterval(() => void this.refresh(false), PROBE_INTERVAL_MS);
|
|
62
|
+
void this.refresh(false);
|
|
63
|
+
}
|
|
64
|
+
stop() {
|
|
65
|
+
if (this.timer !== null) {
|
|
66
|
+
clearInterval(this.timer);
|
|
67
|
+
this.timer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/* One pass at a time; a second caller waits for the first one's answer rather than starting a CLI. */
|
|
71
|
+
refresh(force) {
|
|
72
|
+
this.inFlight ??= this.runAll(force).finally(() => {
|
|
73
|
+
this.inFlight = null;
|
|
74
|
+
});
|
|
75
|
+
return this.inFlight;
|
|
76
|
+
}
|
|
77
|
+
/* An account came, went, was renamed or signed in: the list a client holds is drawn again. */
|
|
78
|
+
accountsChanged() {
|
|
79
|
+
this.emit();
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
* A number a running turn reported, on the account the turn ran under. It lands on the row a full
|
|
83
|
+
* read drew, keeping the reset and the length the event leaves out, so the bar moves without
|
|
84
|
+
* waiting for the next pass.
|
|
85
|
+
*/
|
|
86
|
+
applyLive(update) {
|
|
87
|
+
if (update.windows.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const state = this.stateOf(update.kind, update.account ?? update.kind);
|
|
91
|
+
state.published = {
|
|
92
|
+
...state.published,
|
|
93
|
+
plan: update.plan ?? state.published.plan,
|
|
94
|
+
checkedAt: this.now(),
|
|
95
|
+
source: 'event',
|
|
96
|
+
windows: mergeWindows(state.published.windows, update.windows),
|
|
97
|
+
unavailable: null
|
|
98
|
+
};
|
|
99
|
+
this.emit();
|
|
100
|
+
}
|
|
101
|
+
/* Every account of a CLI whose plan is read, the default one first. */
|
|
102
|
+
listed() {
|
|
103
|
+
const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
|
|
104
|
+
return PROVIDERS.flatMap((kind) => {
|
|
105
|
+
const own = accounts.filter((account) => account.kind === kind);
|
|
106
|
+
return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
stateOf(kind, id) {
|
|
110
|
+
let state = this.states.get(id);
|
|
111
|
+
if (state === undefined || state.kind !== kind) {
|
|
112
|
+
state = { kind, published: blank(kind), backoffMs: PROBE_INTERVAL_MS, nextAt: 0 };
|
|
113
|
+
this.states.set(id, state);
|
|
114
|
+
}
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
/* The default account on the clock; another only while it is in use, since every read starts its CLI. */
|
|
118
|
+
due(account, state, force) {
|
|
119
|
+
if (force) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (this.now() < state.nextAt) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (account.isDefault) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const used = this.accounts?.lastUsedAt(account.id) ?? null;
|
|
129
|
+
return used !== null && this.now() - used < RECENT_USE_MS;
|
|
130
|
+
}
|
|
131
|
+
async runAll(force) {
|
|
132
|
+
const installed = await this.registry.list();
|
|
133
|
+
let changed = false;
|
|
134
|
+
for (const account of this.listed()) {
|
|
135
|
+
const { kind } = account;
|
|
136
|
+
const state = this.stateOf(kind, account.id);
|
|
137
|
+
if (!this.due(account, state, force)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const info = installed.find((provider) => provider.kind === kind);
|
|
141
|
+
if (info === undefined || !info.installed) {
|
|
142
|
+
state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
|
|
143
|
+
state.nextAt = this.now() + PROBE_INTERVAL_MS;
|
|
144
|
+
changed = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const result = await this.read(account);
|
|
148
|
+
const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
|
|
149
|
+
state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
|
|
150
|
+
state.nextAt = this.now() + state.backoffMs;
|
|
151
|
+
if (failed && state.published.windows.length > 0) {
|
|
152
|
+
// A pass that could not reach the CLI leaves the last good numbers where they are.
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
state.published =
|
|
156
|
+
'unavailable' in result
|
|
157
|
+
? { ...blank(kind), checkedAt: this.now(), unavailable: result.unavailable }
|
|
158
|
+
: { kind, plan: result.plan, checkedAt: this.now(), source: 'probe', windows: result.windows, cost: result.cost, unavailable: null };
|
|
159
|
+
changed = true;
|
|
160
|
+
}
|
|
161
|
+
if (changed) {
|
|
162
|
+
this.emit();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async read(account) {
|
|
166
|
+
let env;
|
|
167
|
+
try {
|
|
168
|
+
env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { unavailable: { reason: 'failed', message: errorText(e) } };
|
|
172
|
+
}
|
|
173
|
+
return this.probe(account.kind, this.registry.get(account.kind).command, env);
|
|
174
|
+
}
|
|
175
|
+
emit() {
|
|
176
|
+
const event = { event: 'usage.limitsChanged', payload: this.snapshot() };
|
|
177
|
+
this.sinks.emit(event);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { UsageLimitsProvider, UsageProvider, UsageWindow } from '@adecore/agent-contracts';
|
|
2
|
+
export interface ProviderReading {
|
|
3
|
+
plan: string | null;
|
|
4
|
+
windows: UsageWindow[];
|
|
5
|
+
cost: {
|
|
6
|
+
sessionUsd: number;
|
|
7
|
+
} | null;
|
|
8
|
+
}
|
|
9
|
+
export interface LimitsUpdate {
|
|
10
|
+
kind: UsageProvider;
|
|
11
|
+
account?: string;
|
|
12
|
+
plan?: string | null;
|
|
13
|
+
windows: Partial<UsageWindow>[];
|
|
14
|
+
}
|
|
15
|
+
export declare function kindOfDuration(durationMs: number | null): UsageWindow['kind'];
|
|
16
|
+
export declare function readClaudeUsage(response: unknown): ProviderReading | {
|
|
17
|
+
unavailable: UsageLimitsProvider['unavailable'];
|
|
18
|
+
};
|
|
19
|
+
export declare function readClaudeEvent(info: unknown): LimitsUpdate | null;
|
|
20
|
+
export declare function readCodexLimits(snapshot: unknown): ProviderReading | null;
|
|
21
|
+
export declare function mergeWindows(known: readonly UsageWindow[], updates: readonly Partial<UsageWindow>[]): UsageWindow[];
|