@oar-sh/oar 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,361 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a relay's turns on the saved Claude API key cost, booked from the
5
+ * workers' reports (POST /api/claude-api-usage): the figures behind the Claude
6
+ * API card and its monthly cap (plan decisions 10–12).
7
+ *
8
+ * Both Claude workers report after every turn, the subscription one and the
9
+ * Claude API one, because a chat moves between the two and keeps its Claude
10
+ * Code session. A report carries, per model:
11
+ * - `runCounts`: what this CLI process (`runId`) has run so far, counted from
12
+ * zero, with Claude Code's own cost estimate (`costUSD`);
13
+ * - `sessionTotals`: the CLI's cumulative totals for the session, which a
14
+ * process resumed on the session (after a switch, a restart, an idle
15
+ * shutdown) re-reports in its first result, the earlier turns included.
16
+ *
17
+ * Booking (claude_api_usage_checkpoints, migration 0033):
18
+ * - Spend is the rise of a run's counts over what was booked for that run; a
19
+ * report at or below it is a retried or reordered one and books nothing.
20
+ * Only `claude-api` reports book spend; a subscription report only keeps
21
+ * its checkpoint, the session's mark.
22
+ * - A resumed run's first result is where the run's own counts fall short:
23
+ * they hold that turn's main agent loop only. That turn is booked as the
24
+ * rise of `sessionTotals` over the session's mark (the checkpoint with the
25
+ * latest turn), when the mark covers the session's last finished turn; the
26
+ * run's checkpoint then starts from the report's own counts, so its next
27
+ * result books only its own rise.
28
+ * - A mark that is older than the last finished turn (a report was lost, or
29
+ * turns ran on a relay that did not report) is not used: the turn's own
30
+ * counts are booked, as tokens without a price, and that is logged.
31
+ * - Totals that fall below the mark (a CLI that counts the session from
32
+ * zero after a resume) are booked from the run's own counts.
33
+ *
34
+ * Spend goes to claude_api_usage_totals (migration 0034) per local day and
35
+ * model; a model Claude Code has no price for is booked as tokens only.
36
+ */
37
+
38
+ import { claudeBaseModelId } from '../../shared/model-id.mjs';
39
+ import { migrateClaudeApiUsageCheckpoints } from '../migrations/0033-claude-api-usage-checkpoints.mjs';
40
+ import { migrateClaudeApiUsageTotals } from '../migrations/0034-claude-api-usage-totals.mjs';
41
+
42
+ export const CLAUDE_API_USAGE_BILLINGS = Object.freeze(['claude', 'claude-api']);
43
+ const TOKEN_FIELDS = Object.freeze(['hitTokens', 'missTokens', 'outputTokens']);
44
+ const CHECKPOINT_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
45
+ const PRUNE_EVERY_MS = 60 * 60 * 1000;
46
+
47
+ function count(value) {
48
+ const number = Number(value ?? 0);
49
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
50
+ }
51
+
52
+ function cost(value) {
53
+ const number = Number(value ?? 0);
54
+ return Number.isFinite(number) && number > 0 ? number : 0;
55
+ }
56
+
57
+ function bookedModel(value) {
58
+ return claudeBaseModelId(String(value ?? '').trim()).toLowerCase();
59
+ }
60
+
61
+ /** `[{ model, hitTokens, missTokens, outputTokens, costUsd, costUnknown }]`, summed per booked model id. */
62
+ export function normalizeClaudeApiUsageEntries(entries) {
63
+ const byModel = new Map();
64
+ for (const entry of Array.isArray(entries) ? entries : []) {
65
+ const model = bookedModel(entry?.model);
66
+ if (!model) continue;
67
+ const current = byModel.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
68
+ for (const field of TOKEN_FIELDS) current[field] += count(entry?.[field]);
69
+ current.costUsd += cost(entry?.costUsd);
70
+ current.costUnknown = current.costUnknown || entry?.costUnknown === true;
71
+ byModel.set(model, current);
72
+ }
73
+ return [...byModel.values()];
74
+ }
75
+
76
+ /**
77
+ * The report a worker posts, checked: `{ ok, report }` or `{ ok: false,
78
+ * error }`. `billing` is the caller's to settle (the worker's registered
79
+ * kind); the body's is only a fallback.
80
+ */
81
+ export function normalizeClaudeApiUsageReport(body = {}) {
82
+ const nativeSessionId = String(body?.claudeNativeSessionId || '').trim();
83
+ const runId = String(body?.runId || '').trim();
84
+ const at = String(body?.at || '').trim();
85
+ if (!nativeSessionId) return { ok: false, error: 'Missing claudeNativeSessionId' };
86
+ if (!runId) return { ok: false, error: 'Missing runId' };
87
+ if (!Number.isFinite(Date.parse(at))) return { ok: false, error: 'Missing or invalid at' };
88
+ const runCounts = normalizeClaudeApiUsageEntries(body?.runCounts);
89
+ const sessionTotals = normalizeClaudeApiUsageEntries(body?.sessionTotals);
90
+ if (!runCounts.length && !sessionTotals.length) return { ok: false, error: 'Missing runCounts and sessionTotals' };
91
+ return {
92
+ ok: true,
93
+ report: {
94
+ nativeSessionId,
95
+ runId,
96
+ at: new Date(Date.parse(at)).toISOString(),
97
+ turnId: String(body?.turnId || '').trim() || null,
98
+ firstResultOfRun: body?.firstResultOfRun === true,
99
+ resumedSessionId: String(body?.resumedSessionId || '').trim() || null,
100
+ billing: String(body?.billing || '').trim().toLowerCase(),
101
+ runCounts,
102
+ sessionTotals,
103
+ },
104
+ };
105
+ }
106
+
107
+ function dayFormatter(timeZone) {
108
+ try {
109
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
110
+ } catch {
111
+ return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
112
+ }
113
+ }
114
+
115
+ export function createClaudeApiUsageService({
116
+ db,
117
+ now = () => Date.now(),
118
+ // The relay's time zone (the cap's calendar month is the relay's); a test
119
+ // names one.
120
+ timeZone = undefined,
121
+ logger = console,
122
+ } = {}) {
123
+ if (!db) throw new Error('createClaudeApiUsageService requires db');
124
+ // The schema boot runs these too; a boot where they failed retries here,
125
+ // before the statements below are prepared.
126
+ migrateClaudeApiUsageCheckpoints(db);
127
+ migrateClaudeApiUsageTotals(db);
128
+ const formatDay = dayFormatter(timeZone);
129
+ const localDay = (iso) => formatDay.format(new Date(iso));
130
+
131
+ const getRunCheckpoint = db.prepare(`
132
+ SELECT * FROM claude_api_usage_checkpoints
133
+ WHERE conversation_id = ? AND native_session_id = ? AND run_id = ? AND model = ?
134
+ `);
135
+ // The session's mark: the latest report's run, per model.
136
+ const getMarkRun = db.prepare(`
137
+ SELECT run_id, last_turn_id, last_turn_at FROM claude_api_usage_checkpoints
138
+ WHERE native_session_id = ? AND run_id != ? AND last_turn_at IS NOT NULL
139
+ ORDER BY last_turn_at DESC LIMIT 1
140
+ `);
141
+ const listMarkRows = db.prepare(`
142
+ SELECT * FROM claude_api_usage_checkpoints WHERE native_session_id = ? AND run_id = ?
143
+ `);
144
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
145
+ // The session's last finished turn before this one: the latest reply of
146
+ // the chat that answers a queue row (relay notes answer none).
147
+ const getPreviousTurn = messageColumns.has('source_message_id')
148
+ ? db.prepare(`
149
+ SELECT source_message_id AS turn_id, timestamp FROM messages
150
+ WHERE conversation_id = ? AND role = 'assistant'
151
+ AND source_message_id IS NOT NULL AND source_message_id != ?
152
+ ORDER BY timestamp DESC, rowid DESC LIMIT 1
153
+ `)
154
+ : null;
155
+ const upsertCheckpoint = db.prepare(`
156
+ INSERT INTO claude_api_usage_checkpoints (
157
+ conversation_id, native_session_id, run_id, model, billing,
158
+ run_hit_tokens, run_miss_tokens, run_output_tokens, run_cost_usd,
159
+ session_hit_tokens, session_miss_tokens, session_output_tokens, session_cost_usd,
160
+ cost_unknown, last_turn_id, last_turn_at, updated_at
161
+ ) VALUES (
162
+ @conversationId, @nativeSessionId, @runId, @model, @billing,
163
+ @runHit, @runMiss, @runOutput, @runCost,
164
+ @sessionHit, @sessionMiss, @sessionOutput, @sessionCost,
165
+ @costUnknown, @lastTurnId, @lastTurnAt, @updatedAt
166
+ )
167
+ ON CONFLICT (conversation_id, native_session_id, run_id, model) DO UPDATE SET
168
+ billing = excluded.billing,
169
+ run_hit_tokens = excluded.run_hit_tokens,
170
+ run_miss_tokens = excluded.run_miss_tokens,
171
+ run_output_tokens = excluded.run_output_tokens,
172
+ run_cost_usd = excluded.run_cost_usd,
173
+ session_hit_tokens = excluded.session_hit_tokens,
174
+ session_miss_tokens = excluded.session_miss_tokens,
175
+ session_output_tokens = excluded.session_output_tokens,
176
+ session_cost_usd = excluded.session_cost_usd,
177
+ cost_unknown = excluded.cost_unknown,
178
+ last_turn_id = excluded.last_turn_id,
179
+ last_turn_at = excluded.last_turn_at,
180
+ updated_at = excluded.updated_at
181
+ `);
182
+ const addTotals = db.prepare(`
183
+ INSERT INTO claude_api_usage_totals (
184
+ day, model, hit_tokens, miss_tokens, output_tokens, cost_usd, unknown_cost_tokens, turn_count,
185
+ first_seen_at, last_seen_at, updated_at
186
+ ) VALUES (@day, @model, @hit, @miss, @output, @cost, @unknownTokens, 1, @at, @at, @updatedAt)
187
+ ON CONFLICT (day, model) DO UPDATE SET
188
+ hit_tokens = hit_tokens + excluded.hit_tokens,
189
+ miss_tokens = miss_tokens + excluded.miss_tokens,
190
+ output_tokens = output_tokens + excluded.output_tokens,
191
+ cost_usd = cost_usd + excluded.cost_usd,
192
+ unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
193
+ turn_count = turn_count + 1,
194
+ first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
195
+ last_seen_at = MAX(last_seen_at, excluded.last_seen_at),
196
+ updated_at = excluded.updated_at
197
+ `);
198
+ const pruneCheckpoints = db.prepare(`
199
+ DELETE FROM claude_api_usage_checkpoints
200
+ WHERE updated_at < ?
201
+ AND run_id != (
202
+ SELECT newest.run_id FROM claude_api_usage_checkpoints newest
203
+ WHERE newest.native_session_id = claude_api_usage_checkpoints.native_session_id
204
+ ORDER BY newest.last_turn_at DESC LIMIT 1
205
+ )
206
+ `);
207
+ const readDays = db.prepare(`
208
+ SELECT * FROM claude_api_usage_totals WHERE day >= ? AND day <= ? ORDER BY day ASC, model ASC
209
+ `);
210
+ let lastPruneAt = 0;
211
+
212
+ const tokensOf = (entry) => TOKEN_FIELDS.reduce((sum, field) => sum + count(entry?.[field]), 0);
213
+ const subtract = (from, base) => {
214
+ const out = { model: from.model, costUnknown: from.costUnknown === true };
215
+ for (const field of TOKEN_FIELDS) out[field] = count(from[field]) - count(base?.[field]);
216
+ out.costUsd = cost(from.costUsd) - cost(base?.costUsd);
217
+ return out;
218
+ };
219
+ const fromRow = (row, prefix) => (row
220
+ ? {
221
+ hitTokens: count(row[`${prefix}_hit_tokens`]),
222
+ missTokens: count(row[`${prefix}_miss_tokens`]),
223
+ outputTokens: count(row[`${prefix}_output_tokens`]),
224
+ costUsd: cost(row[`${prefix}_cost_usd`]),
225
+ }
226
+ : null);
227
+
228
+ /**
229
+ * Book one report. `billing` is the kind of the worker that posted it.
230
+ * Returns `{ billing, basis, booked: [{ model, ...delta }] }`; `basis` says
231
+ * how a resumed run's first result was booked ('mark', 'run', 'stale-mark',
232
+ * 'no-mark'), null otherwise.
233
+ */
234
+ function recordReport({ conversationId, billing, report }) {
235
+ const convId = String(conversationId || '').trim();
236
+ const kind = CLAUDE_API_USAGE_BILLINGS.includes(billing) ? billing : '';
237
+ if (!convId || !kind || !report) return null;
238
+ const updatedAt = new Date(now()).toISOString();
239
+ const runByModel = new Map(report.runCounts.map((entry) => [entry.model, entry]));
240
+ const sessionByModel = new Map(report.sessionTotals.map((entry) => [entry.model, entry]));
241
+ const models = [...new Set([...runByModel.keys(), ...sessionByModel.keys()])];
242
+ const booked = [];
243
+ let basis = null;
244
+ const write = db.transaction(() => {
245
+ const seenRun = models.some((model) => getRunCheckpoint.get(convId, report.nativeSessionId, report.runId, model));
246
+ const resumedFirst = !seenRun
247
+ && report.firstResultOfRun
248
+ && report.resumedSessionId === report.nativeSessionId;
249
+ // A resumed run's first result, measured against the session's mark.
250
+ let markRows = null;
251
+ if (resumedFirst) {
252
+ const markRun = getMarkRun.get(report.nativeSessionId, report.runId) || null;
253
+ if (!markRun) {
254
+ basis = 'no-mark';
255
+ } else {
256
+ // The mark covers the last finished turn when that turn's reply is
257
+ // the mark's own, or no reply came after the mark (a turn that
258
+ // left none, a stop, still has its report). A reply of another
259
+ // turn after the mark means a report went missing.
260
+ const previous = getPreviousTurn?.get(convId, report.turnId || '') || null;
261
+ const fresh = !previous
262
+ || String(previous.turn_id) === String(markRun.last_turn_id || '')
263
+ || String(previous.timestamp || '') <= String(markRun.last_turn_at || '');
264
+ if (!fresh) {
265
+ basis = 'stale-mark';
266
+ } else {
267
+ markRows = new Map(listMarkRows.all(report.nativeSessionId, markRun.run_id).map((row) => [row.model, row]));
268
+ const dropped = [...sessionByModel.values()].some((entry) => {
269
+ const mark = fromRow(markRows.get(entry.model), 'session');
270
+ return mark && TOKEN_FIELDS.some((field) => count(entry[field]) < mark[field]);
271
+ });
272
+ basis = dropped ? 'run' : 'mark';
273
+ if (dropped) markRows = null;
274
+ }
275
+ }
276
+ }
277
+ for (const model of models) {
278
+ const run = runByModel.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
279
+ const session = sessionByModel.get(model) || null;
280
+ const costUnknown = run.costUnknown === true || session?.costUnknown === true;
281
+ const previous = fromRow(getRunCheckpoint.get(convId, report.nativeSessionId, report.runId, model), 'run');
282
+ let delta = null;
283
+ let priceKnown = !costUnknown;
284
+ if (previous) {
285
+ // The same run again: its rise; a report below it is stale.
286
+ const rise = subtract(run, previous);
287
+ if (TOKEN_FIELDS.some((field) => rise[field] < 0)) continue;
288
+ delta = rise;
289
+ } else if (basis === 'mark') {
290
+ const mark = fromRow(markRows?.get(model), 'session');
291
+ delta = session ? subtract(session, mark) : { ...run };
292
+ } else if (basis === 'stale-mark' || basis === 'no-mark' || basis === 'run') {
293
+ // A resumed run's first result without a usable mark: the turn's
294
+ // own counts, its main agent loop only, which carry no price.
295
+ delta = { ...run, costUsd: 0 };
296
+ priceKnown = false;
297
+ } else {
298
+ delta = { ...run };
299
+ }
300
+ for (const field of TOKEN_FIELDS) delta[field] = Math.max(0, count(delta[field]));
301
+ delta.costUsd = priceKnown ? Math.max(0, Number(delta.costUsd) || 0) : 0;
302
+ upsertCheckpoint.run({
303
+ conversationId: convId,
304
+ nativeSessionId: report.nativeSessionId,
305
+ runId: report.runId,
306
+ model,
307
+ billing: kind,
308
+ runHit: count(run.hitTokens),
309
+ runMiss: count(run.missTokens),
310
+ runOutput: count(run.outputTokens),
311
+ runCost: cost(run.costUsd),
312
+ sessionHit: count(session?.hitTokens),
313
+ sessionMiss: count(session?.missTokens),
314
+ sessionOutput: count(session?.outputTokens),
315
+ sessionCost: cost(session?.costUsd),
316
+ costUnknown: costUnknown ? 1 : 0,
317
+ lastTurnId: report.turnId,
318
+ lastTurnAt: report.at,
319
+ updatedAt,
320
+ });
321
+ if (kind !== 'claude-api' || tokensOf(delta) + delta.costUsd <= 0) continue;
322
+ addTotals.run({
323
+ day: localDay(report.at),
324
+ model,
325
+ hit: delta.hitTokens,
326
+ miss: delta.missTokens,
327
+ output: delta.outputTokens,
328
+ cost: delta.costUsd,
329
+ unknownTokens: priceKnown ? 0 : tokensOf(delta),
330
+ at: report.at,
331
+ updatedAt,
332
+ });
333
+ booked.push({ model, ...delta, priceKnown });
334
+ }
335
+ if (now() - lastPruneAt > PRUNE_EVERY_MS) {
336
+ lastPruneAt = now();
337
+ pruneCheckpoints.run(new Date(now() - CHECKPOINT_RETENTION_MS).toISOString());
338
+ }
339
+ });
340
+ write();
341
+ if (basis === 'stale-mark' || basis === 'no-mark') {
342
+ try {
343
+ logger.warn?.(`[claude-api-usage] conv=${convId.slice(0, 8)} resumed run ${report.runId.slice(0, 8)}: ${basis === 'no-mark' ? 'no mark' : 'mark stale'}; its first turn is booked from its own counts, without a price`);
344
+ } catch {}
345
+ }
346
+ return { billing: kind, basis, booked };
347
+ }
348
+
349
+ /** Booked rows per day and model, `fromDay`..`toDay` inclusive (local `YYYY-MM-DD`). */
350
+ function readDaysBetween(fromDay, toDay) {
351
+ return readDays.all(String(fromDay || ''), String(toDay || '')).map((row) => ({ ...row }));
352
+ }
353
+
354
+ return {
355
+ recordReport,
356
+ readDaysBetween,
357
+ localDay,
358
+ // The zone `localDay` keeps (the card names it).
359
+ timeZone: formatDay.resolvedOptions().timeZone || '',
360
+ };
361
+ }
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The worker swap behind the Claude billing switch.
5
+ *
6
+ * A Claude chat's worker is of one kind for its whole life: `claude` runs the
7
+ * CLI on the host's subscription login, `claude-api` on the saved API key
8
+ * (which reaches only that kind's launch). After a switch, the chat's next
9
+ * row is of the other billing, and the delivery refuses it to the worker it
10
+ * has (claudeBillingFence, messages-routes.mjs). This service then replaces
11
+ * that worker, between turns:
12
+ *
13
+ * - it waits while the session owns a turn the relay handed out (a
14
+ * `processing` row: running, delivered and not yet started, a cold spawn
15
+ * on its way), and while the worker is not releasable: a turn, an open
16
+ * question, a compaction, background tasks (the worker's steering snapshot
17
+ * says which, and the composer shows it); a worker with no CLI process and
18
+ * no delivery on its way in is releasable;
19
+ * - it stops the worker and every process of the session, and launches the
20
+ * new kind only once they are gone, never reusing a live one, so two CLIs
21
+ * never share the session's transcript;
22
+ * - a stop that fails is retried with a backoff, and after a few the chat
23
+ * gets a note (`onStuck`);
24
+ * - the first delivery to the new worker carries a note of the swap
25
+ * (`takeSwapNote`), which the worker shows in the turn.
26
+ *
27
+ * A switch back before the swap needs nothing here: the rows are re-stamped,
28
+ * the fence lets them through, and the waiting state ages out.
29
+ */
30
+
31
+ export const CLAUDE_BILLING_SWAP_WAIT_TTL_MS = 15_000;
32
+
33
+ /**
34
+ * What keeps a worker from being replaced now: null when nothing does, else
35
+ * 'turn', 'question', 'compaction' or 'background'. From the steering
36
+ * snapshot its heartbeat sends; a worker too old to say `releasable` is read
37
+ * from the rest of it.
38
+ */
39
+ export function claudeWorkerReleaseHold(worker) {
40
+ if (!worker) return null;
41
+ const steering = worker.steering || null;
42
+ if (steering && typeof steering.releasable === 'boolean') {
43
+ return steering.releasable ? null : (steering.releaseHold || 'turn');
44
+ }
45
+ if (steering) {
46
+ if (steering.holdReason === 'question') return 'question';
47
+ if (steering.holdReason === 'compaction') return 'compaction';
48
+ if (steering.turnActive) return 'turn';
49
+ if (steering.autoCompactWindow?.backgroundWork) return 'background';
50
+ return null;
51
+ }
52
+ return String(worker.status || '') === 'processing' ? 'turn' : null;
53
+ }
54
+
55
+ export function createClaudeBillingSwapService({
56
+ // sid → the registry's entry for the session's worker, or null.
57
+ getWorker = () => null,
58
+ // sid → whether the session owns a turn the relay handed out and has not
59
+ // seen finish (a `processing` queue row: a running turn, a delivery not yet
60
+ // acknowledged, a continuation). The relay's own record, asked before the
61
+ // worker's last snapshot, which can lag a claim by a pulse.
62
+ hasRunningTurn = () => false,
63
+ // async sid → { ok, error?, remainingPids? }: every process of the session
64
+ // stopped, and the registry and the supervisor's restart state cleared.
65
+ stopWorker,
66
+ // async sid → { ok, error? }: launch the session's worker (its kind follows
67
+ // the chat's billing), never reusing a live process.
68
+ launchWorker,
69
+ // ({ sdkSessionId, conversationId, rowBilling, attempts, error }): the
70
+ // old worker would not stop, again; once per episode.
71
+ onStuck = () => {},
72
+ // ({ sdkSessionId, conversationId, pending }) whenever the waiting state
73
+ // of a chat changes (null once it is over).
74
+ onPendingChange = () => {},
75
+ now = () => Date.now(),
76
+ retryBaseMs = 5_000,
77
+ retryMaxMs = 60_000,
78
+ stuckAfter = 3,
79
+ // A running-turn probe that fails is logged at most this often per session,
80
+ // and reaches the chat once it has failed `stuckAfter` times over at least
81
+ // `probeStuckAfterMs` (a brief database hiccup says nothing).
82
+ probeLogEveryMs = 60_000,
83
+ probeStuckAfterMs = 30_000,
84
+ logger = console,
85
+ } = {}) {
86
+ if (typeof stopWorker !== 'function' || typeof launchWorker !== 'function') {
87
+ throw new Error('createClaudeBillingSwapService requires stopWorker and launchWorker');
88
+ }
89
+ const bySession = new Map();
90
+ const swapNotes = new Map();
91
+
92
+ function stateFor(sid) {
93
+ let state = bySession.get(sid);
94
+ if (!state) {
95
+ state = {
96
+ inFlight: false,
97
+ failures: 0,
98
+ nextAttemptAt: 0,
99
+ // The two causes of a stuck swap are noted apart: one does not
100
+ // silence the other.
101
+ stuckNoted: false,
102
+ probeFailures: 0,
103
+ probeFailingSince: 0,
104
+ probeStuckNoted: false,
105
+ lastProbeLogAt: -Infinity,
106
+ pending: null,
107
+ conversationId: '',
108
+ };
109
+ bySession.set(sid, state);
110
+ }
111
+ return state;
112
+ }
113
+
114
+ function setPending(sid, state, pending) {
115
+ const before = JSON.stringify(state.pending ? { ...state.pending, seenAt: 0 } : null);
116
+ state.pending = pending ? { ...pending, seenAt: now() } : null;
117
+ const after = JSON.stringify(state.pending ? { ...state.pending, seenAt: 0 } : null);
118
+ if (before === after) return;
119
+ try {
120
+ onPendingChange({
121
+ sdkSessionId: sid,
122
+ conversationId: state.conversationId,
123
+ pending: state.pending ? publicPending(state.pending) : null,
124
+ });
125
+ } catch {}
126
+ }
127
+
128
+ function publicPending(pending) {
129
+ return {
130
+ to: pending.to,
131
+ waitingFor: pending.waitingFor || null,
132
+ backgroundTaskCount: Number(pending.backgroundTaskCount || 0),
133
+ swapping: pending.swapping === true,
134
+ };
135
+ }
136
+
137
+ async function run(sid, state, fence) {
138
+ let stopped = null;
139
+ try {
140
+ stopped = await stopWorker(sid);
141
+ } catch (error) {
142
+ stopped = { ok: false, error: error?.message || String(error) };
143
+ }
144
+ if (!stopped?.ok) {
145
+ state.failures += 1;
146
+ state.nextAttemptAt = now() + Math.min(retryMaxMs, retryBaseMs * (2 ** (state.failures - 1)));
147
+ try { logger.warn?.(`[claude-billing] swap of ${sid.slice(0, 8)} could not stop the old worker (${state.failures}): ${stopped?.error || 'unknown'}`); } catch {}
148
+ if (state.failures >= stuckAfter && !state.stuckNoted) {
149
+ state.stuckNoted = true;
150
+ try {
151
+ onStuck({
152
+ sdkSessionId: sid,
153
+ conversationId: state.conversationId,
154
+ rowBilling: fence.rowBilling,
155
+ attempts: state.failures,
156
+ error: stopped?.error || null,
157
+ reason: 'stop',
158
+ });
159
+ } catch {}
160
+ }
161
+ setPending(sid, state, { to: fence.rowBilling, waitingFor: 'stop', backgroundTaskCount: 0 });
162
+ return;
163
+ }
164
+ swapNotes.set(sid, { from: fence.workerKind || null, to: fence.rowBilling });
165
+ let launched = null;
166
+ try {
167
+ launched = await launchWorker(sid);
168
+ } catch (error) {
169
+ launched = { ok: false, error: error?.message || String(error) };
170
+ }
171
+ state.failures = 0;
172
+ state.stuckNoted = false;
173
+ state.nextAttemptAt = 0;
174
+ try {
175
+ logger.log?.(`[claude-billing] swapped ${sid.slice(0, 8)} ${fence.workerKind || 'unknown'} → ${fence.rowBilling}${launched?.ok ? '' : ` (launch: ${launched?.error || 'pending'})`}`);
176
+ } catch {}
177
+ setPending(sid, state, null);
178
+ }
179
+
180
+ /**
181
+ * A delivery refused a row to this session's worker (the fence). Starts
182
+ * the swap when the worker is releasable; otherwise notes what it waits
183
+ * for. Returns `{ state: 'swapping' | 'waiting' | 'in-flight' | 'backoff',
184
+ * waitingFor? }`.
185
+ */
186
+ function request(fence = {}) {
187
+ const sid = String(fence.requesterSessionId || '').trim();
188
+ if (!sid || !fence.rowBilling) return { state: 'ignored' };
189
+ const state = stateFor(sid);
190
+ state.conversationId = String(fence.conversationId || state.conversationId || '');
191
+ if (state.inFlight) return { state: 'in-flight' };
192
+ if (now() < state.nextAttemptAt) {
193
+ if (state.pending) state.pending.seenAt = now();
194
+ return { state: 'backoff' };
195
+ }
196
+ const worker = getWorker(sid);
197
+ let running = false;
198
+ try {
199
+ running = hasRunningTurn(sid) === true;
200
+ // The probe answers: its failure streak, and its note, are over.
201
+ state.probeFailures = 0;
202
+ state.probeFailingSince = 0;
203
+ state.probeStuckNoted = false;
204
+ } catch (error) {
205
+ // Unknown counts as running: the old worker is never stopped on a
206
+ // guess. Said in the log (not on every fence), and a probe that keeps
207
+ // failing reaches the chat as the stuck note.
208
+ running = true;
209
+ noteProbeFailure(sid, state, fence, error);
210
+ }
211
+ const hold = running ? 'turn' : claudeWorkerReleaseHold(worker);
212
+ if (hold) {
213
+ setPending(sid, state, {
214
+ to: fence.rowBilling,
215
+ waitingFor: hold,
216
+ backgroundTaskCount: hold === 'background' ? Number(worker?.steering?.backgroundTaskCount || 0) : 0,
217
+ });
218
+ return { state: 'waiting', waitingFor: hold };
219
+ }
220
+ state.inFlight = true;
221
+ setPending(sid, state, { to: fence.rowBilling, waitingFor: null, swapping: true });
222
+ void run(sid, state, fence).finally(() => { state.inFlight = false; });
223
+ return { state: 'swapping' };
224
+ }
225
+
226
+ function noteProbeFailure(sid, state, fence, error) {
227
+ if (state.probeFailures === 0) state.probeFailingSince = now();
228
+ state.probeFailures += 1;
229
+ const message = String(error?.message || error || 'unknown');
230
+ if (now() - state.lastProbeLogAt >= probeLogEveryMs) {
231
+ state.lastProbeLogAt = now();
232
+ try { logger.warn?.(`[claude-billing] swap of ${sid.slice(0, 8)} cannot tell whether a turn runs (${state.probeFailures}): ${message}; waiting`); } catch {}
233
+ }
234
+ const lasting = now() - state.probeFailingSince >= probeStuckAfterMs;
235
+ if (state.probeFailures >= stuckAfter && lasting && !state.probeStuckNoted) {
236
+ state.probeStuckNoted = true;
237
+ try {
238
+ onStuck({
239
+ sdkSessionId: sid,
240
+ conversationId: state.conversationId,
241
+ rowBilling: fence.rowBilling,
242
+ attempts: state.probeFailures,
243
+ error: message,
244
+ reason: 'probe',
245
+ });
246
+ } catch {}
247
+ }
248
+ }
249
+
250
+ /** The note the first delivery after a swap carries, once. */
251
+ function takeSwapNote(sid) {
252
+ const key = String(sid || '').trim();
253
+ const note = swapNotes.get(key) || null;
254
+ if (note) swapNotes.delete(key);
255
+ return note;
256
+ }
257
+
258
+ /**
259
+ * What a chat waits for, for the composer: null when nothing, or the
260
+ * fence stopped asking (a switch back, the swap done) long enough ago.
261
+ */
262
+ function pendingFor(sid) {
263
+ const state = bySession.get(String(sid || '').trim());
264
+ if (!state?.pending) return null;
265
+ if (!state.inFlight && now() - Number(state.pending.seenAt || 0) > CLAUDE_BILLING_SWAP_WAIT_TTL_MS) return null;
266
+ return publicPending(state.pending);
267
+ }
268
+
269
+ return {
270
+ request,
271
+ takeSwapNote,
272
+ pendingFor,
273
+ };
274
+ }