@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
@@ -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,18 @@ 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,
467
865
  } = {}) {
468
866
  const generatedAt = nowIso();
469
867
  const claudeSnapshot = readSnapshot('claude');
@@ -544,6 +942,34 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
544
942
  }));
545
943
  }
546
944
 
945
+ // Opt-in like Grok: no card while DeepSeek is off.
946
+ if (deepseekConfigured === true) {
947
+ // The daily chart's 30 days and this calendar month, whichever reaches
948
+ // further back (UTC days, like the booking).
949
+ const today = generatedAt.slice(0, 10);
950
+ const monthStart = `${today.slice(0, 7)}-01`;
951
+ const thirtyDaysAgo = new Date(Date.parse(`${today}T00:00:00.000Z`) - 29 * 86_400_000).toISOString().slice(0, 10);
952
+ // The daily rows reach back to the export's first day too, so the card
953
+ // can take the estimate of every day the export covers out of the totals.
954
+ const firstActualDay = [
955
+ ...(Array.isArray(deepseekActuals?.rows) ? deepseekActuals.rows : []),
956
+ ...(Array.isArray(deepseekActuals?.coveredDays) ? deepseekActuals.coveredDays : []),
957
+ ].map((row) => String(row?.day || '')).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()[0] || null;
958
+ const since = [thirtyDaysAgo, monthStart, firstActualDay].filter(Boolean).sort()[0];
959
+ providers.push(buildDeepSeekPlanCard({
960
+ balance: deepseekBalance,
961
+ balanceReferences: recordDeepSeekBalance(deepseekBalance),
962
+ totals: readDeepSeekUsageTotals(),
963
+ days: readDeepSeekUsageDays({ since }),
964
+ monthlyBudget: deepseekMonthlyBudget,
965
+ monthlyBudgetCurrency: deepseekMonthlyBudgetCurrency,
966
+ startingBalance: deepseekStartingBalance,
967
+ now: generatedAt,
968
+ configured: true,
969
+ actuals: deepseekActuals,
970
+ }));
971
+ }
972
+
547
973
  return {
548
974
  version: PLAN_USAGE_VERSION,
549
975
  generatedAt,
@@ -560,6 +986,11 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
560
986
  recordGrokUsageReport,
561
987
  readGrokCycleTotals,
562
988
  resetGrokAccounting,
989
+ recordDeepSeekUsageReport,
990
+ readDeepSeekUsageTotals,
991
+ readDeepSeekUsageDays,
992
+ recordDeepSeekBalance,
993
+ readDeepSeekBalanceReferences,
563
994
  readClaudeCloudSpend,
564
995
  buildReport,
565
996
  pools: CURSOR_POOLS,
@@ -99,6 +99,7 @@ const PROVIDER_LABELS = Object.freeze({
99
99
  cursor: 'Cursor',
100
100
  grok: 'Grok',
101
101
  'claude-cloud': 'Claude Cloud',
102
+ deepseek: 'DeepSeek',
102
103
  });
103
104
  const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud']);
104
105
  const LONG_CONTEXT_SUFFIX = /\[1m\]$/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
@@ -57,6 +58,8 @@ export function supportedReasoningEffortsForProviderModel({
57
58
  }
58
59
  // A cloud session takes no effort choice: the only tier is the default one.
59
60
  if (provider === 'claude-cloud') return [...CLAUDE_CLOUD_REASONING_EFFORTS];
61
+ // DeepSeek's levels are the same for every model it serves.
62
+ if (provider === 'deepseek') return [...DEEPSEEK_REASONING_EFFORTS];
60
63
  if (provider === 'openai' || provider === 'openai-image') {
61
64
  const efforts = openAIReasoningEffortsForModel(model);
62
65
  return Array.isArray(efforts) && efforts.length ? efforts.map(normalizeEffort).filter(Boolean) : null;
@@ -72,18 +75,28 @@ export function supportedReasoningEffortsForProviderModel({
72
75
  // `strict` reproduces the one provider that refuses rather than clamps: OpenAI
73
76
  // answers an unsupported effort with a 400, so bootstrap must not store a value
74
77
  // the first send would reject.
78
+ //
79
+ // `defaultEffort` replaces the off-switch as the answer to an unstated effort
80
+ // (DeepSeek thinks at `high` unless told otherwise), and `aliasFor` maps a
81
+ // request into the provider's own levels before it is checked.
75
82
  export function resolveProviderReasoningEffort({
76
83
  requestedEffort = '',
77
84
  supportedEfforts = null,
78
85
  strict = false,
86
+ defaultEffort = '',
87
+ aliasFor = null,
79
88
  } = {}) {
80
- const requested = normalizeEffort(requestedEffort);
89
+ const raw = normalizeEffort(requestedEffort);
90
+ const requested = raw && typeof aliasFor === 'function' ? (normalizeEffort(aliasFor(raw)) || raw) : raw;
81
91
  const supported = Array.isArray(supportedEfforts)
82
92
  ? supportedEfforts.map(normalizeEffort).filter(Boolean)
83
93
  : null;
84
94
  if (!supported) return { ok: true, effort: requested, supported: [] };
85
95
  const providerDefault = supported.includes('none') ? 'none' : (supported[0] || '');
86
- if (!requested) return { ok: true, effort: providerDefault, supported };
96
+ const preferredDefault = normalizeEffort(defaultEffort);
97
+ if (!requested) {
98
+ return { ok: true, effort: supported.includes(preferredDefault) ? preferredDefault : providerDefault, supported };
99
+ }
87
100
  if (supported.includes(requested)) return { ok: true, effort: requested, supported };
88
101
  if (strict) {
89
102
  return {