@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -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 { 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,53 @@ 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.
76
+ describeFailure = null,
77
+ runtimeLabel = 'Claude',
78
+ // The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
79
+ // failures print as `relay.deepseek-<code>`, which the browser's buttons
80
+ // key on (relay-error-ctas.mjs), not as Claude's `relay.claude-<code>`.
81
+ failureCodePrefix = null,
82
+ // False for a backend that is not Claude's: no Claude plan windows or cost.
83
+ reportsPlanUsage = true,
84
+ // A backend's own usage route (DeepSeek: /api/deepseek-plan-usage), which
85
+ // takes the CLI's token totals per model in place of the Claude plan
86
+ // usage, and the relay's id of a model the CLI names.
87
+ usageRoute = null,
88
+ usageModelKey = null,
89
+ // The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
90
+ // → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
91
+ relayModel = (value) => value,
92
+ } = {}) {
93
+ /** `{ code, stableCode }` of a failure code, in the backend's namespace. */
94
+ function failureCodes(code) {
95
+ if (!failureCodePrefix) return { code, stableCode: `claude.${code}` };
96
+ const prefixed = String(code).startsWith(`${failureCodePrefix}-`) ? code : `${failureCodePrefix}-${String(code).replace(/_/g, '-')}`;
97
+ return { code: prefixed, stableCode: prefixed };
98
+ }
99
+
100
+ /**
101
+ * The Claude reading of a failed result, in the backend's namespace. A
102
+ * backend gets no Claude account or sign-in advice: the relay's default
103
+ * fits where the backend has none of its own.
104
+ */
105
+ function classifyResultFailure(result) {
106
+ const classified = classifyClaudeResultFailure(result);
107
+ if (!failureCodePrefix) return classified;
108
+ return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
109
+ }
110
+
111
+ function backendFailure(errorText, status = null) {
112
+ if (typeof describeFailure !== 'function') return null;
113
+ const described = describeFailure(errorText, { status });
114
+ return described?.code ? described : null;
115
+ }
116
+
65
117
  function drainWorkflowRuns({ terminal = false } = {}) {
66
118
  if (terminal || typeof takeWorkflowRuns !== 'function') return null;
67
119
  return takeWorkflowRuns();
@@ -150,11 +202,14 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
150
202
  */
151
203
  async function publishContextUsage({ message, state, model, sdkSessionId }) {
152
204
  if (!state.contextUsage && !state.modelUsage) return;
205
+ const contextUsage = state.contextUsage && typeof state.contextUsage === 'object' && state.contextUsage.model
206
+ ? { ...state.contextUsage, model: relayModel(state.contextUsage.model) }
207
+ : state.contextUsage;
153
208
  await api('POST', '/api/claude-context-usage', {
154
209
  conversationId: message.conversationId,
155
210
  sdkSessionId,
156
- model: state.responseModel || model || null,
157
- contextUsage: state.contextUsage,
211
+ model: relayModel(state.responseModel || model || null),
212
+ contextUsage,
158
213
  modelUsage: state.modelUsage,
159
214
  }).catch((error) => {
160
215
  dbg('context usage publish failed', error?.message || String(error));
@@ -167,7 +222,12 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
167
222
  * control request is unavailable, so the card still gets session cost.
168
223
  * Advisory: a failure here must not disturb the turn's response.
169
224
  */
170
- async function publishPlanUsage({ message, state, sdkSessionId }) {
225
+ async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
226
+ if (usageRoute) {
227
+ await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
228
+ return;
229
+ }
230
+ if (!reportsPlanUsage) return;
171
231
  const totalCostUsd = state.result?.totalCostUsd ?? null;
172
232
  // A cost with no per-model breakdown is still a usable fallback payload, so
173
233
  // it has to count as a reason to publish or the card loses session cost on
@@ -184,6 +244,179 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
184
244
  });
185
245
  }
186
246
 
247
+ // The CLI process's own token totals and web search and fetch counts per
248
+ // model, counted from zero for each process (`runId`) and CLI session id
249
+ // (a `/clear` changes it and starts the CLI's totals over). `lastSession`
250
+ // is the CLI's running totals per model at the last result they were taken
251
+ // from, `lastSearches` its running search counts the same way, `results`
252
+ // how many results the process has reported, and
253
+ // `countedStates` the turn states already added: a turn published twice
254
+ // (a stopped turn's chain and the stream's end) counts once.
255
+ const run = { key: '', runId: '', results: 0, totals: new Map(), requests: new Map(), lastSession: new Map(), lastSearches: new Map() };
256
+ const countedStates = new WeakSet();
257
+
258
+ const addTotals = (into, entries) => {
259
+ for (const entry of entries) {
260
+ const current = into.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
261
+ current.hitTokens += entry.hitTokens;
262
+ current.missTokens += entry.missTokens;
263
+ current.outputTokens += entry.outputTokens;
264
+ into.set(entry.model, current);
265
+ }
266
+ };
267
+
268
+ /**
269
+ * The process's running totals and request counts after this turn, as
270
+ * `{ totals: [{ model, hitTokens, missTokens, outputTokens }], requests:
271
+ * [{ model, searchRequests, fetchRequests }] }` without the all-zero
272
+ * models. Without a run id (a caller that has none) the CLI's own totals
273
+ * and the turn's counts, as before.
274
+ *
275
+ * A result's `modelUsage` is the CLI session's running total per model
276
+ * (sub-agents included), so the turn's share is what it grew by since the
277
+ * process's last result. The first result of a process spawned to resume
278
+ * a session carries that session's earlier turns too, which the relay has
279
+ * already booked: that turn's share is the result's `usage`, the turn's
280
+ * own count, under the turn's model (it leaves out what sub-agents spent
281
+ * in that one turn). A process spawned fresh, or a session the CLI
282
+ * started over, reports from zero: its first result is all its own. A
283
+ * result without `modelUsage` adds its `usage`. The baseline the growth
284
+ * is measured from only rises, field by field per model: a result's
285
+ * totals below it belong to an older turn published late.
286
+ *
287
+ * Web searches come from the same place as the tokens: the CLI counts
288
+ * them per model in `modelUsage`, the WebSearch tool's own API request
289
+ * included, which the message stream (`serverToolUse`) never shows. They
290
+ * grow, rise and resume like the tokens; the stream's count stands in
291
+ * where the tokens fall back to `usage` (no `modelUsage`, the resumed
292
+ * first result) and for a CLI that reports no search count. Fetches only
293
+ * the stream counts.
294
+ */
295
+ function runningCounts(state, result, { runId, sessionId, resumedSessionId, modelKey }) {
296
+ const model = result.model || state.responseModel;
297
+ const turnRequests = claudeToolRequests(result.serverToolUse, modelKey);
298
+ if (!runId) return { totals: claudeUsageTotals(result.modelUsage, { usage: result.usage, model, ...modelKey }), requests: turnRequests };
299
+ if (run.runId !== runId) {
300
+ run.runId = runId;
301
+ run.results = 0;
302
+ }
303
+ const key = `${runId}\n${sessionId}`;
304
+ if (run.key !== key) {
305
+ run.key = key;
306
+ run.totals = new Map();
307
+ run.requests = new Map();
308
+ run.lastSession = new Map();
309
+ run.lastSearches = new Map();
310
+ }
311
+ if (!countedStates.has(state)) {
312
+ countedStates.add(state);
313
+ const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
314
+ const turnUsage = claudeUsageTotals(null, { usage: result.usage, model, ...modelKey });
315
+ const sessionSearches = hasModelUsage ? claudeSearchTotals(result.modelUsage, modelKey) : [];
316
+ // The searches this turn adds, per model: the stream's unless the
317
+ // CLI's running count below says more reliably.
318
+ let turnSearches = turnRequests.map((entry) => ({ model: entry.model, searchRequests: entry.searchRequests }));
319
+ if (!hasModelUsage) {
320
+ // The CLI's running totals grew by the turn, as far as this goes.
321
+ addTotals(run.totals, turnUsage);
322
+ addTotals(run.lastSession, turnUsage);
323
+ for (const entry of turnSearches) {
324
+ run.lastSearches.set(entry.model, (run.lastSearches.get(entry.model) || 0) + entry.searchRequests);
325
+ }
326
+ } else {
327
+ const session = claudeUsageTotals(result.modelUsage, modelKey);
328
+ const resumed = run.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
329
+ if (resumed) {
330
+ addTotals(run.totals, turnUsage);
331
+ } else {
332
+ const grown = session.map((entry) => {
333
+ const last = run.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0 };
334
+ return {
335
+ model: entry.model,
336
+ hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
337
+ missTokens: Math.max(0, entry.missTokens - last.missTokens),
338
+ outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
339
+ };
340
+ });
341
+ addTotals(run.totals, grown);
342
+ if (sessionSearches.length) {
343
+ turnSearches = sessionSearches.map((entry) => ({
344
+ model: entry.model,
345
+ searchRequests: Math.max(0, entry.searchRequests - (run.lastSearches.get(entry.model) || 0)),
346
+ }));
347
+ }
348
+ }
349
+ // The baseline only ever rises: the CLI's totals are cumulative, so a
350
+ // snapshot below the last one is an older result arriving late (a
351
+ // stopped turn's publish chain behind a newer turn's), and taking it
352
+ // as the baseline would book the newer turn's growth a second time.
353
+ for (const entry of session) {
354
+ const last = run.lastSession.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
355
+ run.lastSession.set(entry.model, {
356
+ model: entry.model,
357
+ hitTokens: Math.max(last.hitTokens, entry.hitTokens),
358
+ missTokens: Math.max(last.missTokens, entry.missTokens),
359
+ outputTokens: Math.max(last.outputTokens, entry.outputTokens),
360
+ });
361
+ }
362
+ for (const entry of sessionSearches) {
363
+ run.lastSearches.set(entry.model, Math.max(run.lastSearches.get(entry.model) || 0, entry.searchRequests));
364
+ }
365
+ }
366
+ const addRequests = (entryModel, searchRequests, fetchRequests) => {
367
+ const current = run.requests.get(entryModel) || { model: entryModel, searchRequests: 0, fetchRequests: 0 };
368
+ current.searchRequests += searchRequests;
369
+ current.fetchRequests += fetchRequests;
370
+ run.requests.set(entryModel, current);
371
+ };
372
+ for (const entry of turnSearches) addRequests(entry.model, entry.searchRequests, 0);
373
+ for (const entry of turnRequests) addRequests(entry.model, 0, entry.fetchRequests);
374
+ run.results += 1;
375
+ }
376
+ // Copies: the running entries go on changing after the post.
377
+ return {
378
+ totals: [...run.totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0).map((entry) => ({ ...entry })),
379
+ requests: [...run.requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0).map((entry) => ({ ...entry })),
380
+ };
381
+ }
382
+
383
+ /**
384
+ * Ship the CLI process's token totals per model after a turn, with the
385
+ * CLI's session id, the id of the process (`runId`) and the time. They are
386
+ * the process's own running totals, counted from zero (runningCounts): the
387
+ * relay books what each report adds under the run id, a report below the
388
+ * last one under it is stale, and a new run id is booked in full. The
389
+ * process's running counts of web searches and fetches ride along as
390
+ * `requests`, per model, booked the same way. A caller without a run id
391
+ * sends the CLI's session totals and the turn's counts, which the relay
392
+ * books as an older worker's. `resumedSessionId` is the session the process
393
+ * was spawned to resume, if any. Advisory, like the plan usage.
394
+ */
395
+ async function publishBackendUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
396
+ const result = state.result;
397
+ const claudeNativeSessionId = String(result?.sessionId || '').trim();
398
+ if (!usageRoute || !claudeNativeSessionId) return;
399
+ const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
400
+ const { totals, requests } = runningCounts(state, result, {
401
+ runId: String(runId || ''),
402
+ sessionId: claudeNativeSessionId,
403
+ resumedSessionId: String(resumedSessionId || '').trim(),
404
+ modelKey,
405
+ });
406
+ if (!totals.length && !requests.length) return;
407
+ await api('POST', usageRoute, {
408
+ conversationId: message.conversationId,
409
+ sdkSessionId,
410
+ claudeNativeSessionId,
411
+ ...(runId ? { runId } : {}),
412
+ at: new Date().toISOString(),
413
+ totals,
414
+ ...(requests.length ? { requests } : {}),
415
+ }).catch((error) => {
416
+ dbg('backend usage publish failed', error?.message || String(error));
417
+ });
418
+ }
419
+
187
420
  /**
188
421
  * Ship what the CLI reported about the subscription's usage limit: the
189
422
  * relay warns before the limit and shows the reset once it is reached.
@@ -353,7 +586,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
353
586
  async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
354
587
  const result = state.result;
355
588
  const errorText = result.text
356
- || `Claude turn failed (${result.subtype || 'unknown error'}).`;
589
+ || `${runtimeLabel} turn failed (${result.subtype || 'unknown error'}).`;
590
+ const described = backendFailure(errorText, result.apiErrorStatus);
357
591
  await publishFinalStream(message, state.lastStreamedText);
358
592
  // Refused at the usage limit: a relay that knows the kind pauses the turn
359
593
  // until the reset instead of failing it.
@@ -382,7 +616,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
382
616
  // Never built from the subtype alone: a refused turn ends with
383
617
  // subtype 'success'. An account failure brings its own advice, since
384
618
  // sending the message again cannot help before the account is fixed.
385
- ...classifyClaudeResultFailure(result),
619
+ ...classifyResultFailure(result),
620
+ ...(described ? { ...failureCodes(described.code), guidance: described.guidance } : {}),
386
621
  message: errorText,
387
622
  failedAt: new Date().toISOString(),
388
623
  queueMessageId: String(message.id || '') || null,
@@ -400,6 +635,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
400
635
  });
401
636
  return;
402
637
  }
638
+ // Claude Code did not take a backend's connection settings (its key and
639
+ // endpoint pins): nothing was sent, and no sign-in changes that.
640
+ if (error?.code === CLAUDE_LIVE_FLAG_SETTINGS_CODE) {
641
+ await publishResponse(message, {
642
+ text: `System note: ${error.message} ${CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE}`,
643
+ model: null,
644
+ terminalError: {
645
+ kind: 'claude-turn-failed',
646
+ code: CLAUDE_LIVE_FLAG_SETTINGS_CODE,
647
+ stableCode: `claude.${CLAUDE_LIVE_FLAG_SETTINGS_CODE}`,
648
+ message: error.message,
649
+ guidance: CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
650
+ failedAt: new Date().toISOString(),
651
+ queueMessageId: String(message.id || '') || null,
652
+ },
653
+ });
654
+ return;
655
+ }
403
656
  // The conversation's folder is gone: the error names it and says what to
404
657
  // do, which neither a retry nor a sign-in changes.
405
658
  if (isWorkingDirectoryMissingError(error)) {
@@ -408,8 +661,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
408
661
  model: null,
409
662
  terminalError: {
410
663
  kind: 'claude-turn-failed',
411
- code: WORKING_DIRECTORY_MISSING_CODE,
412
- stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
664
+ ...failureCodes(WORKING_DIRECTORY_MISSING_CODE),
413
665
  message: error.message,
414
666
  guidance: error.guidance,
415
667
  failedAt: new Date().toISOString(),
@@ -418,16 +670,33 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
418
670
  });
419
671
  return;
420
672
  }
421
- const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
673
+ const described = backendFailure(errorText);
674
+ if (described) {
675
+ await publishResponse(message, {
676
+ text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,
677
+ model: null,
678
+ terminalError: {
679
+ kind: 'claude-turn-failed',
680
+ ...failureCodes(described.code),
681
+ message: errorText,
682
+ guidance: described.guidance,
683
+ failedAt: new Date().toISOString(),
684
+ queueMessageId: String(message.id || '') || null,
685
+ },
686
+ });
687
+ return;
688
+ }
689
+ // A backend's sign-in failures are its describeFailure's; Claude's sign-in
690
+ // advice is never a backend's.
691
+ const isAuthError = !failureCodePrefix && /authentication|logged in|login|credential|api key/i.test(errorText);
422
692
  await publishResponse(message, {
423
693
  text: isAuthError
424
694
  ? `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.`,
695
+ : `System note: the ${runtimeLabel} turn failed (${errorText}). Retry or send a new message.`,
426
696
  model: null,
427
697
  terminalError: {
428
698
  kind: 'claude-turn-failed',
429
- code: isAuthError ? 'authentication_failed' : 'turn-error',
430
- stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
699
+ ...failureCodes(isAuthError ? 'authentication_failed' : 'turn-error'),
431
700
  message: errorText,
432
701
  ...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
433
702
  failedAt: new Date().toISOString(),
@@ -0,0 +1,108 @@
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
+ }
@@ -198,6 +198,57 @@ export function createSdkMessageNormalizer() {
198
198
  // The `error` of the turn's last main-thread assistant message: the SDK's
199
199
  // name for the failure a refused turn ended on.
200
200
  let lastAssistantError = '';
201
+ // The turn's web searches and fetches, per API message id: the API counts
202
+ // them in each message's `usage.server_tool_use`, and the SDK repeats a
203
+ // message's usage with every content block it delivers, so a message is
204
+ // counted once. Summed per model on the result; DeepSeek bills them per
205
+ // request (shared/deepseek-pricing.mjs).
206
+ const serverToolUse = new Map(); // message id -> { model, webSearchRequests, webFetchRequests }
207
+ let unnamedServerToolUseMessages = 0;
208
+
209
+ function requestCount(value) {
210
+ const number = Number(value ?? 0);
211
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
212
+ }
213
+
214
+ function noteServerToolUse(messageId, message) {
215
+ const use = message?.usage?.server_tool_use;
216
+ if (!use || typeof use !== 'object') return;
217
+ const webSearchRequests = requestCount(use.web_search_requests);
218
+ const webFetchRequests = requestCount(use.web_fetch_requests);
219
+ if (!webSearchRequests && !webFetchRequests) return;
220
+ const key = messageId || `unnamed-${++unnamedServerToolUseMessages}`;
221
+ const current = serverToolUse.get(key)
222
+ || { model: String(message?.model || '').trim() || initModel, webSearchRequests: 0, webFetchRequests: 0 };
223
+ // A message's usage is the message's total, so repeats do not add up.
224
+ current.webSearchRequests = Math.max(current.webSearchRequests, webSearchRequests);
225
+ current.webFetchRequests = Math.max(current.webFetchRequests, webFetchRequests);
226
+ serverToolUse.set(key, current);
227
+ }
228
+
229
+ /** The turn's `[{ model, webSearchRequests, webFetchRequests }]`, and the count starts over. */
230
+ function takeServerToolUse(resultUsage) {
231
+ const byModel = new Map();
232
+ const add = (model, webSearchRequests, webFetchRequests) => {
233
+ const key = String(model || '').trim() || initModel;
234
+ const current = byModel.get(key) || { model: key, webSearchRequests: 0, webFetchRequests: 0 };
235
+ current.webSearchRequests += webSearchRequests;
236
+ current.webFetchRequests += webFetchRequests;
237
+ byModel.set(key, current);
238
+ };
239
+ for (const entry of serverToolUse.values()) add(entry.model, entry.webSearchRequests, entry.webFetchRequests);
240
+ serverToolUse.clear();
241
+ unnamedServerToolUseMessages = 0;
242
+ // A result without assistant messages seen still carries the turn's
243
+ // counts in its own usage, under the turn's model.
244
+ const use = resultUsage?.server_tool_use;
245
+ if (!byModel.size && use && typeof use === 'object') {
246
+ const webSearchRequests = requestCount(use.web_search_requests);
247
+ const webFetchRequests = requestCount(use.web_fetch_requests);
248
+ if (webSearchRequests || webFetchRequests) add(initModel, webSearchRequests, webFetchRequests);
249
+ }
250
+ return [...byModel.values()];
251
+ }
201
252
 
202
253
  function threadKeyFor(parentToolUseId) {
203
254
  return String(parentToolUseId || '').trim() || 'main';
@@ -592,6 +643,7 @@ export function createSdkMessageNormalizer() {
592
643
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
593
644
  const messageId = String(sdkMessage?.message?.id || '').trim();
594
645
  if (!parentToolUseId) lastAssistantError = String(sdkMessage?.error || '').trim();
646
+ noteServerToolUse(messageId, sdkMessage?.message);
595
647
  return actionsForAssistantMessage(content, parentToolUseId, messageId);
596
648
  }
597
649
 
@@ -683,6 +735,9 @@ export function createSdkMessageNormalizer() {
683
735
  totalCostUsd: Number.isFinite(Number(sdkMessage.total_cost_usd))
684
736
  ? Number(sdkMessage.total_cost_usd)
685
737
  : null,
738
+ // This turn's web searches and fetches per model (not running
739
+ // totals): `[{ model, webSearchRequests, webFetchRequests }]`.
740
+ serverToolUse: takeServerToolUse(sdkMessage.usage),
686
741
  },
687
742
  }];
