@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
@@ -12,10 +12,19 @@
12
12
  * has to be *derived*, so two small bookkeeping tables are kept — a cumulative
13
13
  * per-agent checkpoint (to diff against) and per-cycle pool totals (the
14
14
  * accumulated result). Neither grows with conversation history.
15
+ *
16
+ * DeepSeek's estimated spend is summed per model (migration 0024): one row
17
+ * per model. Its workers report the CLI's running totals, so a checkpoint per
18
+ * (conversation, CLI session, model) is kept to diff against, as for Cursor.
19
+ * The same sums go into a row per UTC day and model (migration 0025), for
20
+ * the card's daily chart and its monthly budget; that table grows by at most
21
+ * one row per model per day.
15
22
  */
16
23
 
17
24
  import {
18
25
  PLAN_USAGE_VERSION,
26
+ toFiniteNumber,
27
+ toIsoTimestamp,
19
28
  toTrimmedString,
20
29
  } from './plan-usage-contract.mjs';
21
30
  import { buildCopilotPlanCard } from './plan-usage-copilot.mjs';
@@ -35,6 +44,35 @@ import {
35
44
  normalizeGrokTurnUsage,
36
45
  resolveGrokBillingCycle,
37
46
  } from './plan-usage-grok.mjs';
47
+ import { buildDeepSeekPlanCard } from './plan-usage-deepseek.mjs';
48
+ import {
49
+ DEEPSEEK_USAGE_CHECKPOINTS_TABLE,
50
+ DEEPSEEK_USAGE_TOTALS_TABLE,
51
+ migrateDeepSeekUsageTotals,
52
+ } from '../migrations/0024-deepseek-usage-totals.mjs';
53
+ import {
54
+ DEEPSEEK_BALANCE_REFERENCE_TABLE,
55
+ DEEPSEEK_USAGE_DAYS_TABLE,
56
+ migrateDeepSeekUsageRequestsDaily,
57
+ } from '../migrations/0025-deepseek-usage-requests-daily.mjs';
58
+ import { migrateDeepSeekUsageServedModels } from '../migrations/0026-deepseek-usage-served-models.mjs';
59
+ import { migrateDeepSeekUsageCheckpointRuns } from '../migrations/0027-deepseek-usage-checkpoint-runs.mjs';
60
+ import { migrateDeepSeekUsageCheckpointRequestRuns } from '../migrations/0028-deepseek-usage-checkpoint-request-runs.mjs';
61
+ import { migrateDeepSeekBalanceRises } from '../migrations/0029-deepseek-balance-rises.mjs';
62
+ import { deepseekServedModel } from '../../shared/deepseek-models.mjs';
63
+ import { estimateCost, estimateSearchCost } from '../../shared/deepseek-pricing.mjs';
64
+
65
+ // A DeepSeek report's token fields and their checkpoint columns.
66
+ const DEEPSEEK_TOKEN_FIELDS = Object.freeze([
67
+ ['hitTokens', 'hit_tokens'],
68
+ ['missTokens', 'miss_tokens'],
69
+ ['outputTokens', 'output_tokens'],
70
+ ]);
71
+ // Its request fields (web searches and fetches) and their checkpoint columns.
72
+ const DEEPSEEK_REQUEST_FIELDS = Object.freeze([
73
+ ['searchRequests', 'search_requests'],
74
+ ['fetchRequests', 'fetch_requests'],
75
+ ]);
38
76
 
39
77
  export const PROVIDER_USAGE_SNAPSHOT_TABLE = 'provider_usage_snapshots';
40
78
  export const CURSOR_USAGE_CHECKPOINT_TABLE = 'cursor_usage_checkpoints';
