@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,236 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { UsagePriceBasis, UsagePricing, UsageTotals } from '@adecore/agent-contracts';
|
|
4
|
+
import { isNotFound, writeAtomic } from '../fs.ts';
|
|
5
|
+
import { errorText } from '../error-text.ts';
|
|
6
|
+
import bundled from './prices-snapshot.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
export const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
9
|
+
|
|
10
|
+
/* A table a day old still prices everything anyone ran today, and nobody has to be online for it. */
|
|
11
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
/* What Anthropic charges over the base input rate when the table itself says nothing. */
|
|
15
|
+
const CACHE_READ_SHARE = 0.1;
|
|
16
|
+
const CACHE_WRITE_SHARE = 1.25;
|
|
17
|
+
const CACHE_WRITE_1H_SHARE = 2;
|
|
18
|
+
|
|
19
|
+
export interface ModelPrice {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cacheRead: number;
|
|
23
|
+
cacheWrite: number;
|
|
24
|
+
cacheWrite1h: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PriceTable = Map<string, ModelPrice>;
|
|
28
|
+
|
|
29
|
+
export interface PriceLookup {
|
|
30
|
+
price: ModelPrice | null;
|
|
31
|
+
basis: UsagePriceBasis;
|
|
32
|
+
/* The key the price was found under, when that is not the model's own name. */
|
|
33
|
+
pricedAs: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* A bare family name prices nothing: `opus` and `sonnet` are several models with several prices. */
|
|
37
|
+
const AMBIGUOUS = new Set(['opus', 'sonnet', 'haiku', 'fable', 'gpt', 'codex', 'synthetic', '<synthetic>']);
|
|
38
|
+
|
|
39
|
+
const PROVIDERS = new Set(['anthropic', 'openai']);
|
|
40
|
+
const MODES = new Set(['chat', 'responses']);
|
|
41
|
+
|
|
42
|
+
interface LiteLlmEntry {
|
|
43
|
+
input_cost_per_token?: unknown;
|
|
44
|
+
output_cost_per_token?: unknown;
|
|
45
|
+
cache_read_input_token_cost?: unknown;
|
|
46
|
+
cache_creation_input_token_cost?: unknown;
|
|
47
|
+
litellm_provider?: unknown;
|
|
48
|
+
mode?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rate(value: unknown): number | null {
|
|
52
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* The LiteLLM table as this app needs it: the Anthropic and OpenAI chat and responses models, keyed
|
|
57
|
+
* by the lowercase name and by the bare name behind a provider prefix. A model priced on one side
|
|
58
|
+
* only is dropped, because half a price reads as a cheap model rather than an unknown one.
|
|
59
|
+
*/
|
|
60
|
+
export function parsePriceTable(document: unknown): PriceTable {
|
|
61
|
+
const table: PriceTable = new Map();
|
|
62
|
+
if (typeof document !== 'object' || document === null) {
|
|
63
|
+
return table;
|
|
64
|
+
}
|
|
65
|
+
for (const [name, value] of Object.entries(document as Record<string, LiteLlmEntry>)) {
|
|
66
|
+
if (typeof value !== 'object' || value === null) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value.litellm_provider !== 'string' || !PROVIDERS.has(value.litellm_provider)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (typeof value.mode !== 'string' || !MODES.has(value.mode)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const input = rate(value.input_cost_per_token);
|
|
76
|
+
const output = rate(value.output_cost_per_token);
|
|
77
|
+
if (input === null || output === null) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const price: ModelPrice = {
|
|
81
|
+
input,
|
|
82
|
+
output,
|
|
83
|
+
cacheRead: rate(value.cache_read_input_token_cost) ?? input * CACHE_READ_SHARE,
|
|
84
|
+
cacheWrite: rate(value.cache_creation_input_token_cost) ?? input * CACHE_WRITE_SHARE,
|
|
85
|
+
cacheWrite1h: input * CACHE_WRITE_1H_SHARE
|
|
86
|
+
};
|
|
87
|
+
const key = name.trim().toLowerCase();
|
|
88
|
+
table.set(key, price);
|
|
89
|
+
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
90
|
+
if (bare !== key && !table.has(bare)) {
|
|
91
|
+
table.set(bare, price);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return table;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* `claude-opus-4-5-20251101` is the same model as `claude-opus-4-5`, dated. */
|
|
98
|
+
function withoutDate(key: string): string | null {
|
|
99
|
+
const match = /^(.*)-\d{6,8}$/.exec(key);
|
|
100
|
+
return match === null ? null : match[1]!;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* Exact first, then the same name without its date, then the longest family the table knows. A name
|
|
105
|
+
* nobody prices is left unpriced rather than guessed at: `$0.00` for a model that costs money is a
|
|
106
|
+
* worse answer than a question mark.
|
|
107
|
+
*/
|
|
108
|
+
export function lookupPrice(table: PriceTable, model: string): PriceLookup {
|
|
109
|
+
const key = model.trim().toLowerCase().split('[')[0]!;
|
|
110
|
+
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
111
|
+
if (bare === '' || AMBIGUOUS.has(bare)) {
|
|
112
|
+
return { price: null, basis: 'unknown', pricedAs: null };
|
|
113
|
+
}
|
|
114
|
+
const exact = table.get(key) ?? table.get(bare);
|
|
115
|
+
if (exact !== undefined) {
|
|
116
|
+
return { price: exact, basis: 'exact', pricedAs: null };
|
|
117
|
+
}
|
|
118
|
+
const dated = withoutDate(bare);
|
|
119
|
+
const undated = dated === null ? undefined : table.get(dated);
|
|
120
|
+
if (undated !== undefined && dated !== null) {
|
|
121
|
+
return { price: undated, basis: 'exact', pricedAs: dated };
|
|
122
|
+
}
|
|
123
|
+
for (let family = dated ?? bare; family.includes('-');) {
|
|
124
|
+
family = family.slice(0, family.lastIndexOf('-'));
|
|
125
|
+
const found = AMBIGUOUS.has(family) ? undefined : table.get(family);
|
|
126
|
+
if (found !== undefined) {
|
|
127
|
+
return { price: found, basis: 'family', pricedAs: family };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { price: null, basis: 'unknown', pricedAs: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Reasoning tokens are a part of the output and are never charged a second time. */
|
|
134
|
+
export function costOf(totals: UsageTotals, price: ModelPrice): number {
|
|
135
|
+
return (
|
|
136
|
+
totals.input * price.input +
|
|
137
|
+
totals.cacheRead * price.cacheRead +
|
|
138
|
+
Math.max(0, totals.cacheWrite - totals.cacheWrite1h) * price.cacheWrite +
|
|
139
|
+
totals.cacheWrite1h * price.cacheWrite1h +
|
|
140
|
+
totals.output * price.output
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* What reading from the cache saved against sending the same tokens again. The premium a write
|
|
145
|
+
costs is not taken off: it was paid for the turn that wrote, not for the ones that read. */
|
|
146
|
+
export function cacheSavingsOf(totals: UsageTotals, price: ModelPrice): number {
|
|
147
|
+
return totals.cacheRead * (price.input - price.cacheRead);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface Snapshot {
|
|
151
|
+
fetchedAt: number;
|
|
152
|
+
document: unknown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* One price table for the whole host. It starts on the snapshot that ships with the app, so the
|
|
157
|
+
* first page ever opened already carries prices, then reads the newer copy on disk, then asks
|
|
158
|
+
* LiteLLM once a day. Every step is optional: with the fetch off and nothing on disk the bundled
|
|
159
|
+
* table is what prices everything.
|
|
160
|
+
*/
|
|
161
|
+
export class PriceBook {
|
|
162
|
+
private readonly home: string;
|
|
163
|
+
private readonly allowFetch: boolean;
|
|
164
|
+
private table: PriceTable;
|
|
165
|
+
private source: UsagePricing['source'] = 'snapshot';
|
|
166
|
+
private fetchedAt: number | null = null;
|
|
167
|
+
private loadedFromDisk = false;
|
|
168
|
+
private inFlight: Promise<void> | null = null;
|
|
169
|
+
|
|
170
|
+
constructor(home: string, allowFetch = true) {
|
|
171
|
+
this.home = home;
|
|
172
|
+
this.allowFetch = allowFetch;
|
|
173
|
+
this.table = parsePriceTable(bundled);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get pricing(): UsagePricing {
|
|
177
|
+
return { source: this.table.size === 0 ? 'none' : this.source, fetchedAt: this.fetchedAt, models: this.table.size };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
look(model: string): PriceLookup {
|
|
181
|
+
return lookupPrice(this.table, model);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* Refreshes at most one table at a time; a second caller waits for the first one's answer. */
|
|
185
|
+
ensure(): Promise<void> {
|
|
186
|
+
this.inFlight ??= this.refresh().finally(() => {
|
|
187
|
+
this.inFlight = null;
|
|
188
|
+
});
|
|
189
|
+
return this.inFlight;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private get file(): string {
|
|
193
|
+
return join(this.home, 'usage', 'prices.json');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async refresh(): Promise<void> {
|
|
197
|
+
if (!this.loadedFromDisk) {
|
|
198
|
+
this.loadedFromDisk = true;
|
|
199
|
+
try {
|
|
200
|
+
const snapshot = JSON.parse(await readFile(this.file, 'utf8')) as Snapshot;
|
|
201
|
+
const table = parsePriceTable(snapshot?.document);
|
|
202
|
+
if (table.size > 0 && typeof snapshot.fetchedAt === 'number') {
|
|
203
|
+
this.table = table;
|
|
204
|
+
this.source = 'litellm';
|
|
205
|
+
this.fetchedAt = snapshot.fetchedAt;
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (!isNotFound(e)) {
|
|
209
|
+
console.warn('The stored price table would not read; using the bundled one:', errorText(e));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!this.allowFetch || (this.fetchedAt !== null && Date.now() - this.fetchedAt < TTL_MS)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch(LITELLM_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
throw new Error(`LiteLLM answered ${response.status}`);
|
|
220
|
+
}
|
|
221
|
+
const document: unknown = await response.json();
|
|
222
|
+
const table = parsePriceTable(document);
|
|
223
|
+
if (table.size === 0) {
|
|
224
|
+
throw new Error('The LiteLLM table held no model this app prices');
|
|
225
|
+
}
|
|
226
|
+
this.table = table;
|
|
227
|
+
this.source = 'litellm';
|
|
228
|
+
this.fetchedAt = Date.now();
|
|
229
|
+
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
230
|
+
await writeAtomic(this.file, JSON.stringify({ fetchedAt: this.fetchedAt, document } satisfies Snapshot));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
// Whatever is loaded keeps pricing; a table that could not be refreshed is not a failure.
|
|
233
|
+
console.warn('Could not refresh the price table:', errorText(e));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface KnownProject {
|
|
5
|
+
projectId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
folder: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ResolvedProject {
|
|
11
|
+
folder: string;
|
|
12
|
+
name: string;
|
|
13
|
+
projectId: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* A checkout that is a worktree keeps its `.git` in a file pointing back into the repository it
|
|
18
|
+
* belongs to. The work done there is the same repository's work, so both fold to the one folder.
|
|
19
|
+
*/
|
|
20
|
+
async function mainCheckoutOf(gitPath: string): Promise<string | null> {
|
|
21
|
+
const text = await readFile(gitPath, 'utf8');
|
|
22
|
+
const match = /^gitdir:\s*(.+)$/m.exec(text);
|
|
23
|
+
const gitDir = match?.[1]?.trim();
|
|
24
|
+
const at = gitDir?.indexOf('/.git/worktrees/') ?? -1;
|
|
25
|
+
return gitDir !== undefined && at > 0 ? gitDir.slice(0, at) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Which folder a directory's work belongs to: the repository it sits in, or the directory itself
|
|
30
|
+
* when it is in none. Every answer is remembered, because one scan asks about the same handful of
|
|
31
|
+
* directories tens of thousands of times.
|
|
32
|
+
*/
|
|
33
|
+
export class ProjectResolver {
|
|
34
|
+
private readonly known: Map<string, KnownProject>;
|
|
35
|
+
private readonly roots = new Map<string, string>();
|
|
36
|
+
|
|
37
|
+
constructor(known: readonly KnownProject[]) {
|
|
38
|
+
this.known = new Map(known.flatMap((project) => (project.folder === '' ? [] : [[project.folder, project] as const])));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async resolve(cwd: string): Promise<ResolvedProject> {
|
|
42
|
+
const folder = cwd === '' ? '' : await this.rootOf(cwd);
|
|
43
|
+
const project = this.known.get(folder);
|
|
44
|
+
return { folder, name: project?.name ?? (folder === '' ? 'Elsewhere' : basename(folder)), projectId: project?.projectId ?? null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async rootOf(cwd: string): Promise<string> {
|
|
48
|
+
const cached = this.roots.get(cwd);
|
|
49
|
+
if (cached !== undefined) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
let root = cwd;
|
|
53
|
+
for (let at = cwd; at !== dirname(at); at = dirname(at)) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(join(at, '.git'));
|
|
56
|
+
root = (info.isFile() ? await mainCheckoutOf(join(at, '.git')) : null) ?? at;
|
|
57
|
+
break;
|
|
58
|
+
} catch {
|
|
59
|
+
// Not a checkout at this level; the one above it may be.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this.roots.set(cwd, root);
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { asObject, asString, int, type UsageRecord } from '../record.ts';
|
|
2
|
+
|
|
3
|
+
/* Only a line with this in it can carry counts, which keeps `JSON.parse` off about two thirds of them. */
|
|
4
|
+
export function claudeMightCarryUsage(line: string): boolean {
|
|
5
|
+
return line.includes('"usage"');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* One line of `~/.claude/projects/**\/*.jsonl`. An assistant line carries `message.usage` with the
|
|
10
|
+
* counts of the call that produced it; every other kind of line has nothing to price. The counts are
|
|
11
|
+
* Anthropic's, so `input_tokens` already excludes what was read from or written to the cache.
|
|
12
|
+
*/
|
|
13
|
+
export function parseClaudeLine(line: string): UsageRecord | null {
|
|
14
|
+
let parsed: unknown;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(line);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const record = asObject(parsed);
|
|
21
|
+
if (record === null || record.type !== 'assistant') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const message = asObject(record.message);
|
|
25
|
+
const usage = message === null ? null : asObject(message.usage);
|
|
26
|
+
if (message === null || usage === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const model = asString(message.model);
|
|
30
|
+
const timestampMs = Date.parse(asString(record.timestamp));
|
|
31
|
+
// `<synthetic>` is what Claude Code writes for a message it made up itself, an error or an
|
|
32
|
+
// interruption. It never reached the API, and its counts are zero, so it is not a call.
|
|
33
|
+
if (model === '' || model === '<synthetic>' || Number.isNaN(timestampMs)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const creation = asObject(usage.cache_creation);
|
|
37
|
+
const outputDetails = asObject(usage.output_tokens_details);
|
|
38
|
+
const messageId = asString(message.id);
|
|
39
|
+
const totals = {
|
|
40
|
+
calls: 1,
|
|
41
|
+
input: int(usage.input_tokens),
|
|
42
|
+
cacheRead: int(usage.cache_read_input_tokens),
|
|
43
|
+
cacheWrite: int(usage.cache_creation_input_tokens),
|
|
44
|
+
cacheWrite1h: creation === null ? 0 : int(creation.ephemeral_1h_input_tokens),
|
|
45
|
+
output: int(usage.output_tokens),
|
|
46
|
+
reasoning: outputDetails === null ? 0 : int(outputDetails.thinking_tokens)
|
|
47
|
+
};
|
|
48
|
+
if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
provider: 'claude',
|
|
53
|
+
timestampMs,
|
|
54
|
+
model,
|
|
55
|
+
sessionId: asString(record.sessionId),
|
|
56
|
+
cwd: asString(record.cwd),
|
|
57
|
+
totals,
|
|
58
|
+
dedupeKey: messageId === '' ? null : messageId
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { asObject, asString, int, type UsageRecord } from '../record.ts';
|
|
2
|
+
|
|
3
|
+
/* A `token_count` line carries the counts; the other two carry the model and the directory they belong to. */
|
|
4
|
+
export function codexMightCarryUsage(line: string): boolean {
|
|
5
|
+
return line.includes('token_count') || line.includes('turn_context') || line.includes('session_meta');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* A fork replays its ancestors' events into the new file the moment it is created, so anything this
|
|
9
|
+
close to the file's own start is a copy of work that was already counted somewhere else. */
|
|
10
|
+
const FORK_COPY_MAX_GAP_MS = 1_000;
|
|
11
|
+
|
|
12
|
+
interface CodexCounts {
|
|
13
|
+
input: number;
|
|
14
|
+
cached: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
output: number;
|
|
17
|
+
reasoning: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* What one file's reader has to remember between lines. A `token_count` line names neither the model
|
|
22
|
+
* nor the directory, and its totals are cumulative, so a scan that resumes halfway through a file
|
|
23
|
+
* needs all of it back. Everything here is JSON, so the index can store it verbatim.
|
|
24
|
+
*/
|
|
25
|
+
export interface CodexParserState {
|
|
26
|
+
model: string | null;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
/* The running cumulative total of the last line, which the next line is a delta against. */
|
|
30
|
+
total: CodexCounts | null;
|
|
31
|
+
sawSessionMeta: boolean;
|
|
32
|
+
/* Whether this file opened as a fork or a sub-agent thread, and where its replayed prefix is. */
|
|
33
|
+
suppressing: boolean;
|
|
34
|
+
anchorMs: number;
|
|
35
|
+
/* The last counts as they were written, so a line repeated verbatim is not counted twice. */
|
|
36
|
+
lastSignature: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createCodexState(): CodexParserState {
|
|
40
|
+
return {
|
|
41
|
+
model: null,
|
|
42
|
+
sessionId: '',
|
|
43
|
+
cwd: '',
|
|
44
|
+
total: null,
|
|
45
|
+
sawSessionMeta: false,
|
|
46
|
+
suppressing: false,
|
|
47
|
+
anchorMs: 0,
|
|
48
|
+
lastSignature: null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cloneCodexState(state: CodexParserState): CodexParserState {
|
|
53
|
+
return { ...state, total: state.total === null ? null : { ...state.total } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function countsOf(usage: Record<string, unknown>): CodexCounts {
|
|
57
|
+
return {
|
|
58
|
+
input: int(usage.input_tokens),
|
|
59
|
+
cached: int(usage.cached_input_tokens),
|
|
60
|
+
cacheWrite: int(usage.cache_write_input_tokens),
|
|
61
|
+
output: int(usage.output_tokens),
|
|
62
|
+
reasoning: int(usage.reasoning_output_tokens)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function deltaOf(total: CodexCounts, previous: CodexCounts | null): CodexCounts | null {
|
|
67
|
+
if (previous === null) {
|
|
68
|
+
return total;
|
|
69
|
+
}
|
|
70
|
+
const delta: CodexCounts = {
|
|
71
|
+
input: total.input - previous.input,
|
|
72
|
+
cached: total.cached - previous.cached,
|
|
73
|
+
cacheWrite: total.cacheWrite - previous.cacheWrite,
|
|
74
|
+
output: total.output - previous.output,
|
|
75
|
+
reasoning: total.reasoning - previous.reasoning
|
|
76
|
+
};
|
|
77
|
+
// A compaction or a resume restarts the count, so anything falling is not a delta at all.
|
|
78
|
+
return delta.input < 0 || delta.cached < 0 || delta.cacheWrite < 0 || delta.output < 0 || delta.reasoning < 0 ? null : delta;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* A thread that was forked or spawned repeats its parent's events before its own work starts. */
|
|
82
|
+
function isForkOrSubagent(payload: Record<string, unknown>): boolean {
|
|
83
|
+
if (typeof payload.forked_from_id === 'string' || typeof payload.parent_thread_id === 'string') {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const spawn = asObject(asObject(payload.source)?.subagent)?.thread_spawn;
|
|
87
|
+
return typeof asObject(spawn)?.parent_thread_id === 'string';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* One line of `~/.codex/sessions/**\/*.jsonl`, against the state of the file it came from. Codex
|
|
92
|
+
* reports a cumulative total per turn, so the call is the difference with the line before it; when
|
|
93
|
+
* that difference falls the run started over and `last_token_usage` is the turn on its own. Codex
|
|
94
|
+
* counts cache reads and writes inside `input_tokens`, unlike Anthropic, so what is left after
|
|
95
|
+
* taking both out is the input that was actually paid for at the full rate.
|
|
96
|
+
*/
|
|
97
|
+
export function parseCodexLine(line: string, state: CodexParserState): UsageRecord | null {
|
|
98
|
+
let parsed: unknown;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(line);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const record = asObject(parsed);
|
|
105
|
+
const payload = record === null ? null : asObject(record.payload);
|
|
106
|
+
if (record === null || payload === null) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (record.type === 'session_meta') {
|
|
111
|
+
// A fork carries its ancestors' metas as well; only the file's own says what the file is.
|
|
112
|
+
if (state.sawSessionMeta) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
state.sawSessionMeta = true;
|
|
116
|
+
state.sessionId = asString(payload.id) || asString(payload.session_id);
|
|
117
|
+
state.cwd = asString(payload.cwd);
|
|
118
|
+
if (isForkOrSubagent(payload)) {
|
|
119
|
+
state.suppressing = true;
|
|
120
|
+
state.anchorMs = Date.parse(asString(record.timestamp)) || 0;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (record.type === 'turn_context') {
|
|
126
|
+
const model = asString(payload.model);
|
|
127
|
+
if (model !== '') {
|
|
128
|
+
state.model = model;
|
|
129
|
+
}
|
|
130
|
+
const cwd = asString(payload.cwd);
|
|
131
|
+
if (cwd !== '') {
|
|
132
|
+
state.cwd = cwd;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (payload.type !== 'token_count') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const info = asObject(payload.info);
|
|
141
|
+
const total = info === null ? null : asObject(info.total_token_usage);
|
|
142
|
+
if (info === null || total === null || state.model === null) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const timestampMs = Date.parse(asString(record.timestamp));
|
|
146
|
+
if (Number.isNaN(timestampMs)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const signature = JSON.stringify(info.total_token_usage);
|
|
151
|
+
if (signature === state.lastSignature) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
state.lastSignature = signature;
|
|
155
|
+
|
|
156
|
+
const cumulative = countsOf(total);
|
|
157
|
+
const last = asObject(info.last_token_usage);
|
|
158
|
+
const counts = deltaOf(cumulative, state.total) ?? (last === null ? null : countsOf(last));
|
|
159
|
+
state.total = cumulative;
|
|
160
|
+
if (counts === null) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (state.suppressing) {
|
|
165
|
+
if (timestampMs - state.anchorMs <= FORK_COPY_MAX_GAP_MS) {
|
|
166
|
+
state.anchorMs = timestampMs;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// The first event that is not part of the replayed burst is this thread's own work.
|
|
170
|
+
state.suppressing = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const uncached = Math.max(0, counts.input - counts.cached - counts.cacheWrite);
|
|
174
|
+
const totals = {
|
|
175
|
+
calls: 1,
|
|
176
|
+
input: uncached,
|
|
177
|
+
cacheRead: counts.cached,
|
|
178
|
+
cacheWrite: counts.cacheWrite,
|
|
179
|
+
cacheWrite1h: 0,
|
|
180
|
+
output: counts.output,
|
|
181
|
+
reasoning: Math.min(counts.output, counts.reasoning)
|
|
182
|
+
};
|
|
183
|
+
if (uncached + counts.cached + counts.cacheWrite + counts.output === 0) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
provider: 'codex',
|
|
188
|
+
timestampMs,
|
|
189
|
+
model: state.model,
|
|
190
|
+
sessionId: state.sessionId,
|
|
191
|
+
cwd: state.cwd,
|
|
192
|
+
totals,
|
|
193
|
+
// Fork copies are gone by here, so every event that is left is a call of its own.
|
|
194
|
+
dedupeKey: null
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { UsageProvider, UsageTotals } from '@adecore/agent-contracts';
|
|
2
|
+
|
|
3
|
+
/* One priced call, as a reader found it on a line. The scanner keeps these; the aggregator sums them. */
|
|
4
|
+
export interface UsageRecord {
|
|
5
|
+
provider: UsageProvider;
|
|
6
|
+
timestampMs: number;
|
|
7
|
+
model: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
/* The directory the call was made in, which is what a per-project breakdown folds. */
|
|
10
|
+
cwd: string;
|
|
11
|
+
/* The account the call was made under; absent is the default account of the CLI. */
|
|
12
|
+
account?: string;
|
|
13
|
+
totals: UsageTotals;
|
|
14
|
+
/* What identifies the message this line reports on, or null when nothing does. */
|
|
15
|
+
dedupeKey: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A count on the wire is a whole non-negative number, whatever a transcript put in the field. */
|
|
19
|
+
export function int(value: unknown): number {
|
|
20
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* A field a transcript wrote as anything; a reader takes what it can use and nothing else. */
|
|
24
|
+
export function asObject(value: unknown): Record<string, unknown> | null {
|
|
25
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function asString(value: unknown): string {
|
|
29
|
+
return typeof value === 'string' ? value : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function maxTotals(into: UsageTotals, from: UsageTotals): void {
|
|
33
|
+
into.calls = Math.max(into.calls, from.calls);
|
|
34
|
+
into.input = Math.max(into.input, from.input);
|
|
35
|
+
into.cacheRead = Math.max(into.cacheRead, from.cacheRead);
|
|
36
|
+
into.cacheWrite = Math.max(into.cacheWrite, from.cacheWrite);
|
|
37
|
+
into.cacheWrite1h = Math.max(into.cacheWrite1h, from.cacheWrite1h);
|
|
38
|
+
into.output = Math.max(into.output, from.output);
|
|
39
|
+
into.reasoning = Math.max(into.reasoning, from.reasoning);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Claude repeats growing usage per message and across resumed transcripts; keep each field's maximum.
|
|
43
|
+
export function foldByKey(records: readonly UsageRecord[]): UsageRecord[] {
|
|
44
|
+
const folded: UsageRecord[] = [];
|
|
45
|
+
const at = new Map<string, number>();
|
|
46
|
+
for (const record of records) {
|
|
47
|
+
if (record.dedupeKey === null) {
|
|
48
|
+
folded.push(record);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const seen = at.get(record.dedupeKey);
|
|
52
|
+
if (seen === undefined) {
|
|
53
|
+
at.set(record.dedupeKey, folded.length);
|
|
54
|
+
folded.push({ ...record, totals: { ...record.totals } });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
maxTotals(folded[seen]!.totals, record.totals);
|
|
58
|
+
}
|
|
59
|
+
return folded;
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
4
|
+
|
|
5
|
+
export interface UsageRootPath {
|
|
6
|
+
provider: UsageProvider;
|
|
7
|
+
path: string;
|
|
8
|
+
/* The accounts whose CLI writes here, by id, the default one under its kind; absent is the default account alone. */
|
|
9
|
+
accounts?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Where in its config folder each CLI leaves its transcripts. */
|
|
13
|
+
const TRANSCRIPTS: Record<UsageProvider, string> = { claude: 'projects', codex: 'sessions' };
|
|
14
|
+
|
|
15
|
+
/* Where each CLI leaves its transcripts. The same environment variables the hooks installer reads,
|
|
16
|
+
so the scanner and the installer never disagree about where a CLI keeps its things. */
|
|
17
|
+
export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
|
|
18
|
+
const home = env.HOME ?? homedir();
|
|
19
|
+
return [
|
|
20
|
+
{ provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
|
|
21
|
+
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The transcripts of every account, once per folder: Codex accounts over one home share its sessions. */
|
|
26
|
+
export function accountRoots(folders: ReadonlyArray<{ id: string; kind: UsageProvider; folder: string }>): UsageRootPath[] {
|
|
27
|
+
const roots = new Map<string, UsageRootPath & { accounts: string[] }>();
|
|
28
|
+
for (const { id, kind, folder } of folders) {
|
|
29
|
+
const path = join(folder, TRANSCRIPTS[kind]);
|
|
30
|
+
const key = `${kind}\0${path}`;
|
|
31
|
+
const root = roots.get(key);
|
|
32
|
+
if (root === undefined) {
|
|
33
|
+
roots.set(key, { provider: kind, path, accounts: [id] });
|
|
34
|
+
} else {
|
|
35
|
+
root.accounts.push(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...roots.values()];
|
|
39
|
+
}
|