688
743
  }
@@ -392,8 +392,11 @@ export function isCopilotQuotaError(data) {
392
392
  * "Open Check Usage" pointer shipped in 37d2899. The structured quota verdict
393
393
  * is passed to that classifier as a hint rather than smuggled in as forged
394
394
  * prose, so a quota error is non-retryable however GitHub worded it.
395
+ *
396
+ * `provider` is the worker's provider (`openai` for a BYOK worker): the note
397
+ * names it, so an OpenAI turn does not read as a Copilot one.
395
398
  */
396
- export function classifyCopilotSessionError(data) {
399
+ export function classifyCopilotSessionError(data, { provider = '' } = {}) {
397
400
  const detail = String(data?.message || '').trim() || 'the Copilot runtime reported a session error';
398
401
  const quota = isCopilotQuotaError(data);
399
402
  const probe = new Error(detail);
@@ -420,7 +423,7 @@ export function classifyCopilotSessionError(data) {
420
423
  return {
421
424
  code,
422
425
  stableCode: `copilot.${code}`,
423
- text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
426
+ text: turnFailedText(detail, provider),
424
427
  // Without one the relay adds its default, which advises a retry.
425
428
  guidance: COPILOT_TURN_FAILURE_GUIDANCE,
426
429
  detail,
@@ -490,6 +493,17 @@ export function isCopilotAuthError(error) {
490
493
  return AUTH_ERROR_RE.test(String(error?.message || error || ''));
491
494
  }
492
495
 
496
+ /**
497
+ * The generic failure note. On the SDK engine the provider is GitHub Copilot or,
498
+ * for a BYOK worker, OpenAI; the note names the one the turn ran on.
499
+ */
500
+ function turnFailedText(detail, provider) {
501
+ const subject = String(provider || '').trim().toLowerCase() === 'openai'
502
+ ? 'the OpenAI turn on the Copilot SDK engine'
503
+ : 'the Copilot turn';
504
+ return `System note: ${subject} failed (${detail}). Retry or send a new message.`;
505
+ }
506
+
493
507
  /**
494
508
  * What the relay appends to a failed Copilot turn in place of its own default
495
509
  * (which advises a retry). The worker deals with its runtime itself: a delivered
@@ -509,7 +523,7 @@ export const COPILOT_TURN_CONTINUE_GUIDANCE =
509
523
  * than a `session.error` event. Auth gets its own branch because the fix is a
510
524
  * user action on the relay host, not a retry.
511
525
  */
512
- export function classifyCopilotTurnException(error) {
526
+ export function classifyCopilotTurnException(error, { provider = '' } = {}) {
513
527
  const detail = String(error?.message || error || '').trim() || 'unknown error';
514
528
  // The conversation's folder is gone; the message names it.
515
529
  if (isWorkingDirectoryMissingError(error)) {
@@ -563,7 +577,7 @@ export function classifyCopilotTurnException(error) {
563
577
  return {
564
578
  code: 'turn-error',
565
579
  stableCode: 'copilot.turn-error',
566
- text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
580
+ text: turnFailedText(detail, provider),
567
581
  guidance: COPILOT_TURN_FAILURE_GUIDANCE,
568
582
  detail,
569
583
  };