@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
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek usage rows booked under a Claude model name are folded
5
+ * into the DeepSeek model that served them (services/plan-usage-service.mjs,
6
+ * the DeepSeek card in Check Usage). Builds on 0024 and 0025.
7
+ *
8
+ * A DeepSeek chat runs Claude Code. Before the CLI's model aliases were pinned
9
+ * to the chat's model, a sub-agent asked for as "opus" went to DeepSeek as
10
+ * `claude-opus-5-5`, which DeepSeek's Anthropic-compatible endpoint serves
11
+ * with V4 Pro (and `claude-sonnet*` / `claude-haiku*` with Flash). Such
12
+ * turns were booked under the Claude name and, as shared/deepseek-pricing.mjs
13
+ * prices only DeepSeek's ids, without a price.
14
+ *
15
+ * For each of `deepseek_usage_totals`, `deepseek_usage_days` and
16
+ * `deepseek_usage_checkpoints`, every row whose model name
17
+ * shared/deepseek-models.mjs maps to a DeepSeek model (`deepseekServedModel`)
18
+ * is added to that model's row (created when there is none) and deleted:
19
+ * tokens, requests and turns are summed; `first_seen_at` / `last_seen_at`
20
+ * widen. The folded tokens are priced at the DeepSeek model's price for the
21
+ * row's last turn time (`last_seen_at` of a totals row, `updated_at` of a
22
+ * daily row: peak or off-peak by that time, as the rows keep no per-turn
23
+ * times), the folded requests at the request price for that time, and the
24
+ * amounts are added to the target's costs. A checkpoint is a CLI session's
25
+ * running total per model, so the Claude-named and the DeepSeek-named
26
+ * checkpoints of one session add up to what the session reports once its
27
+ * worker names the DeepSeek model.
28
+ *
29
+ * Idempotent: a database without such rows is left as it is. Safe to run on
30
+ * every boot.
31
+ *
32
+ * Usage: node server/migrations/0026-deepseek-usage-served-models.mjs [path/to/copilot.db]
33
+ */
34
+
35
+ import { fileURLToPath } from 'url';
36
+ import path from 'path';
37
+ import Database from 'better-sqlite3';
38
+
39
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE, DEEPSEEK_USAGE_TOTALS_TABLE } from './0024-deepseek-usage-totals.mjs';
40
+ import { DEEPSEEK_USAGE_DAYS_TABLE, migrateDeepSeekUsageRequestsDaily } from './0025-deepseek-usage-requests-daily.mjs';
41
+ import { deepseekServedModel } from '../../shared/deepseek-models.mjs';
42
+ import { estimateCost, estimateSearchCost } from '../../shared/deepseek-pricing.mjs';
43
+
44
+ /** The DeepSeek model a stored model name is folded into, or '' when the row stays. */
45
+ export function deepseekFoldedModel(model) {
46
+ const name = String(model ?? '').trim();
47
+ const served = deepseekServedModel(name);
48
+ return served && served !== name ? served : '';
49
+ }
50
+
51
+ function foldableRows(db, table) {
52
+ return db.prepare(`SELECT * FROM ${table}`).all()
53
+ .map((row) => ({ row, target: deepseekFoldedModel(row.model) }))
54
+ .filter((entry) => entry.target);
55
+ }
56
+
57
+ /** The rows the service would still read under a Claude name: '' when none. */
58
+ export function deepseekUsageServedModelsProblem(db) {
59
+ for (const table of [DEEPSEEK_USAGE_TOTALS_TABLE, DEEPSEEK_USAGE_DAYS_TABLE, DEEPSEEK_USAGE_CHECKPOINTS_TABLE]) {
60
+ const count = foldableRows(db, table).length;
61
+ if (count) return `${table} holds ${count} row(s) under a model name DeepSeek served with another model`;
62
+ }
63
+ return '';
64
+ }
65
+
66
+ const count = (value) => Math.max(0, Math.round(Number(value) || 0));
67
+ const money = (value) => (value === null || value === undefined ? null : Number(value));
68
+
69
+ /** The folded row's token and request costs at the target model's price for `at`. */
70
+ function repriced(target, row, at) {
71
+ const tokens = { hitTokens: count(row.hit_tokens), missTokens: count(row.miss_tokens), outputTokens: count(row.output_tokens) };
72
+ const tokenCount = tokens.hitTokens + tokens.missTokens + tokens.outputTokens;
73
+ const requests = { searchRequests: count(row.search_requests), fetchRequests: count(row.fetch_requests) };
74
+ return {
75
+ costUsd: tokenCount > 0 ? estimateCost({ model: target, at, currency: 'USD', ...tokens }) : null,
76
+ costCny: tokenCount > 0 ? estimateCost({ model: target, at, currency: 'CNY', ...tokens }) : null,
77
+ toolsCostUsd: estimateSearchCost({ at, currency: 'USD', ...requests }) || 0,
78
+ toolsCostCny: estimateSearchCost({ at, currency: 'CNY', ...requests }) || 0,
79
+ };
80
+ }
81
+
82
+ const sumCost = (current, added) => (added === null ? money(current) : (Number(current) || 0) + added);
83
+
84
+ function foldTotals(db) {
85
+ const entries = foldableRows(db, DEEPSEEK_USAGE_TOTALS_TABLE);
86
+ const get = db.prepare(`SELECT * FROM ${DEEPSEEK_USAGE_TOTALS_TABLE} WHERE model = ?`);
87
+ const put = db.prepare(`
88
+ INSERT OR REPLACE INTO ${DEEPSEEK_USAGE_TOTALS_TABLE} (
89
+ model, hit_tokens, miss_tokens, output_tokens, search_requests, fetch_requests, turn_count,
90
+ cost_usd, cost_cny, tools_cost_usd, tools_cost_cny, first_seen_at, last_seen_at, updated_at
91
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
92
+ `);
93
+ const remove = db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_TOTALS_TABLE} WHERE model = ?`);
94
+ for (const { row, target } of entries) {
95
+ const into = get.get(target) || {};
96
+ const price = repriced(target, row, row.last_seen_at);
97
+ const earliest = [into.first_seen_at, row.first_seen_at].filter(Boolean).sort()[0];
98
+ const latest = [into.last_seen_at, row.last_seen_at].filter(Boolean).sort().at(-1);
99
+ put.run(
100
+ target,
101
+ count(into.hit_tokens) + count(row.hit_tokens),
102
+ count(into.miss_tokens) + count(row.miss_tokens),
103
+ count(into.output_tokens) + count(row.output_tokens),
104
+ count(into.search_requests) + count(row.search_requests),
105
+ count(into.fetch_requests) + count(row.fetch_requests),
106
+ count(into.turn_count) + count(row.turn_count),
107
+ sumCost(into.cost_usd ?? null, price.costUsd),
108
+ sumCost(into.cost_cny ?? null, price.costCny),
109
+ (Number(into.tools_cost_usd) || 0) + price.toolsCostUsd,
110
+ (Number(into.tools_cost_cny) || 0) + price.toolsCostCny,
111
+ earliest,
112
+ latest,
113
+ [into.updated_at, row.updated_at].filter(Boolean).sort().at(-1),
114
+ );
115
+ remove.run(row.model);
116
+ }
117
+ return entries.length;
118
+ }
119
+
120
+ function foldDays(db) {
121
+ const entries = foldableRows(db, DEEPSEEK_USAGE_DAYS_TABLE);
122
+ const get = db.prepare(`SELECT * FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE day = ? AND model = ?`);
123
+ const put = db.prepare(`
124
+ INSERT OR REPLACE INTO ${DEEPSEEK_USAGE_DAYS_TABLE} (
125
+ day, model, hit_tokens, miss_tokens, output_tokens, search_requests, fetch_requests, turn_count,
126
+ cost_usd, cost_cny, tools_cost_usd, tools_cost_cny, updated_at
127
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
128
+ `);
129
+ const remove = db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE day = ? AND model = ?`);
130
+ for (const { row, target } of entries) {
131
+ const into = get.get(row.day, target) || {};
132
+ const price = repriced(target, row, row.updated_at);
133
+ put.run(
134
+ row.day,
135
+ target,
136
+ count(into.hit_tokens) + count(row.hit_tokens),
137
+ count(into.miss_tokens) + count(row.miss_tokens),
138
+ count(into.output_tokens) + count(row.output_tokens),
139
+ count(into.search_requests) + count(row.search_requests),
140
+ count(into.fetch_requests) + count(row.fetch_requests),
141
+ count(into.turn_count) + count(row.turn_count),
142
+ sumCost(into.cost_usd ?? null, price.costUsd),
143
+ sumCost(into.cost_cny ?? null, price.costCny),
144
+ (Number(into.tools_cost_usd) || 0) + price.toolsCostUsd,
145
+ (Number(into.tools_cost_cny) || 0) + price.toolsCostCny,
146
+ [into.updated_at, row.updated_at].filter(Boolean).sort().at(-1),
147
+ );
148
+ remove.run(row.day, row.model);
149
+ }
150
+ return entries.length;
151
+ }
152
+
153
+ function foldCheckpoints(db) {
154
+ const entries = foldableRows(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE);
155
+ const get = db.prepare(`
156
+ SELECT * FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ? AND native_session_id = ? AND model = ?
157
+ `);
158
+ const put = db.prepare(`
159
+ INSERT OR REPLACE INTO ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} (
160
+ conversation_id, native_session_id, model, hit_tokens, miss_tokens, output_tokens, updated_at
161
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
162
+ `);
163
+ const remove = db.prepare(`
164
+ DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ? AND native_session_id = ? AND model = ?
165
+ `);
166
+ for (const { row, target } of entries) {
167
+ const into = get.get(row.conversation_id, row.native_session_id, target) || {};
168
+ put.run(
169
+ row.conversation_id,
170
+ row.native_session_id,
171
+ target,
172
+ count(into.hit_tokens) + count(row.hit_tokens),
173
+ count(into.miss_tokens) + count(row.miss_tokens),
174
+ count(into.output_tokens) + count(row.output_tokens),
175
+ [into.updated_at, row.updated_at].filter(Boolean).sort().at(-1),
176
+ );
177
+ remove.run(row.conversation_id, row.native_session_id, row.model);
178
+ }
179
+ return entries.length;
180
+ }
181
+
182
+ export function migrateDeepSeekUsageServedModels(db) {
183
+ // The rows live in 0024's and 0025's tables: a database without them gets
184
+ // them first (and then has nothing to fold).
185
+ migrateDeepSeekUsageRequestsDaily(db);
186
+ const result = db.transaction(() => ({
187
+ totalsFolded: foldTotals(db),
188
+ daysFolded: foldDays(db),
189
+ checkpointsFolded: foldCheckpoints(db),
190
+ }))();
191
+ return { applied: result.totalsFolded + result.daysFolded + result.checkpointsFolded > 0, ...result };
192
+ }
193
+
194
+ function defaultDbPath() {
195
+ const here = path.dirname(fileURLToPath(import.meta.url));
196
+ return path.resolve(here, '..', 'data', 'copilot.db');
197
+ }
198
+
199
+ export function migrate(dbPath, options = {}) {
200
+ const db = new Database(dbPath, options);
201
+ try {
202
+ return migrateDeepSeekUsageServedModels(db);
203
+ } finally {
204
+ db.close();
205
+ }
206
+ }
207
+
208
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
209
+ if (isMain) {
210
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
211
+ try {
212
+ const result = migrate(dbPath);
213
+ console.log(`Migration 0026 on ${dbPath}`);
214
+ console.log(result.applied
215
+ ? ` deepseek usage served models: totals folded ${result.totalsFolded}, days folded ${result.daysFolded}, checkpoints folded ${result.checkpointsFolded}`
216
+ : ' skipped: nothing to change');
217
+ process.exit(0);
218
+ } catch (err) {
219
+ console.error(`Migration 0026 failed: ${err?.message || err}`);
220
+ process.exit(1);
221
+ }
222
+ }
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the DeepSeek usage checkpoint learns which worker process its
5
+ * totals came from, and keeps the process's web search and fetch counts
6
+ * (services/plan-usage-service.mjs, the DeepSeek card in Check Usage).
7
+ * Builds on 0024.
8
+ *
9
+ * `deepseek_usage_checkpoints` gains three columns:
10
+ * - `run_id`: the id the worker gives each Claude Code process it spawns
11
+ * (`runId` of a `POST /api/deepseek-plan-usage` body); '' for a report
12
+ * from an older worker. Running totals only grow within one process, so
13
+ * a report below the checkpoint under the SAME run id is a stale one (a
14
+ * retried or reordered post) and books nothing, while one under a NEW run
15
+ * id is a process that started counting over, booked in full. Before, any
16
+ * drop was taken as a fresh run and booked in full: a reordered pair of
17
+ * reports booked the turns before them twice.
18
+ * - `search_requests`, `fetch_requests`: the process's running counts of
19
+ * web searches and fetches, which a worker with a run id reports in place
20
+ * of the turn's counts, so a repeated post adds nothing.
21
+ *
22
+ * Additive and idempotent. Safe to run on every boot.
23
+ *
24
+ * Usage: node server/migrations/0027-deepseek-usage-checkpoint-runs.mjs [path/to/copilot.db]
25
+ */
26
+
27
+ import { fileURLToPath } from 'url';
28
+ import path from 'path';
29
+ import process from 'process';
30
+ import Database from 'better-sqlite3';
31
+
32
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE, migrateDeepSeekUsageTotals } from './0024-deepseek-usage-totals.mjs';
33
+
34
+ // Added to deepseek_usage_checkpoints, with their DDL.
35
+ export const DEEPSEEK_USAGE_CHECKPOINT_RUN_COLUMNS = Object.freeze([
36
+ ['run_id', "TEXT NOT NULL DEFAULT ''"],
37
+ ['search_requests', 'INTEGER NOT NULL DEFAULT 0'],
38
+ ['fetch_requests', 'INTEGER NOT NULL DEFAULT 0'],
39
+ ]);
40
+
41
+ function tableExists(db, name) {
42
+ return !!db
43
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
44
+ .get(name);
45
+ }
46
+
47
+ function columnNames(db, table) {
48
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
49
+ }
50
+
51
+ /** What keeps the service from using the columns: '' when nothing does. */
52
+ export function deepseekUsageCheckpointRunsProblem(db) {
53
+ if (!tableExists(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) return `no ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} table`;
54
+ const present = columnNames(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE);
55
+ const missing = DEEPSEEK_USAGE_CHECKPOINT_RUN_COLUMNS.map(([name]) => name).filter((name) => !present.has(name));
56
+ if (missing.length) return `${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} lacks ${missing.join(', ')}`;
57
+ return '';
58
+ }
59
+
60
+ export function migrateDeepSeekUsageCheckpointRuns(db) {
61
+ // The columns go on 0024's table: a database that skipped 0024 gets it first.
62
+ migrateDeepSeekUsageTotals(db);
63
+ let columnsAdded = 0;
64
+ const present = columnNames(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE);
65
+ for (const [name, ddl] of DEEPSEEK_USAGE_CHECKPOINT_RUN_COLUMNS) {
66
+ if (present.has(name)) continue;
67
+ db.exec(`ALTER TABLE ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} ADD COLUMN ${name} ${ddl}`);
68
+ columnsAdded += 1;
69
+ }
70
+ return { applied: columnsAdded > 0, columnsAdded };
71
+ }
72
+
73
+ function defaultDbPath() {
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ return path.resolve(here, '..', 'data', 'copilot.db');
76
+ }
77
+
78
+ export function migrate(dbPath, options = {}) {
79
+ const db = new Database(dbPath, options);
80
+ try {
81
+ return migrateDeepSeekUsageCheckpointRuns(db);
82
+ } finally {
83
+ db.close();
84
+ }
85
+ }
86
+
87
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
88
+ if (isMain) {
89
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
90
+ try {
91
+ const result = migrate(dbPath);
92
+ console.log(`Migration 0027 on ${dbPath}`);
93
+ console.log(result.applied
94
+ ? ` deepseek usage checkpoint runs: columns added ${result.columnsAdded}`
95
+ : ' skipped: nothing to change');
96
+ process.exit(0);
97
+ } catch (err) {
98
+ console.error(`Migration 0027 failed: ${err?.message || err}`);
99
+ process.exit(1);
100
+ }
101
+ }
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the DeepSeek usage checkpoint keeps the run id of its web
5
+ * search and fetch counts apart from the run id of its token totals
6
+ * (services/plan-usage-service.mjs, the DeepSeek card in Check Usage).
7
+ * Builds on 0027.
8
+ *
9
+ * `deepseek_usage_checkpoints` gains one column:
10
+ * - `request_run_id`: the worker's id of the Claude Code process the
11
+ * `search_requests` and `fetch_requests` counts came from; '' before any
12
+ * report with a run id carried counts for the model. With 0027 one
13
+ * `run_id` served both the totals and the counts, so a report that
14
+ * carried only counts for a model (a sub-agent's model that searched
15
+ * without a token entry of its own) moved the run id of that model's token
16
+ * totals too: a later token report of the earlier process was then read
17
+ * as a new process and booked in full instead of as a stale one. Now a
18
+ * token entry moves `run_id` and a request entry moves `request_run_id`,
19
+ * and neither disturbs the other.
20
+ *
21
+ * Additive and idempotent. Safe to run on every boot.
22
+ *
23
+ * Usage: node server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs [path/to/copilot.db]
24
+ */
25
+
26
+ import { fileURLToPath } from 'url';
27
+ import path from 'path';
28
+ import process from 'process';
29
+ import Database from 'better-sqlite3';
30
+
31
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from './0024-deepseek-usage-totals.mjs';
32
+ import { migrateDeepSeekUsageCheckpointRuns } from './0027-deepseek-usage-checkpoint-runs.mjs';
33
+
34
+ // Added to deepseek_usage_checkpoints, with their DDL.
35
+ export const DEEPSEEK_USAGE_CHECKPOINT_REQUEST_RUN_COLUMNS = Object.freeze([
36
+ ['request_run_id', "TEXT NOT NULL DEFAULT ''"],
37
+ ]);
38
+
39
+ function tableExists(db, name) {
40
+ return !!db
41
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
42
+ .get(name);
43
+ }
44
+
45
+ function columnNames(db, table) {
46
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
47
+ }
48
+
49
+ /** What keeps the service from using the column: '' when nothing does. */
50
+ export function deepseekUsageCheckpointRequestRunsProblem(db) {
51
+ if (!tableExists(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) return `no ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} table`;
52
+ const present = columnNames(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE);
53
+ const missing = DEEPSEEK_USAGE_CHECKPOINT_REQUEST_RUN_COLUMNS.map(([name]) => name).filter((name) => !present.has(name));
54
+ if (missing.length) return `${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} lacks ${missing.join(', ')}`;
55
+ return '';
56
+ }
57
+
58
+ export function migrateDeepSeekUsageCheckpointRequestRuns(db) {
59
+ // The column goes next to 0027's: a database that skipped 0027 gets them first.
60
+ migrateDeepSeekUsageCheckpointRuns(db);
61
+ let columnsAdded = 0;
62
+ const present = columnNames(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE);
63
+ for (const [name, ddl] of DEEPSEEK_USAGE_CHECKPOINT_REQUEST_RUN_COLUMNS) {
64
+ if (present.has(name)) continue;
65
+ db.exec(`ALTER TABLE ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} ADD COLUMN ${name} ${ddl}`);
66
+ columnsAdded += 1;
67
+ }
68
+ return { applied: columnsAdded > 0, columnsAdded };
69
+ }
70
+
71
+ function defaultDbPath() {
72
+ const here = path.dirname(fileURLToPath(import.meta.url));
73
+ return path.resolve(here, '..', 'data', 'copilot.db');
74
+ }
75
+
76
+ export function migrate(dbPath, options = {}) {
77
+ const db = new Database(dbPath, options);
78
+ try {
79
+ return migrateDeepSeekUsageCheckpointRequestRuns(db);
80
+ } finally {
81
+ db.close();
82
+ }
83
+ }
84
+
85
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
86
+ if (isMain) {
87
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
88
+ try {
89
+ const result = migrate(dbPath);
90
+ console.log(`Migration 0028 on ${dbPath}`);
91
+ console.log(result.applied
92
+ ? ` deepseek usage checkpoint request runs: columns added ${result.columnsAdded}`
93
+ : ' skipped: nothing to change');
94
+ process.exit(0);
95
+ } catch (err) {
96
+ console.error(`Migration 0028 failed: ${err?.message || err}`);
97
+ process.exit(1);
98
+ }
99
+ }
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the DeepSeek balance reference notes when the balance last rose
5
+ * (services/plan-usage-service.mjs, the DeepSeek card's balance bar). Builds
6
+ * on 0025.
7
+ *
8
+ * `deepseek_balance_reference` gains these columns:
9
+ * - `rose_at`: when a read last found the balance above the read before it
10
+ * (a top-up or a grant); NULL while no rise was seen. `reference_at` cannot
11
+ * tell: it is also set by the first read of a currency. A starting balance
12
+ * set on the DeepSeek tab counts until the balance rises after it was set
13
+ * (plan-usage-deepseek.mjs).
14
+ * - `last_read_at`: when `last_total` was read; NULL for a row from before.
15
+ * - `rose_from_at`: when the balance before that rise was read; NULL when
16
+ * unknown. A rise the relay first sees after the starting balance was set
17
+ * may be a top-up from before it, unless the balance before the rise was
18
+ * read after it too.
19
+ *
20
+ * Additive and idempotent. Safe to run on every boot.
21
+ *
22
+ * Usage: node server/migrations/0029-deepseek-balance-rises.mjs [path/to/copilot.db]
23
+ */
24
+
25
+ import { fileURLToPath } from 'url';
26
+ import path from 'path';
27
+ import process from 'process';
28
+ import Database from 'better-sqlite3';
29
+
30
+ import { DEEPSEEK_BALANCE_REFERENCE_TABLE, migrateDeepSeekUsageRequestsDaily } from './0025-deepseek-usage-requests-daily.mjs';
31
+
32
+ // Added to deepseek_balance_reference, with their DDL.
33
+ export const DEEPSEEK_BALANCE_RISE_COLUMNS = Object.freeze([
34
+ ['rose_at', 'TEXT'],
35
+ ['last_read_at', 'TEXT'],
36
+ ['rose_from_at', 'TEXT'],
37
+ ]);
38
+
39
+ function tableExists(db, name) {
40
+ return !!db
41
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
42
+ .get(name);
43
+ }
44
+
45
+ function columnNames(db, table) {
46
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
47
+ }
48
+
49
+ /** What keeps the service from using the column: '' when nothing does. */
50
+ export function deepseekBalanceRisesProblem(db) {
51
+ if (!tableExists(db, DEEPSEEK_BALANCE_REFERENCE_TABLE)) return `no ${DEEPSEEK_BALANCE_REFERENCE_TABLE} table`;
52
+ const present = columnNames(db, DEEPSEEK_BALANCE_REFERENCE_TABLE);
53
+ const missing = DEEPSEEK_BALANCE_RISE_COLUMNS.map(([name]) => name).filter((name) => !present.has(name));
54
+ if (missing.length) return `${DEEPSEEK_BALANCE_REFERENCE_TABLE} lacks ${missing.join(', ')}`;
55
+ return '';
56
+ }
57
+
58
+ export function migrateDeepSeekBalanceRises(db) {
59
+ // The column goes on 0025's table: a database that skipped 0025 gets it first.
60
+ migrateDeepSeekUsageRequestsDaily(db);
61
+ let columnsAdded = 0;
62
+ const present = columnNames(db, DEEPSEEK_BALANCE_REFERENCE_TABLE);
63
+ for (const [name, ddl] of DEEPSEEK_BALANCE_RISE_COLUMNS) {
64
+ if (present.has(name)) continue;
65
+ db.exec(`ALTER TABLE ${DEEPSEEK_BALANCE_REFERENCE_TABLE} ADD COLUMN ${name} ${ddl}`);
66
+ columnsAdded += 1;
67
+ }
68
+ return { applied: columnsAdded > 0, columnsAdded };
69
+ }
70
+
71
+ function defaultDbPath() {
72
+ const here = path.dirname(fileURLToPath(import.meta.url));
73
+ return path.resolve(here, '..', 'data', 'copilot.db');
74
+ }
75
+
76
+ export function migrate(dbPath, options = {}) {
77
+ const db = new Database(dbPath, options);
78
+ try {
79
+ return migrateDeepSeekBalanceRises(db);
80
+ } finally {
81
+ db.close();
82
+ }
83
+ }
84
+
85
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
86
+ if (isMain) {
87
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
88
+ try {
89
+ const result = migrate(dbPath);
90
+ console.log(`Migration 0029 on ${dbPath}`);
91
+ console.log(result.applied
92
+ ? ` deepseek balance rises: columns added ${result.columnsAdded}`
93
+ : ' skipped: nothing to change');
94
+ process.exit(0);
95
+ } catch (err) {
96
+ console.error(`Migration 0029 failed: ${err?.message || err}`);
97
+ process.exit(1);
98
+ }
99
+ }
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the figures of DeepSeek's own usage export, imported from the
5
+ * platform's two CSV files (services/deepseek-usage-import.mjs). The usage
6
+ * card in Check Usage shows them instead of the relay's estimate for the
7
+ * days and models they cover.
8
+ *
9
+ * Adds one table of its own, `deepseek_usage_actuals`, one row per export
10
+ * day, model and API key (the key is the day, so a re-export taken in
11
+ * another time zone replaces the day instead of adding a second row):
12
+ * - `day`: the local date of `period_start`, in the export's time zone;
13
+ * - `period_start`, `period_end`: the first start and last end of the day's
14
+ * periods as the export gives them (ISO with the export's offset);
15
+ * - `model`, `key_fp` (the key masked as `sk-XXXXX…YYYY`), `key_name`;
16
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`, `request_count`;
17
+ * - `cost`, `currency`: the period's cost, amount × price summed;
18
+ * - `imported_at`.
19
+ *
20
+ * The relay's own usage tables are not touched. Additive and idempotent.
21
+ * Safe to run on every boot.
22
+ *
23
+ * Usage: node server/migrations/0030-deepseek-usage-actuals.mjs [path/to/copilot.db]
24
+ */
25
+
26
+ import { fileURLToPath } from 'url';
27
+ import path from 'path';
28
+ import process from 'process';
29
+ import Database from 'better-sqlite3';
30
+
31
+ export const DEEPSEEK_USAGE_ACTUALS_TABLE = 'deepseek_usage_actuals';
32
+
33
+ export const DEEPSEEK_USAGE_ACTUALS_COLUMNS = Object.freeze([
34
+ 'period_start',
35
+ 'period_end',
36
+ 'day',
37
+ 'model',
38
+ 'key_fp',
39
+ 'key_name',
40
+ 'hit_tokens',
41
+ 'miss_tokens',
42
+ 'output_tokens',
43
+ 'request_count',
44
+ 'cost',
45
+ 'currency',
46
+ 'imported_at',
47
+ ]);
48
+
49
+ const CREATE_SQL = `
50
+ CREATE TABLE IF NOT EXISTS ${DEEPSEEK_USAGE_ACTUALS_TABLE} (
51
+ period_start TEXT NOT NULL,
52
+ period_end TEXT NOT NULL,
53
+ day TEXT NOT NULL,
54
+ model TEXT NOT NULL,
55
+ key_fp TEXT NOT NULL,
56
+ key_name TEXT NOT NULL DEFAULT '',
57
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
58
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
59
+ output_tokens INTEGER NOT NULL DEFAULT 0,
60
+ request_count INTEGER NOT NULL DEFAULT 0,
61
+ cost REAL NOT NULL DEFAULT 0,
62
+ currency TEXT NOT NULL DEFAULT '',
63
+ imported_at TEXT NOT NULL,
64
+ PRIMARY KEY (day, model, key_fp)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_${DEEPSEEK_USAGE_ACTUALS_TABLE}_key_day
67
+ ON ${DEEPSEEK_USAGE_ACTUALS_TABLE} (key_fp, day);
68
+ `;
69
+
70
+ function tableExists(db, name) {
71
+ return !!db
72
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
73
+ .get(name);
74
+ }
75
+
76
+ function columnNames(db, table) {
77
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
78
+ }
79
+
80
+ /** What keeps the import from using the table: '' when nothing does. */
81
+ export function deepseekUsageActualsProblem(db) {
82
+ if (!tableExists(db, DEEPSEEK_USAGE_ACTUALS_TABLE)) return `no ${DEEPSEEK_USAGE_ACTUALS_TABLE} table`;
83
+ const present = columnNames(db, DEEPSEEK_USAGE_ACTUALS_TABLE);
84
+ const missing = DEEPSEEK_USAGE_ACTUALS_COLUMNS.filter((name) => !present.has(name));
85
+ if (missing.length) return `${DEEPSEEK_USAGE_ACTUALS_TABLE} lacks ${missing.join(', ')}`;
86
+ return '';
87
+ }
88
+
89
+ export function migrateDeepSeekUsageActuals(db) {
90
+ const existed = tableExists(db, DEEPSEEK_USAGE_ACTUALS_TABLE);
91
+ db.exec(CREATE_SQL);
92
+ return { applied: !existed, tableCreated: !existed };
93
+ }
94
+
95
+ function defaultDbPath() {
96
+ const here = path.dirname(fileURLToPath(import.meta.url));
97
+ return path.resolve(here, '..', 'data', 'copilot.db');
98
+ }
99
+
100
+ export function migrate(dbPath, options = {}) {
101
+ const db = new Database(dbPath, options);
102
+ try {
103
+ return migrateDeepSeekUsageActuals(db);
104
+ } finally {
105
+ db.close();
106
+ }
107
+ }
108
+
109
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
110
+ if (isMain) {
111
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
112
+ try {
113
+ const result = migrate(dbPath);
114
+ console.log(`Migration 0030 on ${dbPath}`);
115
+ console.log(result.applied
116
+ ? ' deepseek usage actuals: table created'
117
+ : ' skipped: nothing to change');
118
+ process.exit(0);
119
+ } catch (err) {
120
+ console.error(`Migration 0030 failed: ${err?.message || err}`);
121
+ process.exit(1);
122
+ }
123
+ }