@@ -103,9 +141,29 @@ const CHECKPOINT_COLUMNS = [
103
141
  export function ensurePlanUsageSchema(db) {
104
142
  if (typeof db?.exec !== 'function') return false;
105
143
  db.exec(PLAN_USAGE_SCHEMA_SQL);
144
+ // The boot runs the same migration (db-schema.mjs); a database the service
145
+ // is handed on its own (tests, tools) gets the table here.
146
+ migrateDeepSeekUsageTotals(db);
147
+ migrateDeepSeekUsageRequestsDaily(db);
148
+ migrateDeepSeekUsageServedModels(db);
149
+ migrateDeepSeekUsageCheckpointRuns(db);
150
+ migrateDeepSeekUsageCheckpointRequestRuns(db);
151
+ migrateDeepSeekBalanceRises(db);
106
152
  return true;
107
153
  }
108
154
 
155
+ /**
156
+ * The model a DeepSeek report's entry is booked under: the DeepSeek model
157
+ * that served it. An older worker names a sub-agent's model as Claude Code
158
+ * requested it (`claude-opus-5-5`, which DeepSeek serves with V4 Pro); the
159
+ * table and the prices know DeepSeek's ids only. A name DeepSeek is not known
160
+ * to serve is booked as it is, without a price.
161
+ */
162
+ function deepseekBookedModel(value) {
163
+ const model = toTrimmedString(value);
164
+ return deepseekServedModel(model) || model;
165
+ }
166
+
109
167
  function rowToCheckpoint(row) {
110
168
  if (!row) return null;
111
169
  const checkpoint = { agentId: row.agent_id, capturedAt: row.updated_at };
@@ -201,6 +259,88 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
201
259
  updated_at = excluded.updated_at
202
260
  `),
203
261
  clearGrokCycle: db.prepare(`DELETE FROM ${GROK_USAGE_CYCLE_TABLE} WHERE cycle_key = ?`),
262
+ // A cost of NULL is "no estimate": it stays NULL until a turn of the
263
+ // model can be priced, and never turns a known sum into NULL. The tools
264
+ // cost (searches and fetches) is always known.
265
+ addDeepSeekTotals: db.prepare(`
266
+ INSERT INTO ${DEEPSEEK_USAGE_TOTALS_TABLE} (
267
+ model, hit_tokens, miss_tokens, output_tokens, search_requests, fetch_requests, turn_count,
268
+ cost_usd, cost_cny, tools_cost_usd, tools_cost_cny, first_seen_at, last_seen_at, updated_at
269
+ ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
270
+ ON CONFLICT(model) DO UPDATE SET
271
+ hit_tokens = ${DEEPSEEK_USAGE_TOTALS_TABLE}.hit_tokens + excluded.hit_tokens,
272
+ miss_tokens = ${DEEPSEEK_USAGE_TOTALS_TABLE}.miss_tokens + excluded.miss_tokens,
273
+ output_tokens = ${DEEPSEEK_USAGE_TOTALS_TABLE}.output_tokens + excluded.output_tokens,
274
+ search_requests = ${DEEPSEEK_USAGE_TOTALS_TABLE}.search_requests + excluded.search_requests,
275
+ fetch_requests = ${DEEPSEEK_USAGE_TOTALS_TABLE}.fetch_requests + excluded.fetch_requests,
276
+ turn_count = ${DEEPSEEK_USAGE_TOTALS_TABLE}.turn_count + 1,
277
+ cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN ${DEEPSEEK_USAGE_TOTALS_TABLE}.cost_usd
278
+ ELSE COALESCE(${DEEPSEEK_USAGE_TOTALS_TABLE}.cost_usd, 0) + excluded.cost_usd END,
279
+ cost_cny = CASE WHEN excluded.cost_cny IS NULL THEN ${DEEPSEEK_USAGE_TOTALS_TABLE}.cost_cny
280
+ ELSE COALESCE(${DEEPSEEK_USAGE_TOTALS_TABLE}.cost_cny, 0) + excluded.cost_cny END,
281
+ tools_cost_usd = ${DEEPSEEK_USAGE_TOTALS_TABLE}.tools_cost_usd + excluded.tools_cost_usd,
282
+ tools_cost_cny = ${DEEPSEEK_USAGE_TOTALS_TABLE}.tools_cost_cny + excluded.tools_cost_cny,
283
+ first_seen_at = MIN(${DEEPSEEK_USAGE_TOTALS_TABLE}.first_seen_at, excluded.first_seen_at),
284
+ last_seen_at = MAX(${DEEPSEEK_USAGE_TOTALS_TABLE}.last_seen_at, excluded.last_seen_at),
285
+ updated_at = excluded.updated_at
286
+ `),
287
+ // The same sums per UTC day, for the daily chart and the month's spend.
288
+ addDeepSeekDay: db.prepare(`
289
+ INSERT INTO ${DEEPSEEK_USAGE_DAYS_TABLE} (
290
+ day, model, hit_tokens, miss_tokens, output_tokens, search_requests, fetch_requests, turn_count,
291
+ cost_usd, cost_cny, tools_cost_usd, tools_cost_cny, updated_at
292
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
293
+ ON CONFLICT(day, model) DO UPDATE SET
294
+ hit_tokens = ${DEEPSEEK_USAGE_DAYS_TABLE}.hit_tokens + excluded.hit_tokens,
295
+ miss_tokens = ${DEEPSEEK_USAGE_DAYS_TABLE}.miss_tokens + excluded.miss_tokens,
296
+ output_tokens = ${DEEPSEEK_USAGE_DAYS_TABLE}.output_tokens + excluded.output_tokens,
297
+ search_requests = ${DEEPSEEK_USAGE_DAYS_TABLE}.search_requests + excluded.search_requests,
298
+ fetch_requests = ${DEEPSEEK_USAGE_DAYS_TABLE}.fetch_requests + excluded.fetch_requests,
299
+ turn_count = ${DEEPSEEK_USAGE_DAYS_TABLE}.turn_count + 1,
300
+ cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN ${DEEPSEEK_USAGE_DAYS_TABLE}.cost_usd
301
+ ELSE COALESCE(${DEEPSEEK_USAGE_DAYS_TABLE}.cost_usd, 0) + excluded.cost_usd END,
302
+ cost_cny = CASE WHEN excluded.cost_cny IS NULL THEN ${DEEPSEEK_USAGE_DAYS_TABLE}.cost_cny
303
+ ELSE COALESCE(${DEEPSEEK_USAGE_DAYS_TABLE}.cost_cny, 0) + excluded.cost_cny END,
304
+ tools_cost_usd = ${DEEPSEEK_USAGE_DAYS_TABLE}.tools_cost_usd + excluded.tools_cost_usd,
305
+ tools_cost_cny = ${DEEPSEEK_USAGE_DAYS_TABLE}.tools_cost_cny + excluded.tools_cost_cny,
306
+ updated_at = excluded.updated_at
307
+ `),
308
+ listDeepSeekTotals: db.prepare(`SELECT * FROM ${DEEPSEEK_USAGE_TOTALS_TABLE} ORDER BY last_seen_at DESC, model`),
309
+ listDeepSeekDays: db.prepare(`SELECT * FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE day >= ? ORDER BY day, model`),
310
+ firstDeepSeekDay: db.prepare(`SELECT MIN(day) AS day FROM ${DEEPSEEK_USAGE_DAYS_TABLE}`),
311
+ listDeepSeekBalanceReferences: db.prepare(`SELECT * FROM ${DEEPSEEK_BALANCE_REFERENCE_TABLE} ORDER BY currency`),
312
+ getDeepSeekBalanceReference: db.prepare(`SELECT * FROM ${DEEPSEEK_BALANCE_REFERENCE_TABLE} WHERE currency = ?`),
313
+ upsertDeepSeekBalanceReference: db.prepare(`
314
+ INSERT INTO ${DEEPSEEK_BALANCE_REFERENCE_TABLE} (currency, reference_total, last_total, reference_at, updated_at, rose_at, last_read_at, rose_from_at)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
316
+ ON CONFLICT(currency) DO UPDATE SET
317
+ reference_total = excluded.reference_total,
318
+ last_total = excluded.last_total,
319
+ reference_at = excluded.reference_at,
320
+ updated_at = excluded.updated_at,
321
+ rose_at = excluded.rose_at,
322
+ last_read_at = excluded.last_read_at,
323
+ rose_from_at = excluded.rose_from_at
324
+ `),
325
+ getDeepSeekCheckpoint: db.prepare(`
326
+ SELECT * FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE}
327
+ WHERE conversation_id = ? AND native_session_id = ? AND model = ?
328
+ `),
329
+ upsertDeepSeekCheckpoint: db.prepare(`
330
+ INSERT INTO ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} (
331
+ conversation_id, native_session_id, model, run_id, request_run_id,
332
+ hit_tokens, miss_tokens, output_tokens, search_requests, fetch_requests, updated_at
333
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
334
+ ON CONFLICT(conversation_id, native_session_id, model) DO UPDATE SET
335
+ run_id = excluded.run_id,
336
+ request_run_id = excluded.request_run_id,
337
+ hit_tokens = excluded.hit_tokens,
338
+ miss_tokens = excluded.miss_tokens,
339
+ output_tokens = excluded.output_tokens,
340
+ search_requests = excluded.search_requests,
341
+ fetch_requests = excluded.fetch_requests,
342
+ updated_at = excluded.updated_at
343
+ `),
204
344
  };
205
345
 
206
346
  function nowIso() {
@@ -384,6 +524,252 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
384
524
  return { cycle };
385
525
  }
386
526
 
527
+ /**
528
+ * Book one DeepSeek report (normalizeDeepSeekUsageReport): a worker's
529
+ * running token totals per model for one CLI session, with its estimate
530
+ * at the price of the report's time in both currencies. The checkpoint
531
+ * per (conversation, session, model) holds the totals last booked and the
532
+ * `runId` they came from, the id the worker gives each CLI process.
533
+ *
534
+ * With a run id the totals are that process's own, counted from zero when
535
+ * it started (the worker takes the CLI's session totals apart, so what a
536
+ * resumed CLI restored from its transcript is never in them). Under the
537
+ * run the checkpoint came from only the increase is booked, field by
538
+ * field, and a total below the checkpoint is a stale report (a post
539
+ * retried or delivered out of order): it books nothing and leaves the
540
+ * checkpoint at its high-water mark. Under another run the report is a
541
+ * new process, booked in full, from which the checkpoint starts over; a
542
+ * late report of the earlier process after that is booked in full too,
543
+ * since the relay cannot tell it from a process counting from zero.
544
+ *
545
+ * Without a run id (an older worker) the totals are the CLI's own, which
546
+ * a resumed CLI continues from its session's earlier ones: what they add
547
+ * is booked, and a drop is a CLI counting from zero again, booked in full.
548
+ *
549
+ * Web searches and fetches: a report with a run id carries the process's
550
+ * running counts per model, booked like the tokens under a run id of their
551
+ * own (`request_run_id`: an entry with counts only, as a sub-agent's model
552
+ * that searched without tokens of its own, leaves the token run alone).
553
+ * Without a run id they are the turn's counts, booked as they come.
554
+ * Returns what was booked: models with nothing new are left out.
555
+ */
556
+ function recordDeepSeekUsageReport(report) {
557
+ const at = toTrimmedString(report?.at);
558
+ const conversationId = toTrimmedString(report?.conversationId);
559
+ const nativeSessionId = toTrimmedString(report?.nativeSessionId);
560
+ const runId = toTrimmedString(report?.runId) || '';
561
+ const totals = Array.isArray(report?.totals) ? report.totals : [];
562
+ const requests = Array.isArray(report?.requests) ? report.requests : [];
563
+ if (!at || !conversationId || !nativeSessionId || (!totals.length && !requests.length)) return null;
564
+ const timestamp = nowIso();
565
+ const day = at.slice(0, 10);
566
+ // Entries that name one DeepSeek model in two ways (its own id and a
567
+ // Claude id it served) are summed, like the worker sums the CLI's names.
568
+ const totalsByModel = new Map();
569
+ for (const entry of totals) {
570
+ const model = deepseekBookedModel(entry?.model);
571
+ if (!model) continue;
572
+ const current = totalsByModel.get(model) || { hitTokens: 0, missTokens: 0, outputTokens: 0 };
573
+ for (const [field] of DEEPSEEK_TOKEN_FIELDS) current[field] += Math.max(0, Number(entry?.[field]) || 0);
574
+ totalsByModel.set(model, current);
575
+ }
576
+ const requestsByModel = new Map();
577
+ for (const entry of requests) {
578
+ const model = deepseekBookedModel(entry?.model);
579
+ if (!model) continue;
580
+ const current = requestsByModel.get(model) || { searchRequests: 0, fetchRequests: 0 };
581
+ current.searchRequests += Math.max(0, Number(entry.searchRequests) || 0);
582
+ current.fetchRequests += Math.max(0, Number(entry.fetchRequests) || 0);
583
+ requestsByModel.set(model, current);
584
+ }
585
+ const models = [...new Set([...totalsByModel.keys(), ...requestsByModel.keys()])];
586
+ const booked = [];
587
+ const persist = db.transaction(() => {
588
+ for (const model of models) {
589
+ const entry = totalsByModel.get(model);
590
+ const modelRequests = requestsByModel.get(model);
591
+ const delta = { model, hitTokens: 0, missTokens: 0, outputTokens: 0, searchRequests: 0, fetchRequests: 0 };
592
+ const previous = stmts.getDeepSeekCheckpoint.get(conversationId, nativeSessionId, model) || null;
593
+ // The checkpoint's totals (counts) came from this very process: they
594
+ // can only have grown since.
595
+ const sameRun = Boolean(runId) && Boolean(previous) && String(previous.run_id || '') === runId;
596
+ const sameRequestRun = Boolean(runId) && Boolean(previous) && String(previous.request_run_id || '') === runId;
597
+ // What the checkpoint will hold: the report's totals, or the
598
+ // high-water mark when the report is stale.
599
+ const checkpoint = { runId: String(previous?.run_id || ''), requestRunId: String(previous?.request_run_id || '') };
600
+ for (const [field, column] of DEEPSEEK_TOKEN_FIELDS) checkpoint[field] = Number(previous?.[column]) || 0;
601
+ for (const [field, column] of DEEPSEEK_REQUEST_FIELDS) checkpoint[field] = Number(previous?.[column]) || 0;
602
+ if (entry) {
603
+ const reported = {};
604
+ for (const [field] of DEEPSEEK_TOKEN_FIELDS) reported[field] = Number(entry[field]) || 0;
605
+ const drop = DEEPSEEK_TOKEN_FIELDS.some(([field]) => reported[field] < checkpoint[field]);
606
+ if (drop && sameRun) {
607
+ // Stale: a retried post, or one that arrived after a later one.
608
+ } else {
609
+ // A process's own totals under a new run are all new spend; an
610
+ // older worker's session totals add their increase, or are a CLI
611
+ // counting from zero again when they dropped.
612
+ const full = runId ? !sameRun : drop;
613
+ for (const [field] of DEEPSEEK_TOKEN_FIELDS) {
614
+ delta[field] = full ? reported[field] : reported[field] - checkpoint[field];
615
+ checkpoint[field] = reported[field];
616
+ }
617
+ checkpoint.runId = runId;
618
+ }
619
+ }
620
+ if (modelRequests && runId) {
621
+ // Running counts for this process: in full under a new run (the
622
+ // checkpoint's counts are another process's), the increase under
623
+ // the same one, nothing when they are at or below the checkpoint.
624
+ const drop = DEEPSEEK_REQUEST_FIELDS.some(([field]) => modelRequests[field] < checkpoint[field]);
625
+ if (!(drop && sameRequestRun)) {
626
+ for (const [field] of DEEPSEEK_REQUEST_FIELDS) {
627
+ delta[field] = sameRequestRun ? modelRequests[field] - checkpoint[field] : modelRequests[field];
628
+ checkpoint[field] = modelRequests[field];
629
+ }
630
+ checkpoint.requestRunId = runId;
631
+ }
632
+ } else if (modelRequests) {
633
+ // An older worker: the turn's counts, booked as they come.
634
+ delta.searchRequests = modelRequests.searchRequests;
635
+ delta.fetchRequests = modelRequests.fetchRequests;
636
+ }
637
+ if (entry || (modelRequests && runId)) {
638
+ stmts.upsertDeepSeekCheckpoint.run(
639
+ conversationId,
640
+ nativeSessionId,
641
+ model,
642
+ checkpoint.runId,
643
+ checkpoint.requestRunId,
644
+ checkpoint.hitTokens,
645
+ checkpoint.missTokens,
646
+ checkpoint.outputTokens,
647
+ checkpoint.searchRequests,
648
+ checkpoint.fetchRequests,
649
+ timestamp,
650
+ );
651
+ }
652
+ const tokenCount = delta.hitTokens + delta.missTokens + delta.outputTokens;
653
+ if (tokenCount <= 0 && delta.searchRequests + delta.fetchRequests <= 0) continue;
654
+ const tokens = { hitTokens: delta.hitTokens, missTokens: delta.missTokens, outputTokens: delta.outputTokens };
655
+ // No tokens booked (a request-only report) is no token estimate.
656
+ delta.costUsd = tokenCount > 0 ? estimateCost({ model, at, currency: 'USD', ...tokens }) : null;
657
+ delta.costCny = tokenCount > 0 ? estimateCost({ model, at, currency: 'CNY', ...tokens }) : null;
658
+ const requested = { at, searchRequests: delta.searchRequests, fetchRequests: delta.fetchRequests };
659
+ delta.toolsCostUsd = estimateSearchCost({ ...requested, currency: 'USD' }) || 0;
660
+ delta.toolsCostCny = estimateSearchCost({ ...requested, currency: 'CNY' }) || 0;
661
+ const values = [
662
+ delta.hitTokens,
663
+ delta.missTokens,
664
+ delta.outputTokens,
665
+ delta.searchRequests,
666
+ delta.fetchRequests,
667
+ delta.costUsd,
668
+ delta.costCny,
669
+ delta.toolsCostUsd,
670
+ delta.toolsCostCny,
671
+ ];
672
+ stmts.addDeepSeekTotals.run(model, ...values, at, at, timestamp);
673
+ stmts.addDeepSeekDay.run(day, model, ...values, timestamp);
674
+ booked.push(delta);
675
+ }
676
+ });
677
+ persist();
678
+ return { at, models: booked };
679
+ }
680
+
681
+ // Ten decimals, as each estimate is kept: the sum's binary-float tail goes.
682
+ const tenDecimals = (value) => Math.round(Number(value) * 1e10) / 1e10;
683
+ const nullableMoney = (value) => (value === null || value === undefined ? null : tenDecimals(value));
684
+
685
+ function deepseekSumsOf(row) {
686
+ return {
687
+ model: row.model,
688
+ hitTokens: Number(row.hit_tokens) || 0,
689
+ missTokens: Number(row.miss_tokens) || 0,
690
+ outputTokens: Number(row.output_tokens) || 0,
691
+ searchRequests: Number(row.search_requests) || 0,
692
+ fetchRequests: Number(row.fetch_requests) || 0,
693
+ turnCount: Number(row.turn_count) || 0,
694
+ costUsd: nullableMoney(row.cost_usd),
695
+ costCny: nullableMoney(row.cost_cny),
696
+ toolsCostUsd: tenDecimals(row.tools_cost_usd || 0),
697
+ toolsCostCny: tenDecimals(row.tools_cost_cny || 0),
698
+ };
699
+ }
700
+
701
+ function readDeepSeekUsageTotals() {
702
+ const models = stmts.listDeepSeekTotals.all().map((row) => ({
703
+ ...deepseekSumsOf(row),
704
+ firstSeenAt: row.first_seen_at || null,
705
+ lastSeenAt: row.last_seen_at || null,
706
+ }));
707
+ return { models };
708
+ }
709
+
710
+ /**
711
+ * The daily sums per model from `since` (a `YYYY-MM-DD` UTC day) on:
712
+ * `{ days: [{ day, model, ...sums }] }`, oldest first.
713
+ */
714
+ function readDeepSeekUsageDays({ since } = {}) {
715
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(String(since ?? '')) ? String(since) : '0000-00-00';
716
+ const days = stmts.listDeepSeekDays.all(from).map((row) => ({ day: row.day, ...deepseekSumsOf(row) }));
717
+ // The first day the daily table holds: the history starts with the
718
+ // relay that brought it, not with the first DeepSeek turn.
719
+ const first = stmts.firstDeepSeekDay.get()?.day || null;
720
+ return { days, firstDay: first };
721
+ }
722
+
723
+ function deepseekBalanceReferenceOf(row) {
724
+ return {
725
+ currency: String(row.currency),
726
+ referenceTotal: Number(row.reference_total) || 0,
727
+ lastTotal: Number(row.last_total) || 0,
728
+ referenceAt: row.reference_at || null,
729
+ // When the balance last rose after the first read, and when the
730
+ // balance before that rise was read (migration 0029).
731
+ roseAt: row.rose_at || null,
732
+ roseFromAt: row.rose_from_at || null,
733
+ };
734
+ }
735
+
736
+ /** The balance references per currency (`recordDeepSeekBalance`). */
737
+ function readDeepSeekBalanceReferences() {
738
+ return stmts.listDeepSeekBalanceReferences.all().map(deepseekBalanceReferenceOf);
739
+ }
740
+
741
+ /**
742
+ * Notes a balance read (deepseek-balance-service.mjs's `{ ok: true,
743
+ * balances: [{ currency, total }] }`) for the balance meter, which shows
744
+ * what is left of the balance since the last top-up. Per currency the
745
+ * reference is the balance right after its latest rise (a top-up or a
746
+ * grant); before any rise was seen it is the highest balance seen, so the
747
+ * meter is full on the first read and fills with the spend. A failed read,
748
+ * or one repeated from the cache, changes nothing. Returns the references.
749
+ */
750
+ function recordDeepSeekBalance(balance) {
751
+ if (balance?.ok !== true || !Array.isArray(balance.balances)) return readDeepSeekBalanceReferences();
752
+ const readAt = toIsoTimestamp(balance.fetchedAt) || nowIso();
753
+ const timestamp = nowIso();
754
+ db.transaction(() => {
755
+ for (const entry of balance.balances) {
756
+ const currency = toTrimmedString(entry?.currency)?.toUpperCase();
757
+ const total = toFiniteNumber(entry?.total);
758
+ if (!currency || total === null || total < 0) continue;
759
+ const row = stmts.getDeepSeekBalanceReference.get(currency);
760
+ const rose = row ? total > Number(row.last_total) : false;
761
+ const reference = row && !rose ? Math.max(Number(row.reference_total) || 0, total) : total;
762
+ const referenceAt = !row || rose || reference !== Number(row.reference_total) ? readAt : (row.reference_at || readAt);
763
+ const roseAt = rose ? readAt : (row?.rose_at || null);
764
+ // The read before the rise: a row from before last_read_at was
765
+ // recorded at about the time it was read.
766
+ const roseFromAt = rose ? (row.last_read_at || row.updated_at || null) : (row?.rose_from_at || null);
767
+ stmts.upsertDeepSeekBalanceReference.run(currency, reference, total, referenceAt, timestamp, roseAt, readAt, roseFromAt);
768
+ }
769
+ })();
770
+ return readDeepSeekBalanceReferences();
771
+ }
772
+
387
773
  // Claude Cloud conversations keep their reported cost on the runtime
388
774
  // session row (migration 0007). That table is the relay's, not this
389
775
  // service's, so the statements are prepared on first use, and only once
@@ -464,6 +850,21 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
464
850
  grokConfigured = false,
465
851
  grokAllowances = null,
466
852
  grokBilling = null,
853
+ deepseekConfigured = false,
854
+ // The balance service's answer (deepseek-balance-service.mjs), or null.
855
+ deepseekBalance = null,
856
+ // The DeepSeek tab's monthly budget and its currency ('USD' or 'CNY'; null
857
+ // for a budget saved without one, read in the balance's currency), or null.
858
+ deepseekMonthlyBudget = null,
859
+ deepseekMonthlyBudgetCurrency = null,
860
+ deepseekStartingBalance = null,
861
+ // The imported DeepSeek export's figures of the saved key
862
+ // (deepseek-usage-import.mjs readActuals()), or null: read only, laid
863
+ // over the estimate by the card.
864
+ deepseekActuals = null,
865
+ // The Claude API card, built by its own module (plan-usage-claude-api.mjs)
866
+ // from the Claude API booking; null while Claude API has no key and is off.
867
+ claudeApiCard = null,
467
868
  } = {}) {
468
869
  const generatedAt = nowIso();
469
870
  const claudeSnapshot = readSnapshot('claude');
@@ -513,6 +914,8 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
513
914
  configured: true,
514
915
  })
515
916
  : null,
917
+ // Opt-in too, and next to the Claude cards: the same models on a key.
918
+ claudeApiCard || null,
516
919
  buildCursorPlanCard({
517
920
  totals: cursorCycle.totals,
518
921
  allowances,
@@ -544,6 +947,34 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
544
947
  }));
545
948
  }
546
949
 
950
+ // Opt-in like Grok: no card while DeepSeek is off.
951
+ if (deepseekConfigured === true) {
952
+ // The daily chart's 30 days and this calendar month, whichever reaches
953
+ // further back (UTC days, like the booking).
954
+ const today = generatedAt.slice(0, 10);
955
+ const monthStart = `${today.slice(0, 7)}-01`;
956
+ const thirtyDaysAgo = new Date(Date.parse(`${today}T00:00:00.000Z`) - 29 * 86_400_000).toISOString().slice(0, 10);
957
+ // The daily rows reach back to the export's first day too, so the card
958
+ // can take the estimate of every day the export covers out of the totals.
959
+ const firstActualDay = [
960
+ ...(Array.isArray(deepseekActuals?.rows) ? deepseekActuals.rows : []),
961
+ ...(Array.isArray(deepseekActuals?.coveredDays) ? deepseekActuals.coveredDays : []),
962
+ ].map((row) => String(row?.day || '')).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()[0] || null;
963
+ const since = [thirtyDaysAgo, monthStart, firstActualDay].filter(Boolean).sort()[0];
964
+ providers.push(buildDeepSeekPlanCard({
965
+ balance: deepseekBalance,
966
+ balanceReferences: recordDeepSeekBalance(deepseekBalance),
967
+ totals: readDeepSeekUsageTotals(),
968
+ days: readDeepSeekUsageDays({ since }),
969
+ monthlyBudget: deepseekMonthlyBudget,
970
+ monthlyBudgetCurrency: deepseekMonthlyBudgetCurrency,
971
+ startingBalance: deepseekStartingBalance,
972
+ now: generatedAt,
973
+ configured: true,
974
+ actuals: deepseekActuals,
975
+ }));
976
+ }
977
+
547
978
  return {
548
979
  version: PLAN_USAGE_VERSION,
549
980
  generatedAt,
@@ -560,6 +991,11 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
560
991
  recordGrokUsageReport,
561
992
  readGrokCycleTotals,
562
993
  resetGrokAccounting,
994
+ recordDeepSeekUsageReport,
995
+ readDeepSeekUsageTotals,
996
+ readDeepSeekUsageDays,
997
+ recordDeepSeekBalance,
998
+ readDeepSeekBalanceReferences,
563
999
  readClaudeCloudSpend,
564
1000
  buildReport,
565
1001
  pools: CURSOR_POOLS,
@@ -90,7 +90,7 @@ export function resolveProviderModelSelection({
90
90
  };
91
91
  }
92
92
 
93
- // ─── Checking a model an agent names (remote_relay create_session / send) ───
93
+ // ─── Checking a model an agent names (relay_tool create_session / send) ───
94
94
 
95
95
  const PROVIDER_LABELS = Object.freeze({
96
96
  github: 'Copilot',
@@ -99,8 +99,10 @@ const PROVIDER_LABELS = Object.freeze({
99
99
  cursor: 'Cursor',
100
100
  grok: 'Grok',
101
101
  'claude-cloud': 'Claude Cloud',
102
+ deepseek: 'DeepSeek',
103
+ 'claude-api': 'Claude API',
102
104
  });
103
- const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud']);
105
+ const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud', 'claude-api']);
104
106
  const LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
105
107
  // A Copilot model with its effort, as the composer sends it ("gpt-5.4-high").
106
108
  const EFFORT_VARIANT_SUFFIX = /-(none|minimal|low|medium|high|xhigh|max)$/i;
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ import { DEEPSEEK_REASONING_EFFORTS } from '../../shared/deepseek-models.mjs';
3
4
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
4
5
 
5
6
  // 'ultracode' is not an Agent SDK EffortLevel: it is a relay-side sentinel for
@@ -44,6 +45,7 @@ export function supportedReasoningEffortsForProviderModel({
44
45
  model = '',
45
46
  cursorSettings = null,
46
47
  claudeSettings = null,
48
+ claudeApiSettings = null,
47
49
  grokSettings = null,
48
50
  reasoningByModel = null,
49
51
  } = {}) {
@@ -52,11 +54,21 @@ export function supportedReasoningEffortsForProviderModel({
52
54
  if (provider === 'claude') {
53
55
  return effortsForModel(claudeSettings?.effortsByModel, model) || [...DEFAULT_CLAUDE_REASONING_EFFORTS];
54
56
  }
57
+ // Claude API runs the same CLI as Claude: its levels are the Claude
58
+ // provider's (Ultracode included), narrowed by what Anthropic's model list
59
+ // says about the model (claude-api-settings-service.mjs).
60
+ if (provider === 'claude-api') {
61
+ return effortsForModel(claudeApiSettings?.effortsByModel, model)
62
+ || effortsForModel(claudeSettings?.effortsByModel, model)
63
+ || [...DEFAULT_CLAUDE_REASONING_EFFORTS];
64
+ }
55
65
  if (provider === 'grok') {
56
66
  return effortsForModel(grokSettings?.effortsByModel, model) || [...DEFAULT_GROK_REASONING_EFFORTS];
57
67
  }
58
68
  // A cloud session takes no effort choice: the only tier is the default one.
59
69
  if (provider === 'claude-cloud') return [...CLAUDE_CLOUD_REASONING_EFFORTS];
70
+ // DeepSeek's levels are the same for every model it serves.
71
+ if (provider === 'deepseek') return [...DEEPSEEK_REASONING_EFFORTS];
60
72
  if (provider === 'openai' || provider === 'openai-image') {
61
73
  const efforts = openAIReasoningEffortsForModel(model);
62
74
  return Array.isArray(efforts) && efforts.length ? efforts.map(normalizeEffort).filter(Boolean) : null;
@@ -72,18 +84,28 @@ export function supportedReasoningEffortsForProviderModel({
72
84
  // `strict` reproduces the one provider that refuses rather than clamps: OpenAI
73
85
  // answers an unsupported effort with a 400, so bootstrap must not store a value
74
86
  // the first send would reject.
87
+ //
88
+ // `defaultEffort` replaces the off-switch as the answer to an unstated effort
89
+ // (DeepSeek thinks at `high` unless told otherwise), and `aliasFor` maps a
90
+ // request into the provider's own levels before it is checked.
75
91
  export function resolveProviderReasoningEffort({
76
92
  requestedEffort = '',
77
93
  supportedEfforts = null,
78
94
  strict = false,
95
+ defaultEffort = '',
96
+ aliasFor = null,
79
97
  } = {}) {
80
- const requested = normalizeEffort(requestedEffort);
98
+ const raw = normalizeEffort(requestedEffort);
99
+ const requested = raw && typeof aliasFor === 'function' ? (normalizeEffort(aliasFor(raw)) || raw) : raw;
81
100
  const supported = Array.isArray(supportedEfforts)
82
101
  ? supportedEfforts.map(normalizeEffort).filter(Boolean)
83
102
  : null;
84
103
  if (!supported) return { ok: true, effort: requested, supported: [] };
85
104
  const providerDefault = supported.includes('none') ? 'none' : (supported[0] || '');
86
- if (!requested) return { ok: true, effort: providerDefault, supported };
105
+ const preferredDefault = normalizeEffort(defaultEffort);
106
+ if (!requested) {
107
+ return { ok: true, effort: supported.includes(preferredDefault) ? preferredDefault : providerDefault, supported };
108
+ }
87
109
  if (supported.includes(requested)) return { ok: true, effort: requested, supported };
88
110
  if (strict) {
89
111
  return {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Questions that don't block: remote_relay's `ask` (shared/relay-asks.mjs).
4
+ * Questions that don't block: relay_tool's `ask` (shared/relay-asks.mjs).
5
5
  * An agent asks the owner something and goes on working; the question shows
6
6
  * as a card in its session and in the Inbox, and when the owner answers or
7
7
  * dismisses it the relay tells the session the ask is in and the
@@ -107,6 +107,7 @@ import {
107
107
  relayEventQuietWindowEnd,
108
108
  relayEventTypesTake,
109
109
  } from '../../shared/relay-events.mjs';
110
+ import { REMOTE_RELAY_APPROVAL_SOURCE } from '../../shared/relay-asks.mjs';
110
111
 
111
112
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
112
113
  export const RELAY_EVENT_SELF_RELAY_ID = 'self';
@@ -2307,8 +2308,8 @@ export function createRelayEventService({
2307
2308
  prompt: String(question.prompt || question.question || '').slice(0, 2000),
2308
2309
  options,
2309
2310
  questionId: toText(question.id),
2310
- ...(toText(context?.source) === 'remote_relay' ? { approvalCard: true } : {}),
2311
- // A question that does not block (remote_relay ask).
2311
+ ...(toText(context?.source) === REMOTE_RELAY_APPROVAL_SOURCE ? { approvalCard: true } : {}),
2312
+ // A question that does not block (relay_tool ask).
2312
2313
  ...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
2313
2314
  });
2314
2315
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Relay schedules: a prompt the relay queues into a session at a set time,
5
- * once or every N minutes (remote_relay's `schedule`). An agent's own timers
5
+ * once or every N minutes (relay_tool's `schedule`). An agent's own timers
6
6
  * die with the relay; these live in the database (migrations/0022-relay-schedules.mjs)
7
7
  * and their timers are rebuilt at boot.
8
8
  *
@@ -118,6 +118,13 @@ function describeRemoteTurn(action, args, caller, group = null) {
118
118
  const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
119
119
  parts.push(`Provider: ${provider}`);
120
120
  }
121
+ // A send that names a Claude billing moves the session to it with the
122
+ // message: who pays for its turns changes, so the card says so.
123
+ if (action === 'send') {
124
+ const billing = toText(args.provider).toLowerCase();
125
+ if (billing === 'claude-api') parts.push('Switches billing to API key');
126
+ else if (billing === 'claude') parts.push('Switches billing to subscription');
127
+ }
121
128
  if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
122
129
  if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)}`);
123
130
  if (action === 'create_session') {