@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
@@ -1,10 +1,16 @@
1
1
  import fs from 'node:fs';
2
- import { classifyClaudeResultFailure, claudeFailureGuidance } from './claude-turn-failure.mjs';
2
+ import {
3
+ CLAUDE_LIVE_FLAG_SETTINGS_CODE,
4
+ CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
5
+ classifyClaudeResultFailure,
6
+ claudeFailureGuidance,
7
+ } from './claude-turn-failure.mjs';
3
8
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
4
9
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
5
10
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
6
11
  import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
7
12
  import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
13
+ import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
8
14
 
9
15
  const PLAN_BOARD_ACTIONS = [
10
16
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -61,7 +67,60 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
61
67
  * mutable turn state, so the session process can run any number of turns
62
68
  * (delivered or background-continuation) through one publisher.
63
69
  */
64
- export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
70
+ export function createClaudeTurnPublisher({
71
+ api,
72
+ dbg = () => {},
73
+ takeWorkflowRuns = null,
74
+ // A backend's own reading of a failed request (`{ code, guidance }` or
75
+ // null) and its name in the notes; null for Claude. It gets the error a
76
+ // turn ended with, when there is one, beside its text and status.
77
+ describeFailure = null,
78
+ runtimeLabel = 'Claude',
79
+ // The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
80
+ // failures print as `relay.deepseek-<code>`, which the browser's buttons
81
+ // key on (relay-error-ctas.mjs), not as Claude's `relay.claude-<code>`.
82
+ failureCodePrefix = null,
83
+ // False for a backend that is not Claude's: no Claude plan windows or cost.
84
+ reportsPlanUsage = true,
85
+ // A backend's own usage route (DeepSeek: /api/deepseek-plan-usage), which
86
+ // takes the CLI's token totals per model in place of the Claude plan
87
+ // usage, and the relay's id of a model the CLI names.
88
+ usageRoute = null,
89
+ usageModelKey = null,
90
+ // The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
91
+ // → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
92
+ relayModel = (value) => value,
93
+ // The Claude API booking (POST /api/claude-api-usage), which both Claude
94
+ // workers report to after every turn, whatever else they report: a chat
95
+ // moves between the subscription and the key and keeps its session.
96
+ // `billingKind` is the worker's kind.
97
+ billingUsageRoute = null,
98
+ billingKind = null,
99
+ } = {}) {
100
+ /** `{ code, stableCode }` of a failure code, in the backend's namespace. */
101
+ function failureCodes(code) {
102
+ if (!failureCodePrefix) return { code, stableCode: `claude.${code}` };
103
+ const prefixed = String(code).startsWith(`${failureCodePrefix}-`) ? code : `${failureCodePrefix}-${String(code).replace(/_/g, '-')}`;
104
+ return { code: prefixed, stableCode: prefixed };
105
+ }
106
+
107
+ /**
108
+ * The Claude reading of a failed result, in the backend's namespace. A
109
+ * backend gets no Claude account or sign-in advice: the relay's default
110
+ * fits where the backend has none of its own.
111
+ */
112
+ function classifyResultFailure(result) {
113
+ const classified = classifyClaudeResultFailure(result);
114
+ if (!failureCodePrefix) return classified;
115
+ return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
116
+ }
117
+
118
+ function backendFailure(errorText, status = null, error = null) {
119
+ if (typeof describeFailure !== 'function') return null;
120
+ const described = describeFailure(errorText, { status, ...(error ? { error } : {}) });
121
+ return described?.code ? described : null;
122
+ }
123
+
65
124
  function drainWorkflowRuns({ terminal = false } = {}) {
66
125
  if (terminal || typeof takeWorkflowRuns !== 'function') return null;
67
126
  return takeWorkflowRuns();
@@ -150,11 +209,14 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
150
209
  */
151
210
  async function publishContextUsage({ message, state, model, sdkSessionId }) {
152
211
  if (!state.contextUsage && !state.modelUsage) return;
212
+ const contextUsage = state.contextUsage && typeof state.contextUsage === 'object' && state.contextUsage.model
213
+ ? { ...state.contextUsage, model: relayModel(state.contextUsage.model) }
214
+ : state.contextUsage;
153
215
  await api('POST', '/api/claude-context-usage', {
154
216
  conversationId: message.conversationId,
155
217
  sdkSessionId,
156
- model: state.responseModel || model || null,
157
- contextUsage: state.contextUsage,
218
+ model: relayModel(state.responseModel || model || null),
219
+ contextUsage,
158
220
  modelUsage: state.modelUsage,
159
221
  }).catch((error) => {
160
222
  dbg('context usage publish failed', error?.message || String(error));
@@ -167,7 +229,13 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
167
229
  * control request is unavailable, so the card still gets session cost.
168
230
  * Advisory: a failure here must not disturb the turn's response.
169
231
  */
170
- async function publishPlanUsage({ message, state, sdkSessionId }) {
232
+ async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
233
+ if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
234
+ if (usageRoute) {
235
+ await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
236
+ return;
237
+ }
238
+ if (!reportsPlanUsage) return;
171
239
  const totalCostUsd = state.result?.totalCostUsd ?? null;
172
240
  // A cost with no per-model breakdown is still a usable fallback payload, so
173
241
  // it has to count as a reason to publish or the card loses session cost on
@@ -184,6 +252,283 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
184
252
  });
185
253
  }
186
254
 
255
+ // The CLI process's own token totals and web search and fetch counts per
256
+ // model, counted from zero for each process (`runId`) and CLI session id
257
+ // (a `/clear` changes it and starts the CLI's totals over). `lastSession`
258
+ // is the CLI's running totals per model at the last result they were taken
259
+ // from, `lastSearches` its running search counts the same way, `results`
260
+ // how many results the process has reported, and
261
+ // `countedStates` the turn states already added: a turn published twice
262
+ // (a stopped turn's chain and the stream's end) counts once.
263
+ const run = { key: '', runId: '', results: 0, totals: new Map(), requests: new Map(), lastSession: new Map(), lastSearches: new Map() };
264
+ const countedStates = new WeakSet();
265
+
266
+ const addTotals = (into, entries) => {
267
+ for (const entry of entries) {
268
+ const current = into.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
269
+ current.hitTokens += entry.hitTokens;
270
+ current.missTokens += entry.missTokens;
271
+ current.outputTokens += entry.outputTokens;
272
+ into.set(entry.model, current);
273
+ }
274
+ };
275
+
276
+ /**
277
+ * The process's running totals and request counts after this turn, as
278
+ * `{ totals: [{ model, hitTokens, missTokens, outputTokens }], requests:
279
+ * [{ model, searchRequests, fetchRequests }] }` without the all-zero
280
+ * models. Without a run id (a caller that has none) the CLI's own totals
281
+ * and the turn's counts, as before.
282
+ *
283
+ * A result's `modelUsage` is the CLI session's running total per model
284
+ * (sub-agents included), so the turn's share is what it grew by since the
285
+ * process's last result. The first result of a process spawned to resume
286
+ * a session carries that session's earlier turns too, which the relay has
287
+ * already booked: that turn's share is the result's `usage`, the turn's
288
+ * own count, under the turn's model (it leaves out what sub-agents spent
289
+ * in that one turn). A process spawned fresh, or a session the CLI
290
+ * started over, reports from zero: its first result is all its own. A
291
+ * result without `modelUsage` adds its `usage`. The baseline the growth
292
+ * is measured from only rises, field by field per model: a result's
293
+ * totals below it belong to an older turn published late.
294
+ *
295
+ * Web searches come from the same place as the tokens: the CLI counts
296
+ * them per model in `modelUsage`, the WebSearch tool's own API request
297
+ * included, which the message stream (`serverToolUse`) never shows. They
298
+ * grow, rise and resume like the tokens; the stream's count stands in
299
+ * where the tokens fall back to `usage` (no `modelUsage`, the resumed
300
+ * first result) and for a CLI that reports no search count. Fetches only
301
+ * the stream counts.
302
+ */
303
+ function runningCounts(state, result, { runId, sessionId, resumedSessionId, modelKey }) {
304
+ const model = result.model || state.responseModel;
305
+ const turnRequests = claudeToolRequests(result.serverToolUse, modelKey);
306
+ if (!runId) return { totals: claudeUsageTotals(result.modelUsage, { usage: result.usage, model, ...modelKey }), requests: turnRequests };
307
+ if (run.runId !== runId) {
308
+ run.runId = runId;
309
+ run.results = 0;
310
+ }
311
+ const key = `${runId}\n${sessionId}`;
312
+ if (run.key !== key) {
313
+ run.key = key;
314
+ run.totals = new Map();
315
+ run.requests = new Map();
316
+ run.lastSession = new Map();
317
+ run.lastSearches = new Map();
318
+ }
319
+ if (!countedStates.has(state)) {
320
+ countedStates.add(state);
321
+ const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
322
+ const turnUsage = claudeUsageTotals(null, { usage: result.usage, model, ...modelKey });
323
+ const sessionSearches = hasModelUsage ? claudeSearchTotals(result.modelUsage, modelKey) : [];
324
+ // The searches this turn adds, per model: the stream's unless the
325
+ // CLI's running count below says more reliably.
326
+ let turnSearches = turnRequests.map((entry) => ({ model: entry.model, searchRequests: entry.searchRequests }));
327
+ if (!hasModelUsage) {
328
+ // The CLI's running totals grew by the turn, as far as this goes.
329
+ addTotals(run.totals, turnUsage);
330
+ addTotals(run.lastSession, turnUsage);
331
+ for (const entry of turnSearches) {
332
+ run.lastSearches.set(entry.model, (run.lastSearches.get(entry.model) || 0) + entry.searchRequests);
333
+ }
334
+ } else {
335
+ const session = claudeUsageTotals(result.modelUsage, modelKey);
336
+ const resumed = run.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
337
+ if (resumed) {
338
+ addTotals(run.totals, turnUsage);
339
+ } else {
340
+ const grown = session.map((entry) => {
341
+ const last = run.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0 };
342
+ return {
343
+ model: entry.model,
344
+ hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
345
+ missTokens: Math.max(0, entry.missTokens - last.missTokens),
346
+ outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
347
+ };
348
+ });
349
+ addTotals(run.totals, grown);
350
+ if (sessionSearches.length) {
351
+ turnSearches = sessionSearches.map((entry) => ({
352
+ model: entry.model,
353
+ searchRequests: Math.max(0, entry.searchRequests - (run.lastSearches.get(entry.model) || 0)),
354
+ }));
355
+ }
356
+ }
357
+ // The baseline only ever rises: the CLI's totals are cumulative, so a
358
+ // snapshot below the last one is an older result arriving late (a
359
+ // stopped turn's publish chain behind a newer turn's), and taking it
360
+ // as the baseline would book the newer turn's growth a second time.
361
+ for (const entry of session) {
362
+ const last = run.lastSession.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
363
+ run.lastSession.set(entry.model, {
364
+ model: entry.model,
365
+ hitTokens: Math.max(last.hitTokens, entry.hitTokens),
366
+ missTokens: Math.max(last.missTokens, entry.missTokens),
367
+ outputTokens: Math.max(last.outputTokens, entry.outputTokens),
368
+ });
369
+ }
370
+ for (const entry of sessionSearches) {
371
+ run.lastSearches.set(entry.model, Math.max(run.lastSearches.get(entry.model) || 0, entry.searchRequests));
372
+ }
373
+ }
374
+ const addRequests = (entryModel, searchRequests, fetchRequests) => {
375
+ const current = run.requests.get(entryModel) || { model: entryModel, searchRequests: 0, fetchRequests: 0 };
376
+ current.searchRequests += searchRequests;
377
+ current.fetchRequests += fetchRequests;
378
+ run.requests.set(entryModel, current);
379
+ };
380
+ for (const entry of turnSearches) addRequests(entry.model, entry.searchRequests, 0);
381
+ for (const entry of turnRequests) addRequests(entry.model, 0, entry.fetchRequests);
382
+ run.results += 1;
383
+ }
384
+ // Copies: the running entries go on changing after the post.
385
+ return {
386
+ totals: [...run.totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0).map((entry) => ({ ...entry })),
387
+ requests: [...run.requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0).map((entry) => ({ ...entry })),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Ship the CLI process's token totals per model after a turn, with the
393
+ * CLI's session id, the id of the process (`runId`) and the time. They are
394
+ * the process's own running totals, counted from zero (runningCounts): the
395
+ * relay books what each report adds under the run id, a report below the
396
+ * last one under it is stale, and a new run id is booked in full. The
397
+ * process's running counts of web searches and fetches ride along as
398
+ * `requests`, per model, booked the same way. A caller without a run id
399
+ * sends the CLI's session totals and the turn's counts, which the relay
400
+ * books as an older worker's. `resumedSessionId` is the session the process
401
+ * was spawned to resume, if any. Advisory, like the plan usage.
402
+ */
403
+ async function publishBackendUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
404
+ const result = state.result;
405
+ const claudeNativeSessionId = String(result?.sessionId || '').trim();
406
+ if (!usageRoute || !claudeNativeSessionId) return;
407
+ const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
408
+ const { totals, requests } = runningCounts(state, result, {
409
+ runId: String(runId || ''),
410
+ sessionId: claudeNativeSessionId,
411
+ resumedSessionId: String(resumedSessionId || '').trim(),
412
+ modelKey,
413
+ });
414
+ if (!totals.length && !requests.length) return;
415
+ await api('POST', usageRoute, {
416
+ conversationId: message.conversationId,
417
+ sdkSessionId,
418
+ claudeNativeSessionId,
419
+ ...(runId ? { runId } : {}),
420
+ at: new Date().toISOString(),
421
+ totals,
422
+ ...(requests.length ? { requests } : {}),
423
+ }).catch((error) => {
424
+ dbg('backend usage publish failed', error?.message || String(error));
425
+ });
426
+ }
427
+
428
+ // The process's own totals per model with their cost, for the Claude API
429
+ // booking: counted like `run` above, per process and CLI session, with the
430
+ // CLI's cost estimate beside the tokens. A resumed process's first result
431
+ // adds the turn's `usage` (its main agent loop, no cost); the relay books
432
+ // that turn from the session totals the report carries as well.
433
+ const billingRun = { key: '', results: 0, totals: new Map(), lastSession: new Map() };
434
+ const billedStates = new WeakSet();
435
+
436
+ function billingCounts(state, result, { runId, sessionId, resumedSessionId }) {
437
+ const model = result.model || state.responseModel;
438
+ const key = `${runId}\n${sessionId}`;
439
+ if (billingRun.key !== key) {
440
+ billingRun.key = key;
441
+ billingRun.results = 0;
442
+ billingRun.totals = new Map();
443
+ billingRun.lastSession = new Map();
444
+ }
445
+ const firstResultOfRun = billingRun.results === 0;
446
+ const session = claudeBillingTotals(result.modelUsage, { usage: result.usage, model });
447
+ const add = (entries) => {
448
+ for (const entry of entries) {
449
+ const current = billingRun.totals.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
450
+ current.hitTokens += entry.hitTokens;
451
+ current.missTokens += entry.missTokens;
452
+ current.outputTokens += entry.outputTokens;
453
+ current.costUsd += entry.costUsd;
454
+ current.costUnknown = current.costUnknown || entry.costUnknown;
455
+ billingRun.totals.set(entry.model, current);
456
+ }
457
+ };
458
+ if (!billedStates.has(state)) {
459
+ billedStates.add(state);
460
+ const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
461
+ const resumed = firstResultOfRun && Boolean(resumedSessionId) && sessionId === resumedSessionId;
462
+ if (!hasModelUsage || resumed) {
463
+ add(claudeBillingTotals(null, { usage: result.usage, model }));
464
+ } else {
465
+ add(session.map((entry) => {
466
+ const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
467
+ return {
468
+ model: entry.model,
469
+ hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
470
+ missTokens: Math.max(0, entry.missTokens - last.missTokens),
471
+ outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
472
+ costUsd: Math.max(0, entry.costUsd - last.costUsd),
473
+ costUnknown: entry.costUnknown,
474
+ };
475
+ }));
476
+ }
477
+ // The baseline only rises: a late result of an older turn below it
478
+ // must not make the next one count twice.
479
+ if (hasModelUsage) {
480
+ for (const entry of session) {
481
+ const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
482
+ billingRun.lastSession.set(entry.model, {
483
+ hitTokens: Math.max(last.hitTokens, entry.hitTokens),
484
+ missTokens: Math.max(last.missTokens, entry.missTokens),
485
+ outputTokens: Math.max(last.outputTokens, entry.outputTokens),
486
+ costUsd: Math.max(last.costUsd, entry.costUsd),
487
+ });
488
+ }
489
+ }
490
+ billingRun.results += 1;
491
+ }
492
+ return {
493
+ firstResultOfRun,
494
+ runCounts: [...billingRun.totals.values()].map((entry) => ({ ...entry })),
495
+ sessionTotals: session,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Ship the turn's counts to the Claude API booking: the process's own
501
+ * running totals (`runCounts`) and the CLI's session totals
502
+ * (`sessionTotals`), with the worker's kind, the process (`runId`), the
503
+ * session it resumed, and the turn (`turnId`). Advisory, like the plan usage.
504
+ */
505
+ async function publishBillingUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
506
+ const result = state.result;
507
+ const claudeNativeSessionId = String(result?.sessionId || '').trim();
508
+ if (!billingUsageRoute || !claudeNativeSessionId || !runId) return;
509
+ const { firstResultOfRun, runCounts, sessionTotals } = billingCounts(state, result, {
510
+ runId: String(runId),
511
+ sessionId: claudeNativeSessionId,
512
+ resumedSessionId: String(resumedSessionId || '').trim(),
513
+ });
514
+ if (!runCounts.length && !sessionTotals.length) return;
515
+ await api('POST', billingUsageRoute, {
516
+ conversationId: message.conversationId,
517
+ sdkSessionId,
518
+ claudeNativeSessionId,
519
+ runId: String(runId),
520
+ billing: billingKind || null,
521
+ turnId: String(message.id || '') || null,
522
+ at: new Date().toISOString(),
523
+ firstResultOfRun,
524
+ ...(resumedSessionId ? { resumedSessionId: String(resumedSessionId) } : {}),
525
+ runCounts,
526
+ sessionTotals,
527
+ }).catch((error) => {
528
+ dbg('billing usage publish failed', error?.message || String(error));
529
+ });
530
+ }
531
+
187
532
  /**
188
533
  * Ship what the CLI reported about the subscription's usage limit: the
189
534
  * relay warns before the limit and shows the reset once it is reached.
@@ -353,7 +698,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
353
698
  async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
354
699
  const result = state.result;
355
700
  const errorText = result.text
356
- || `Claude turn failed (${result.subtype || 'unknown error'}).`;
701
+ || `${runtimeLabel} turn failed (${result.subtype || 'unknown error'}).`;
702
+ const described = backendFailure(errorText, result.apiErrorStatus);
357
703
  await publishFinalStream(message, state.lastStreamedText);
358
704
  // Refused at the usage limit: a relay that knows the kind pauses the turn
359
705
  // until the reset instead of failing it.
@@ -382,7 +728,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
382
728
  // Never built from the subtype alone: a refused turn ends with
383
729
  // subtype 'success'. An account failure brings its own advice, since
384
730
  // sending the message again cannot help before the account is fixed.
385
- ...classifyClaudeResultFailure(result),
731
+ ...classifyResultFailure(result),
732
+ ...(described ? { ...failureCodes(described.code), guidance: described.guidance } : {}),
386
733
  message: errorText,
387
734
  failedAt: new Date().toISOString(),
388
735
  queueMessageId: String(message.id || '') || null,
@@ -400,6 +747,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
400
747
  });
401
748
  return;
402
749
  }
750
+ // Claude Code did not take a backend's connection settings (its key and
751
+ // endpoint pins): nothing was sent, and no sign-in changes that.
752
+ if (error?.code === CLAUDE_LIVE_FLAG_SETTINGS_CODE) {
753
+ await publishResponse(message, {
754
+ text: `System note: ${error.message} ${CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE}`,
755
+ model: null,
756
+ terminalError: {
757
+ kind: 'claude-turn-failed',
758
+ code: CLAUDE_LIVE_FLAG_SETTINGS_CODE,
759
+ stableCode: `claude.${CLAUDE_LIVE_FLAG_SETTINGS_CODE}`,
760
+ message: error.message,
761
+ guidance: CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
762
+ failedAt: new Date().toISOString(),
763
+ queueMessageId: String(message.id || '') || null,
764
+ },
765
+ });
766
+ return;
767
+ }
403
768
  // The conversation's folder is gone: the error names it and says what to
404
769
  // do, which neither a retry nor a sign-in changes.
405
770
  if (isWorkingDirectoryMissingError(error)) {
@@ -408,8 +773,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
408
773
  model: null,
409
774
  terminalError: {
410
775
  kind: 'claude-turn-failed',
411
- code: WORKING_DIRECTORY_MISSING_CODE,
412
- stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
776
+ ...failureCodes(WORKING_DIRECTORY_MISSING_CODE),
413
777
  message: error.message,
414
778
  guidance: error.guidance,
415
779
  failedAt: new Date().toISOString(),
@@ -418,16 +782,33 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
418
782
  });
419
783
  return;
420
784
  }
421
- const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
785
+ const described = backendFailure(errorText, null, error);
786
+ if (described) {
787
+ await publishResponse(message, {
788
+ text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,
789
+ model: null,
790
+ terminalError: {
791
+ kind: 'claude-turn-failed',
792
+ ...failureCodes(described.code),
793
+ message: errorText,
794
+ guidance: described.guidance,
795
+ failedAt: new Date().toISOString(),
796
+ queueMessageId: String(message.id || '') || null,
797
+ },
798
+ });
799
+ return;
800
+ }
801
+ // A backend's sign-in failures are its describeFailure's; Claude's sign-in
802
+ // advice is never a backend's.
803
+ const isAuthError = !failureCodePrefix && /authentication|logged in|login|credential|api key/i.test(errorText);
422
804
  await publishResponse(message, {
423
805
  text: isAuthError
424
806
  ? `System note: the Claude runtime could not authenticate (${errorText}). Log in again from Settings → Providers → Claude → Relogin (or run \`claude\` on the relay host), then retry.`
425
- : `System note: the Claude turn failed (${errorText}). Retry or send a new message.`,
807
+ : `System note: the ${runtimeLabel} turn failed (${errorText}). Retry or send a new message.`,
426
808
  model: null,
427
809
  terminalError: {
428
810
  kind: 'claude-turn-failed',
429
- code: isAuthError ? 'authentication_failed' : 'turn-error',
430
- stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
811
+ ...failureCodes(isAuthError ? 'authentication_failed' : 'turn-error'),
431
812
  message: errorText,
432
813
  ...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
433
814
  failedAt: new Date().toISOString(),
@@ -0,0 +1,161 @@
1
+ // The CLI's token totals per model, out of a result message.
2
+ //
3
+ // A result's `modelUsage` is the CLI session's running total, not the turn's:
4
+ // a long-lived CLI reports more on every turn, a CLI resumed on a session
5
+ // starts from the totals that session's transcript saved (its first result
6
+ // already carries the earlier turns), and a `/clear` starts it over. Its
7
+ // `usage` is the turn's own, and the main agent loop's only (sub-agents and
8
+ // auxiliary calls are left out). The publisher turns these into the
9
+ // process's own running totals (claude-turn-publisher.mjs, runningCounts),
10
+ // which the relay books per process (plan-usage-service.mjs,
11
+ // recordDeepSeekUsageReport).
12
+
13
+ function count(value) {
14
+ const number = Number(value ?? 0);
15
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
16
+ }
17
+
18
+ function addTo(totals, model, hit, miss, output) {
19
+ const current = totals.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
20
+ current.hitTokens += hit;
21
+ current.missTokens += miss;
22
+ current.outputTokens += output;
23
+ totals.set(model, current);
24
+ }
25
+
26
+ /**
27
+ * `[{ model, hitTokens, missTokens, outputTokens }]`: cache reads are hits,
28
+ * fresh and cache-creation input are misses. `modelKey` maps the CLI's name
29
+ * of a model to the relay's (DeepSeek: without the `[1m]` suffix); entries
30
+ * that map to the same id are summed. `usage` stands in only for a result
31
+ * without `modelUsage`, under the turn's model (pass `modelUsage` as null to
32
+ * read `usage` alone). Models with no tokens are left out.
33
+ */
34
+ export function claudeUsageTotals(modelUsage, {
35
+ usage = null,
36
+ model = '',
37
+ modelKey = (value) => String(value ?? '').trim(),
38
+ } = {}) {
39
+ const keyOf = (value) => String(modelKey(value) ?? '').trim().toLowerCase();
40
+ const totals = new Map();
41
+ if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
42
+ for (const [name, entry] of Object.entries(modelUsage)) {
43
+ const key = keyOf(name);
44
+ if (!key || !entry || typeof entry !== 'object') continue;
45
+ addTo(
46
+ totals,
47
+ key,
48
+ count(entry.cacheReadInputTokens),
49
+ count(entry.inputTokens) + count(entry.cacheCreationInputTokens),
50
+ count(entry.outputTokens),
51
+ );
52
+ }
53
+ } else {
54
+ const key = keyOf(model);
55
+ if (key && usage && typeof usage === 'object') {
56
+ addTo(
57
+ totals,
58
+ key,
59
+ count(usage.cache_read_input_tokens),
60
+ count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
61
+ count(usage.output_tokens),
62
+ );
63
+ }
64
+ }
65
+ return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0);
66
+ }
67
+
68
+ /**
69
+ * The CLI's running count of web searches per model, `[{ model,
70
+ * searchRequests }]`, out of a result's `modelUsage`: the CLI adds every API
71
+ * request's `server_tool_use.web_search_requests` to it, the internal request
72
+ * its WebSearch tool makes included, which never shows up in the message
73
+ * stream. Same `modelKey` and summing as the token totals. Only the entries
74
+ * that carry the count, zero included, so an empty list means the CLI does
75
+ * not report it. It has no fetch count.
76
+ */
77
+ export function claudeSearchTotals(modelUsage, { modelKey = (value) => String(value ?? '').trim() } = {}) {
78
+ const searches = new Map();
79
+ if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
80
+ for (const [name, entry] of Object.entries(modelUsage)) {
81
+ const key = String(modelKey(name) ?? '').trim().toLowerCase();
82
+ if (!key || !entry || typeof entry !== 'object' || !Number.isFinite(Number(entry.webSearchRequests ?? NaN))) continue;
83
+ const current = searches.get(key) || { model: key, searchRequests: 0 };
84
+ current.searchRequests += count(entry.webSearchRequests);
85
+ searches.set(key, current);
86
+ }
87
+ return [...searches.values()];
88
+ }
89
+
90
+ /**
91
+ * The turn's web searches and fetches per model, `[{ model, searchRequests,
92
+ * fetchRequests }]`, from the result's `serverToolUse` (the normalizer's
93
+ * per-turn count, not a running total): the message stream's, which leaves
94
+ * out the WebSearch tool's own request. Same `modelKey` and summing as the
95
+ * token totals; models with no requests are left out.
96
+ */
97
+ export function claudeToolRequests(serverToolUse, { modelKey = (value) => String(value ?? '').trim() } = {}) {
98
+ const requests = new Map();
99
+ for (const entry of Array.isArray(serverToolUse) ? serverToolUse : []) {
100
+ const key = String(modelKey(entry?.model) ?? '').trim().toLowerCase();
101
+ if (!key || !entry || typeof entry !== 'object') continue;
102
+ const current = requests.get(key) || { model: key, searchRequests: 0, fetchRequests: 0 };
103
+ current.searchRequests += count(entry.webSearchRequests);
104
+ current.fetchRequests += count(entry.webFetchRequests);
105
+ requests.set(key, current);
106
+ }
107
+ return [...requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0);
108
+ }
109
+
110
+ function costOf(value) {
111
+ const number = Number(value ?? 0);
112
+ return Number.isFinite(number) && number > 0 ? number : 0;
113
+ }
114
+
115
+ /**
116
+ * The CLI's totals per model with Claude Code's own cost estimate, for the
117
+ * Claude API booking (claude-api-usage-service.mjs): `[{ model, hitTokens,
118
+ * missTokens, outputTokens, costUsd, costUnknown }]`. From `modelUsage` (the
119
+ * session's running totals, sub-agents and helper calls included; `costUSD`
120
+ * per model, `costBasis: 'unknown'` for a model the CLI has no price for), or
121
+ * from `usage` alone under the turn's model (pass `modelUsage` as null): the
122
+ * turn's main agent loop, which carries no cost. Model ids keep Claude's
123
+ * `[1m]` tier; the relay books them under the base id.
124
+ */
125
+ export function claudeBillingTotals(modelUsage, { usage = null, model = '' } = {}) {
126
+ const totals = new Map();
127
+ const add = (name, hit, miss, output, costUsd, costUnknown) => {
128
+ const key = String(name ?? '').trim().toLowerCase();
129
+ if (!key) return;
130
+ const current = totals.get(key) || { model: key, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
131
+ current.hitTokens += hit;
132
+ current.missTokens += miss;
133
+ current.outputTokens += output;
134
+ current.costUsd += costUsd;
135
+ current.costUnknown = current.costUnknown || costUnknown;
136
+ totals.set(key, current);
137
+ };
138
+ if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
139
+ for (const [name, entry] of Object.entries(modelUsage)) {
140
+ if (!entry || typeof entry !== 'object') continue;
141
+ add(
142
+ name,
143
+ count(entry.cacheReadInputTokens),
144
+ count(entry.inputTokens) + count(entry.cacheCreationInputTokens),
145
+ count(entry.outputTokens),
146
+ costOf(entry.costUSD),
147
+ entry.costBasis === 'unknown',
148
+ );
149
+ }
150
+ } else if (usage && typeof usage === 'object') {
151
+ add(
152
+ model,
153
+ count(usage.cache_read_input_tokens),
154
+ count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
155
+ count(usage.output_tokens),
156
+ 0,
157
+ false,
158
+ );
159
+ }
160
+ return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0 || entry.costUsd > 0);
161
+ }