@oar-sh/oar 0.9.18 → 0.9.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +359 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
- package/server/claude-worker/claude-session-process.mjs +3 -0
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +351 -0
- package/server/relay-self-restart.mjs +116 -9
- package/server/relay-tools.md +5 -4
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +120 -4
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +141 -31
- package/server/server-runtime.mjs +142 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +68 -8
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-scope.mjs +77 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +98 -21
- package/shared/turn-token-usage.mjs +144 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"!server/README.md",
|
|
26
26
|
"!server/data/",
|
|
27
27
|
"!server/logs/",
|
|
28
|
+
"!server/backups/",
|
|
28
29
|
"!server/config.json",
|
|
29
30
|
"!server/uploads/",
|
|
30
31
|
"server/uploads/README.md",
|
|
@@ -43,8 +44,9 @@
|
|
|
43
44
|
"preinstall": "node server/services/package-data-rescue-preinstall.mjs",
|
|
44
45
|
"start": "node server/server.js",
|
|
45
46
|
"copilot:relay": "gh copilot -- --allow-all",
|
|
46
|
-
"test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
47
|
+
"test": "node --import ./scripts/node-test-keep-alive.mjs --import ./scripts/node-test-worktree-guard.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
47
48
|
"test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
|
|
49
|
+
"test:packed-update": "node --test tests/packed-update/packed-update.test.mjs",
|
|
48
50
|
"test:e2e": "node tests/run-e2e.mjs",
|
|
49
51
|
"setup:git": "node scripts/setup-git.mjs",
|
|
50
52
|
"land": "node scripts/land.mjs",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
2
|
+
import { tokenUsageModel } from '../../shared/turn-token-usage.mjs';
|
|
2
3
|
import {
|
|
3
4
|
compactBoundaryActivityAction,
|
|
4
5
|
displayToolName,
|
|
@@ -68,6 +69,57 @@ export function readCloudResult(payload) {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A cloud session's running token totals per model, for the token booking
|
|
74
|
+
* (POST /api/turn-token-usage with `sessionTotals`; the relay books what
|
|
75
|
+
* they grew by): `[{ model, inputTokens, cacheWriteTokens, cacheReadTokens,
|
|
76
|
+
* outputTokens, costUsd, costSource, costUnknown }]` out of a result's
|
|
77
|
+
* `modelUsage`. A field the entry does not carry is null, not 0. The cache
|
|
78
|
+
* fields of the session read (`sessionTokens`, readCloudSessionUsage) cover
|
|
79
|
+
* the whole session, so they stand in only when the session ran one model;
|
|
80
|
+
* with more, a missing field stays null. Model ids drop the `[1m]` tier.
|
|
81
|
+
*/
|
|
82
|
+
export function cloudSessionTokenTotals(modelUsage, sessionTokens = null) {
|
|
83
|
+
if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
|
|
84
|
+
const count = (value) => {
|
|
85
|
+
const n = finiteOrNull(value);
|
|
86
|
+
return n === null || n < 0 ? null : Math.trunc(n);
|
|
87
|
+
};
|
|
88
|
+
const totals = new Map();
|
|
89
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
90
|
+
const model = tokenUsageModel(name);
|
|
91
|
+
if (!model || !entry || typeof entry !== 'object') continue;
|
|
92
|
+
const costUsd = finiteOrNull(entry.costUSD);
|
|
93
|
+
const next = {
|
|
94
|
+
model,
|
|
95
|
+
inputTokens: count(entry.inputTokens),
|
|
96
|
+
cacheWriteTokens: count(entry.cacheCreationInputTokens),
|
|
97
|
+
cacheReadTokens: count(entry.cacheReadInputTokens),
|
|
98
|
+
outputTokens: count(entry.outputTokens),
|
|
99
|
+
costUsd: costUsd === null || costUsd < 0 ? null : costUsd,
|
|
100
|
+
costSource: null,
|
|
101
|
+
costUnknown: entry.costBasis === 'unknown',
|
|
102
|
+
};
|
|
103
|
+
const current = totals.get(model);
|
|
104
|
+
if (!current) {
|
|
105
|
+
totals.set(model, next);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const field of ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens', 'costUsd']) {
|
|
109
|
+
if (next[field] !== null) current[field] = (current[field] ?? 0) + next[field];
|
|
110
|
+
}
|
|
111
|
+
current.costUnknown = current.costUnknown || next.costUnknown;
|
|
112
|
+
}
|
|
113
|
+
const entries = [...totals.values()];
|
|
114
|
+
if (entries.length === 1 && sessionTokens) {
|
|
115
|
+
const [only] = entries;
|
|
116
|
+
if (only.cacheWriteTokens === null) only.cacheWriteTokens = count(sessionTokens.cacheWriteTokens);
|
|
117
|
+
if (only.cacheReadTokens === null) only.cacheReadTokens = count(sessionTokens.cacheReadTokens);
|
|
118
|
+
}
|
|
119
|
+
for (const entry of entries) entry.costSource = entry.costUsd === null ? null : 'claude-code';
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
71
123
|
/**
|
|
72
124
|
* What `GET /v1/code/sessions/{id}` says after a turn, for the relay's usage
|
|
73
125
|
* routes: the context occupancy in the shape `/api/claude-context-usage`
|
|
@@ -99,6 +151,14 @@ export function readCloudSessionUsage(sessionBody, { model = '' } = {}) {
|
|
|
99
151
|
};
|
|
100
152
|
return {
|
|
101
153
|
contextUsage,
|
|
154
|
+
// The session's token totals over every model: what the token booking
|
|
155
|
+
// falls back on for the cache fields (cloudSessionTokenTotals).
|
|
156
|
+
tokens: {
|
|
157
|
+
inputTokens: finiteOrNull(usage.input_tokens),
|
|
158
|
+
outputTokens: finiteOrNull(usage.output_tokens),
|
|
159
|
+
cacheReadTokens: finiteOrNull(usage.cache_read_tokens),
|
|
160
|
+
cacheWriteTokens: finiteOrNull(usage.cache_write_tokens),
|
|
161
|
+
},
|
|
102
162
|
costUsd: finiteOrNull(usage.cost_usd),
|
|
103
163
|
sessionUrl: String(shape?.session_url || sessionBody?.sessionUrl || '').trim() || null,
|
|
104
164
|
workerStatus: String(shape?.worker_status || '').trim().toLowerCase() || null,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../claude-worker/claude-turn-publisher.mjs';
|
|
19
19
|
import { summarizeToolInput } from '../claude-worker/sdk-message-normalizer.mjs';
|
|
20
20
|
import {
|
|
21
|
+
cloudSessionTokenTotals,
|
|
21
22
|
createClaudeCloudEventNormalizer,
|
|
22
23
|
readCloudResult,
|
|
23
24
|
readCloudSessionUsage,
|
|
@@ -1180,6 +1181,32 @@ export function createClaudeCloudSessionRunner({
|
|
|
1180
1181
|
state.contextUsage = usage.contextUsage;
|
|
1181
1182
|
await publisher.publishContextUsage({ message, state, model, sdkSessionId });
|
|
1182
1183
|
await publisher.publishPlanUsage({ message, state, sdkSessionId });
|
|
1184
|
+
await publishTurnTokens(message, state, usage.tokens);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// A worker process names its run once: a turn requeued onto a restarted
|
|
1188
|
+
// worker is another run of it (POST /api/turn-token-usage).
|
|
1189
|
+
const tokenRunId = crypto.randomUUID();
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* The session's running token totals per model (the result's
|
|
1193
|
+
* `modelUsage`), under the turn: the relay books what they grew by since
|
|
1194
|
+
* the session's other turns. Advisory; nothing without `modelUsage`.
|
|
1195
|
+
*/
|
|
1196
|
+
async function publishTurnTokens(message, state, sessionTokens) {
|
|
1197
|
+
const sessionTotals = cloudSessionTokenTotals(state.result?.modelUsage, sessionTokens);
|
|
1198
|
+
if (!sessionTotals.length || !session.id) return;
|
|
1199
|
+
await api('POST', '/api/turn-token-usage', {
|
|
1200
|
+
conversationId: message.conversationId,
|
|
1201
|
+
turnId: String(message.id || ''),
|
|
1202
|
+
runId: tokenRunId,
|
|
1203
|
+
provider: 'claude-cloud',
|
|
1204
|
+
nativeSessionId: session.id,
|
|
1205
|
+
at: new Date().toISOString(),
|
|
1206
|
+
sessionTotals,
|
|
1207
|
+
}).catch((error) => {
|
|
1208
|
+
dbg('turn token publish failed', error?.message || String(error));
|
|
1209
|
+
});
|
|
1183
1210
|
}
|
|
1184
1211
|
|
|
1185
1212
|
async function finishTurn(t, outcome) {
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
REMOTE_RELAY_ACTIONS,
|
|
12
12
|
REMOTE_RELAY_IF_BUSY,
|
|
13
13
|
REMOTE_RELAY_MODES,
|
|
14
|
-
REMOTE_RELAY_PROVIDERS,
|
|
15
14
|
REMOTE_RELAY_SESSION_SCOPES,
|
|
16
15
|
REMOTE_RELAY_THINKING_DISPLAYS,
|
|
17
16
|
} from '../../shared/remote-relay-contract.mjs';
|
|
@@ -61,7 +60,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
|
61
60
|
before: text('before'),
|
|
62
61
|
include_activity: z.boolean().optional().describe(FIELDS.include_activity.description),
|
|
63
62
|
max_chars: number('max_chars'),
|
|
64
|
-
|
|
63
|
+
// Free text: the worker outlives relay updates, the relay checks the id.
|
|
64
|
+
provider: text('provider'),
|
|
65
65
|
model: text('model'),
|
|
66
66
|
cwd: text('cwd'),
|
|
67
67
|
repo: text('repo'),
|
|
@@ -75,6 +75,7 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
|
75
75
|
group: z.string().nullable().optional().describe(FIELDS.group.description),
|
|
76
76
|
auto_compact_window: number('auto_compact_window'),
|
|
77
77
|
thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
|
|
78
|
+
planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
|
|
78
79
|
thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
|
|
79
80
|
question_id: text('question_id'),
|
|
80
81
|
// ask: the contract's validator refuses a question or detail that is too
|
|
@@ -615,6 +615,9 @@ export function createClaudeSessionRunner({
|
|
|
615
615
|
// Both Claude billings report to the Claude API booking; DeepSeek has
|
|
616
616
|
// its own.
|
|
617
617
|
...(claudeBillingOf(workerKind) ? { billingUsageRoute: '/api/claude-api-usage', billingKind: workerKind } : {}),
|
|
618
|
+
// Every Claude-family worker books its turns' tokens.
|
|
619
|
+
turnTokenProvider: workerKind,
|
|
620
|
+
...(typeof backend?.turnTokenCost === 'function' ? { turnTokenCost: backend.turnTokenCost } : {}),
|
|
618
621
|
...(backend
|
|
619
622
|
? {
|
|
620
623
|
describeFailure: backend.describeFailure,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
4
5
|
CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
@@ -6,17 +7,12 @@ import {
|
|
|
6
7
|
claudeFailureGuidance,
|
|
7
8
|
} from './claude-turn-failure.mjs';
|
|
8
9
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
10
|
+
import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
|
|
9
11
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
10
12
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
11
13
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
12
14
|
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
13
|
-
import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
14
|
-
|
|
15
|
-
const PLAN_BOARD_ACTIONS = [
|
|
16
|
-
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
17
|
-
{ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
|
|
18
|
-
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
19
|
-
];
|
|
15
|
+
import { claudeBillingTotals, claudeSearchTotals, claudeTokenTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
20
16
|
|
|
21
17
|
/**
|
|
22
18
|
* The attempt-fencing echo for a row's write bodies. Every publish that names
|
|
@@ -96,6 +92,13 @@ export function createClaudeTurnPublisher({
|
|
|
96
92
|
// `billingKind` is the worker's kind.
|
|
97
93
|
billingUsageRoute = null,
|
|
98
94
|
billingKind = null,
|
|
95
|
+
// The token booking (POST /api/turn-token-usage): the provider a
|
|
96
|
+
// turn's tokens are reported under (the worker's kind: 'claude',
|
|
97
|
+
// 'claude-api', 'deepseek'), null for none. `turnTokenCost(entry, at)`
|
|
98
|
+
// prices a model's share of a turn where Claude Code's own estimate does
|
|
99
|
+
// not fit (DeepSeek: `{ costUsd, costSource }`, costUsd null when unknown).
|
|
100
|
+
turnTokenProvider = null,
|
|
101
|
+
turnTokenCost = null,
|
|
99
102
|
} = {}) {
|
|
100
103
|
/** `{ code, stableCode }` of a failure code, in the backend's namespace. */
|
|
101
104
|
function failureCodes(code) {
|
|
@@ -230,6 +233,9 @@ export function createClaudeTurnPublisher({
|
|
|
230
233
|
* Advisory: a failure here must not disturb the turn's response.
|
|
231
234
|
*/
|
|
232
235
|
async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
236
|
+
// Fire-and-forget: the token post never holds the reply (the counting
|
|
237
|
+
// itself runs at once, before the next result can arrive).
|
|
238
|
+
if (turnTokenProvider) void publishTurnTokens({ message, state, runId, resumedSessionId });
|
|
233
239
|
if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
234
240
|
if (usageRoute) {
|
|
235
241
|
await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
@@ -252,6 +258,20 @@ export function createClaudeTurnPublisher({
|
|
|
252
258
|
});
|
|
253
259
|
}
|
|
254
260
|
|
|
261
|
+
// Three trackers below measure a turn from the CLI's results, one per
|
|
262
|
+
// booking, and mirror each other's rules: `run` / runningCounts (DeepSeek's
|
|
263
|
+
// card), `billingRun` / billingCounts (the Claude API booking) and
|
|
264
|
+
// `tokenRun` / turnCounts (the token booking). Each keys on the
|
|
265
|
+
// process (`runId`) and the CLI session id, so a `/clear` starts over;
|
|
266
|
+
// takes a fresh process's `modelUsage` growth over the last result's, but
|
|
267
|
+
// a resumed process's first result (and a result without `modelUsage`) as
|
|
268
|
+
// its turn `usage` alone, the main loop, and a result without `modelUsage`
|
|
269
|
+
// raises the baseline by that `usage`, so the next result's growth does
|
|
270
|
+
// not count it again; lets the baseline only rise, so a late result of an
|
|
271
|
+
// older turn books nothing twice; and counts a turn state once. They differ only in what they keep: hits and misses (cache
|
|
272
|
+
// writes inside) for the first two, the four token kinds apart for the
|
|
273
|
+
// third. A change to one rule belongs in all three.
|
|
274
|
+
|
|
255
275
|
// The CLI process's own token totals and web search and fetch counts per
|
|
256
276
|
// model, counted from zero for each process (`runId`) and CLI session id
|
|
257
277
|
// (a `/clear` changes it and starts the CLI's totals over). `lastSession`
|
|
@@ -475,7 +495,10 @@ export function createClaudeTurnPublisher({
|
|
|
475
495
|
}));
|
|
476
496
|
}
|
|
477
497
|
// The baseline only rises: a late result of an older turn below it
|
|
478
|
-
// must not make the next one count twice.
|
|
498
|
+
// must not make the next one count twice. A result without
|
|
499
|
+
// `modelUsage` grew the CLI's running totals by its `usage`, as `run`
|
|
500
|
+
// has it: the baseline grows by it too, so the next result's growth
|
|
501
|
+
// does not book that turn again.
|
|
479
502
|
if (hasModelUsage) {
|
|
480
503
|
for (const entry of session) {
|
|
481
504
|
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
@@ -486,6 +509,16 @@ export function createClaudeTurnPublisher({
|
|
|
486
509
|
costUsd: Math.max(last.costUsd, entry.costUsd),
|
|
487
510
|
});
|
|
488
511
|
}
|
|
512
|
+
} else {
|
|
513
|
+
for (const entry of claudeBillingTotals(null, { usage: result.usage, model })) {
|
|
514
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
515
|
+
billingRun.lastSession.set(entry.model, {
|
|
516
|
+
hitTokens: last.hitTokens + entry.hitTokens,
|
|
517
|
+
missTokens: last.missTokens + entry.missTokens,
|
|
518
|
+
outputTokens: last.outputTokens + entry.outputTokens,
|
|
519
|
+
costUsd: last.costUsd,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
489
522
|
}
|
|
490
523
|
billingRun.results += 1;
|
|
491
524
|
}
|
|
@@ -496,6 +529,128 @@ export function createClaudeTurnPublisher({
|
|
|
496
529
|
};
|
|
497
530
|
}
|
|
498
531
|
|
|
532
|
+
// The token booking's counts, measured like billingRun above,
|
|
533
|
+
// with the four token kinds apart, then summed per turn: a turn's results
|
|
534
|
+
// (a stopped turn's, the steered rows it folded in) post the turn's total
|
|
535
|
+
// so far under its id, which the relay keeps the larger of. A worker
|
|
536
|
+
// without a process id names one for its own life, so a requeued turn on
|
|
537
|
+
// a restarted worker is a second run, not the same row.
|
|
538
|
+
const tokenRun = { key: '', results: 0, lastSession: new Map() };
|
|
539
|
+
const tokenStates = new WeakSet();
|
|
540
|
+
const turnTokens = new Map();
|
|
541
|
+
const ownRunId = randomUUID();
|
|
542
|
+
const TOKEN_FIELDS = ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens'];
|
|
543
|
+
const TURN_TOKENS_KEPT = 20;
|
|
544
|
+
|
|
545
|
+
function turnCounts(state, result, { turnId, runId, sessionId, resumedSessionId, modelKey }) {
|
|
546
|
+
const model = result.model || state.responseModel;
|
|
547
|
+
const key = `${runId}\n${sessionId}`;
|
|
548
|
+
if (tokenRun.key !== key) {
|
|
549
|
+
tokenRun.key = key;
|
|
550
|
+
tokenRun.results = 0;
|
|
551
|
+
tokenRun.lastSession = new Map();
|
|
552
|
+
}
|
|
553
|
+
// Per run and turn: a turn re-run by a new CLI process (after a crash)
|
|
554
|
+
// is a second row at the relay, holding that run's tokens only.
|
|
555
|
+
const turnKey = `${runId}\n${turnId}`;
|
|
556
|
+
let turn = turnTokens.get(turnKey);
|
|
557
|
+
if (!turn) {
|
|
558
|
+
turn = new Map();
|
|
559
|
+
turnTokens.set(turnKey, turn);
|
|
560
|
+
while (turnTokens.size > TURN_TOKENS_KEPT) turnTokens.delete(turnTokens.keys().next().value);
|
|
561
|
+
}
|
|
562
|
+
if (!tokenStates.has(state)) {
|
|
563
|
+
tokenStates.add(state);
|
|
564
|
+
const at = new Date().toISOString();
|
|
565
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
566
|
+
const resumed = tokenRun.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
567
|
+
const session = hasModelUsage ? claudeTokenTotals(result.modelUsage, modelKey) : [];
|
|
568
|
+
const grown = !hasModelUsage || resumed
|
|
569
|
+
? claudeTokenTotals(null, { usage: result.usage, model, ...modelKey })
|
|
570
|
+
: session.map((entry) => {
|
|
571
|
+
const last = tokenRun.lastSession.get(entry.model);
|
|
572
|
+
const delta = { model: entry.model, costUnknown: entry.costUnknown };
|
|
573
|
+
for (const field of TOKEN_FIELDS) delta[field] = Math.max(0, entry[field] - (last?.[field] || 0));
|
|
574
|
+
delta.costUsd = entry.costUsd === null ? null : Math.max(0, entry.costUsd - (last?.costUsd || 0));
|
|
575
|
+
return delta;
|
|
576
|
+
});
|
|
577
|
+
for (const entry of grown) {
|
|
578
|
+
const priced = typeof turnTokenCost === 'function' ? turnTokenCost(entry, at) : null;
|
|
579
|
+
const costUsd = priced ? priced.costUsd : entry.costUsd;
|
|
580
|
+
const costSource = priced ? priced.costSource : (entry.costUsd === null ? null : 'claude-code');
|
|
581
|
+
const current = turn.get(entry.model) || {
|
|
582
|
+
model: entry.model, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costSource: null, costUnknown: false,
|
|
583
|
+
};
|
|
584
|
+
for (const field of TOKEN_FIELDS) current[field] += entry[field];
|
|
585
|
+
if (costUsd !== null && costUsd !== undefined) current.costUsd = (current.costUsd ?? 0) + costUsd;
|
|
586
|
+
current.costSource = current.costSource || costSource;
|
|
587
|
+
// A backend's own price wins over Claude Code's word on it (DeepSeek's
|
|
588
|
+
// models are unknown to Claude Code's table, yet OAR prices them).
|
|
589
|
+
const unknown = priced ? priced.costUsd === null || priced.costUsd === undefined : (entry.costUnknown || costUsd === null || costUsd === undefined);
|
|
590
|
+
current.costUnknown = current.costUnknown || unknown;
|
|
591
|
+
turn.set(entry.model, current);
|
|
592
|
+
}
|
|
593
|
+
// The baseline only rises: a late result of an older turn below it
|
|
594
|
+
// must not make the next one count twice. A result without
|
|
595
|
+
// `modelUsage` grew the CLI's running totals by its `usage`, as `run`
|
|
596
|
+
// has it: the baseline grows by it too.
|
|
597
|
+
for (const entry of session) {
|
|
598
|
+
const last = tokenRun.lastSession.get(entry.model) || {};
|
|
599
|
+
const next = { costUsd: Math.max(last.costUsd || 0, entry.costUsd || 0) };
|
|
600
|
+
for (const field of TOKEN_FIELDS) next[field] = Math.max(last[field] || 0, entry[field]);
|
|
601
|
+
tokenRun.lastSession.set(entry.model, next);
|
|
602
|
+
}
|
|
603
|
+
if (!hasModelUsage) {
|
|
604
|
+
for (const entry of grown) {
|
|
605
|
+
const last = tokenRun.lastSession.get(entry.model) || {};
|
|
606
|
+
const next = { costUsd: last.costUsd || 0 };
|
|
607
|
+
for (const field of TOKEN_FIELDS) next[field] = (last[field] || 0) + entry[field];
|
|
608
|
+
tokenRun.lastSession.set(entry.model, next);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
tokenRun.results += 1;
|
|
612
|
+
}
|
|
613
|
+
return [...turn.values()]
|
|
614
|
+
.filter((entry) => TOKEN_FIELDS.some((field) => entry[field] > 0) || entry.costUsd > 0)
|
|
615
|
+
.map((entry) => ({ ...entry }));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Ship the turn's tokens per model to the token booking: the
|
|
620
|
+
* turn's total so far, under the turn's id and the process's run id.
|
|
621
|
+
* Reasoning is not reported apart by Claude, so it is left out (NULL).
|
|
622
|
+
* Advisory and fire-and-forget: it never throws and nothing waits on it.
|
|
623
|
+
*/
|
|
624
|
+
async function publishTurnTokens({ message, state, runId = '', resumedSessionId = '' }) {
|
|
625
|
+
try {
|
|
626
|
+
const result = state.result;
|
|
627
|
+
const turnId = String(message?.id || '').trim();
|
|
628
|
+
if (!turnId || !result) return;
|
|
629
|
+
const sessionId = String(result.sessionId || '').trim();
|
|
630
|
+
const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
|
|
631
|
+
const run = String(runId || '') || ownRunId;
|
|
632
|
+
const models = turnCounts(state, result, {
|
|
633
|
+
turnId,
|
|
634
|
+
runId: run,
|
|
635
|
+
sessionId,
|
|
636
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
637
|
+
modelKey,
|
|
638
|
+
});
|
|
639
|
+
if (!models.length) return;
|
|
640
|
+
await api('POST', '/api/turn-token-usage', {
|
|
641
|
+
conversationId: message.conversationId,
|
|
642
|
+
turnId,
|
|
643
|
+
runId: run,
|
|
644
|
+
provider: turnTokenProvider,
|
|
645
|
+
...(sessionId ? { nativeSessionId: sessionId } : {}),
|
|
646
|
+
at: new Date().toISOString(),
|
|
647
|
+
models,
|
|
648
|
+
});
|
|
649
|
+
} catch (error) {
|
|
650
|
+
dbg('turn token publish failed', error?.message || String(error));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
499
654
|
/**
|
|
500
655
|
* Ship the turn's counts to the Claude API booking: the process's own
|
|
501
656
|
* running totals (`runCounts`) and the CLI's session totals
|
|
@@ -159,3 +159,61 @@ export function claudeBillingTotals(modelUsage, { usage = null, model = '' } = {
|
|
|
159
159
|
}
|
|
160
160
|
return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0 || entry.costUsd > 0);
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The CLI's totals per model with the four token kinds apart, for the token
|
|
165
|
+
* booking (POST /api/turn-token-usage): `[{ model, inputTokens,
|
|
166
|
+
* cacheWriteTokens, cacheReadTokens, outputTokens, costUsd, costUnknown }]`.
|
|
167
|
+
* `inputTokens` is fresh input (Anthropic counts cache reads and writes
|
|
168
|
+
* apart). Read like claudeBillingTotals: `modelUsage` (running totals, with
|
|
169
|
+
* Claude Code's cost estimate) or `usage` alone under the turn's model (no
|
|
170
|
+
* cost: `costUsd` null). `modelKey` maps the CLI's model names to the
|
|
171
|
+
* relay's, as in claudeUsageTotals; the `[1m]` tier is dropped. Models with
|
|
172
|
+
* no tokens and no cost are left out.
|
|
173
|
+
*/
|
|
174
|
+
export function claudeTokenTotals(modelUsage, {
|
|
175
|
+
usage = null,
|
|
176
|
+
model = '',
|
|
177
|
+
modelKey = (value) => String(value ?? '').trim(),
|
|
178
|
+
} = {}) {
|
|
179
|
+
const totals = new Map();
|
|
180
|
+
const add = (name, entry) => {
|
|
181
|
+
const key = String(modelKey(name) ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
|
|
182
|
+
if (!key) return;
|
|
183
|
+
const current = totals.get(key) || {
|
|
184
|
+
model: key, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costUnknown: false,
|
|
185
|
+
};
|
|
186
|
+
current.inputTokens += entry.inputTokens;
|
|
187
|
+
current.cacheWriteTokens += entry.cacheWriteTokens;
|
|
188
|
+
current.cacheReadTokens += entry.cacheReadTokens;
|
|
189
|
+
current.outputTokens += entry.outputTokens;
|
|
190
|
+
if (entry.costUsd !== null) current.costUsd = (current.costUsd ?? 0) + entry.costUsd;
|
|
191
|
+
current.costUnknown = current.costUnknown || entry.costUnknown;
|
|
192
|
+
totals.set(key, current);
|
|
193
|
+
};
|
|
194
|
+
if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
|
|
195
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
196
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
197
|
+
add(name, {
|
|
198
|
+
inputTokens: count(entry.inputTokens),
|
|
199
|
+
cacheWriteTokens: count(entry.cacheCreationInputTokens),
|
|
200
|
+
cacheReadTokens: count(entry.cacheReadInputTokens),
|
|
201
|
+
outputTokens: count(entry.outputTokens),
|
|
202
|
+
costUsd: costOf(entry.costUSD),
|
|
203
|
+
costUnknown: entry.costBasis === 'unknown',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else if (usage && typeof usage === 'object') {
|
|
207
|
+
add(model, {
|
|
208
|
+
inputTokens: count(usage.input_tokens),
|
|
209
|
+
cacheWriteTokens: count(usage.cache_creation_input_tokens),
|
|
210
|
+
cacheReadTokens: count(usage.cache_read_input_tokens),
|
|
211
|
+
outputTokens: count(usage.output_tokens),
|
|
212
|
+
costUsd: null,
|
|
213
|
+
costUnknown: false,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return [...totals.values()].filter((entry) => (
|
|
217
|
+
entry.inputTokens + entry.cacheWriteTokens + entry.cacheReadTokens + entry.outputTokens > 0 || entry.costUsd > 0
|
|
218
|
+
));
|
|
219
|
+
}
|
|
@@ -14,16 +14,13 @@
|
|
|
14
14
|
// shape of the final prose. Both paths are wired — the hook is primary, the
|
|
15
15
|
// text heuristic is the fallback — which is the Claude worker's arrangement.
|
|
16
16
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
17
|
+
import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* The one list every worker posts (shared/plan-board-actions.mjs): `exit_only`
|
|
21
|
+
* dismisses; the others queue a follow-up user message in the mapped mode.
|
|
21
22
|
*/
|
|
22
|
-
export
|
|
23
|
-
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
24
|
-
{ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
|
|
25
|
-
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
26
|
-
];
|
|
23
|
+
export { PLAN_BOARD_ACTIONS };
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* How many list-ish lines a final message needs before it is treated as a plan.
|
|
@@ -174,7 +174,7 @@ export function formatSubagentStats(data) {
|
|
|
174
174
|
* figures keep the FIRST call's time-to-first-token (that is the number a user
|
|
175
175
|
* experiences as "did it start answering").
|
|
176
176
|
*/
|
|
177
|
-
function createUsageAccumulator() {
|
|
177
|
+
function createUsageAccumulator({ debugTokenUsage = null } = {}) {
|
|
178
178
|
const totals = {
|
|
179
179
|
inputTokens: 0,
|
|
180
180
|
outputTokens: 0,
|
|
@@ -195,8 +195,61 @@ function createUsageAccumulator() {
|
|
|
195
195
|
isByok: null,
|
|
196
196
|
quotaSnapshots: null,
|
|
197
197
|
};
|
|
198
|
+
// The token booking's view (POST /api/turn-token-usage): the same calls,
|
|
199
|
+
// per model, the four token kinds apart. A field no call of the model
|
|
200
|
+
// carried stays null, so "not reported" is not booked as 0.
|
|
201
|
+
const byModel = new Map();
|
|
202
|
+
let compactionCalls = 0;
|
|
203
|
+
// A compaction's call as `session.compaction_complete` reports it, kept
|
|
204
|
+
// apart until the snapshot: it is booked only if no usage event of the
|
|
205
|
+
// compaction class came in the turn, before or after it.
|
|
206
|
+
const compactionTokens = new Map();
|
|
207
|
+
function addTokens(model, data, into = byModel) {
|
|
208
|
+
const key = String(model || '').trim();
|
|
209
|
+
const bucket = into.get(key) || {
|
|
210
|
+
model: key, inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null,
|
|
211
|
+
};
|
|
212
|
+
const count = (value) => {
|
|
213
|
+
if (value === null || value === undefined || value === '') return null;
|
|
214
|
+
const n = Number(value);
|
|
215
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
216
|
+
};
|
|
217
|
+
const cacheRead = count(data.cacheReadTokens);
|
|
218
|
+
const cacheWrite = count(data.cacheWriteTokens);
|
|
219
|
+
const prompt = count(data.inputTokens);
|
|
220
|
+
// Fresh input is the prompt count less the cache reads and writes, per
|
|
221
|
+
// call, floored at 0. Inferred, not verified: the bundled runtime reads
|
|
222
|
+
// OpenAI-style `prompt_tokens` with `prompt_tokens_details.cached_tokens`
|
|
223
|
+
// (and cache creation), where the prompt count includes the cache. A
|
|
224
|
+
// real-provider check at the merge gate confirms it, reading the raw
|
|
225
|
+
// counts that OAR_DEBUG_TOKEN_USAGE=1 logs.
|
|
226
|
+
const fresh = prompt === null ? null : Math.max(0, prompt - (cacheRead || 0) - (cacheWrite || 0));
|
|
227
|
+
for (const [field, value] of [
|
|
228
|
+
['inputTokens', fresh],
|
|
229
|
+
['outputTokens', count(data.outputTokens)],
|
|
230
|
+
['cacheReadTokens', cacheRead],
|
|
231
|
+
['cacheWriteTokens', cacheWrite],
|
|
232
|
+
['reasoningTokens', count(data.reasoningTokens)],
|
|
233
|
+
]) {
|
|
234
|
+
if (value !== null) bucket[field] = (bucket[field] ?? 0) + value;
|
|
235
|
+
}
|
|
236
|
+
into.set(key, bucket);
|
|
237
|
+
}
|
|
198
238
|
function add(data, { allowModel = true } = {}) {
|
|
199
239
|
if (!data || typeof data !== 'object') return;
|
|
240
|
+
if (typeof debugTokenUsage === 'function') {
|
|
241
|
+
debugTokenUsage({
|
|
242
|
+
model: data.model ?? null,
|
|
243
|
+
interactionType: data.interactionType ?? null,
|
|
244
|
+
inputTokens: data.inputTokens ?? null,
|
|
245
|
+
cacheReadTokens: data.cacheReadTokens ?? null,
|
|
246
|
+
cacheWriteTokens: data.cacheWriteTokens ?? null,
|
|
247
|
+
outputTokens: data.outputTokens ?? null,
|
|
248
|
+
reasoningTokens: data.reasoningTokens ?? null,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
addTokens(data.model, data);
|
|
252
|
+
if (String(data.interactionType || '').trim() === 'conversation-compaction') compactionCalls += 1;
|
|
200
253
|
totals.modelCalls += 1;
|
|
201
254
|
if (!allowModel) totals.subagentModelCalls += 1;
|
|
202
255
|
for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'cost']) {
|
|
@@ -222,13 +275,38 @@ function createUsageAccumulator() {
|
|
|
222
275
|
}
|
|
223
276
|
return {
|
|
224
277
|
add,
|
|
278
|
+
/**
|
|
279
|
+
* A compaction's own model call, from `session.compaction_complete`'s
|
|
280
|
+
* `compactionTokensUsed`: booked unless the runtime reports it, before
|
|
281
|
+
* or after, as an `assistant.usage` of the compaction class.
|
|
282
|
+
*/
|
|
283
|
+
addCompaction(tokensUsed, fallbackModel = '') {
|
|
284
|
+
if (!tokensUsed || typeof tokensUsed !== 'object') return;
|
|
285
|
+
addTokens(tokensUsed.model || fallbackModel, tokensUsed, compactionTokens);
|
|
286
|
+
},
|
|
225
287
|
setQuotaSnapshots(snapshots) {
|
|
226
288
|
if (snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length) {
|
|
227
289
|
totals.quotaSnapshots = snapshots;
|
|
228
290
|
}
|
|
229
291
|
},
|
|
230
292
|
snapshot() {
|
|
231
|
-
|
|
293
|
+
// A call without a model id is the turn's main model's.
|
|
294
|
+
const models = [];
|
|
295
|
+
const buckets = [...byModel.values(), ...(compactionCalls > 0 ? [] : compactionTokens.values())];
|
|
296
|
+
for (const bucket of buckets) {
|
|
297
|
+
const model = bucket.model || totals.model;
|
|
298
|
+
const same = models.find((entry) => entry.model === model);
|
|
299
|
+
if (!same) {
|
|
300
|
+
models.push({ ...bucket, model });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
for (const field of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
|
|
304
|
+
if (bucket[field] !== null) same[field] = (same[field] ?? 0) + bucket[field];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const tokenModels = models.filter((entry) => entry.model);
|
|
308
|
+
if (!totals.modelCalls && !totals.quotaSnapshots && !tokenModels.length) return null;
|
|
309
|
+
return { ...totals, models: tokenModels };
|
|
232
310
|
},
|
|
233
311
|
};
|
|
234
312
|
}
|
|
@@ -242,6 +320,9 @@ export function createCopilotEventNormalizer({
|
|
|
242
320
|
// The runner's answer to "is this agent id a background agent?" — shared
|
|
243
321
|
// across turns, unlike this normalizer's own `backgroundRuns`.
|
|
244
322
|
isBackgroundAgent = () => false,
|
|
323
|
+
// Called with the raw token counts of every model call (no ids), when the
|
|
324
|
+
// worker runs with OAR_DEBUG_TOKEN_USAGE=1.
|
|
325
|
+
debugTokenUsage = null,
|
|
245
326
|
} = {}) {
|
|
246
327
|
let sessionModel = '';
|
|
247
328
|
let sessionId = '';
|
|
@@ -250,7 +331,7 @@ export function createCopilotEventNormalizer({
|
|
|
250
331
|
let terminalEmitted = false;
|
|
251
332
|
let contextUsage = null;
|
|
252
333
|
let interactionId = '';
|
|
253
|
-
const usage = createUsageAccumulator();
|
|
334
|
+
const usage = createUsageAccumulator({ debugTokenUsage });
|
|
254
335
|
|
|
255
336
|
// Assistant text is assembled per `messageId`. A tool-using request produces
|
|
256
337
|
// several assistant messages (the first typically empty with `toolRequests`,
|
|
@@ -866,6 +947,8 @@ export function createCopilotEventNormalizer({
|
|
|
866
947
|
return [activityAction('Compacting the conversation context…')];
|
|
867
948
|
}
|
|
868
949
|
case 'session.compaction_complete': {
|
|
950
|
+
// Its model call costs tokens too (booked with the turn it ran in).
|
|
951
|
+
usage.addCompaction(data.compactionTokensUsed, data.model || sessionModel);
|
|
869
952
|
if (data.success === false) {
|
|
870
953
|
const reason = String(data.error || '').trim();
|
|
871
954
|
return [activityAction(truncate(reason ? `Context compaction failed: ${reason}` : 'Context compaction failed'))];
|