@oar-sh/oar 0.9.16 → 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 (75) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +220 -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-sdk-adapter.mjs +167 -9
  7. package/server/claude-worker/claude-session-process.mjs +127 -29
  8. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  9. package/server/claude-worker/claude-session-worker.mjs +2 -253
  10. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  12. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  14. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
  16. package/server/db-schema.mjs +63 -0
  17. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  18. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  19. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  20. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  21. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  22. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  23. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  24. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  25. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  26. package/server/public/app/api-client.js +53 -0
  27. package/server/public/app/bootstrap.js +101 -13
  28. package/server/public/app/context-tier-options.mjs +4 -0
  29. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  30. package/server/public/app/conversation-view.js +2 -0
  31. package/server/public/app/deepseek-settings-ui.js +342 -0
  32. package/server/public/app/inbox-ui.js +14 -3
  33. package/server/public/app/journal-view.js +31 -3
  34. package/server/public/app/model-selector-options.mjs +11 -0
  35. package/server/public/app/model-vision-support.mjs +2 -0
  36. package/server/public/app/no-conversation-state.mjs +1 -1
  37. package/server/public/app/plan-usage-view.mjs +165 -13
  38. package/server/public/app/provider-readiness.mjs +4 -1
  39. package/server/public/app/relay-error-ctas.mjs +8 -0
  40. package/server/public/app/settings-modal.js +11 -0
  41. package/server/public/app/settings-tabs.js +1 -1
  42. package/server/public/app/socket-handlers.js +10 -0
  43. package/server/public/index.html +408 -33
  44. package/server/repositories/conversation-group-repository.mjs +13 -6
  45. package/server/routes/conversation-group-routes.mjs +2 -1
  46. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  47. package/server/routes/messages-routes.mjs +192 -15
  48. package/server/routes/sessions-routes.mjs +364 -55
  49. package/server/server-runtime.mjs +123 -10
  50. package/server/services/about-service.mjs +1 -0
  51. package/server/services/claude-session-leftovers.mjs +226 -0
  52. package/server/services/deepseek-balance-service.mjs +102 -0
  53. package/server/services/deepseek-settings-service.mjs +380 -0
  54. package/server/services/deepseek-usage-import.mjs +471 -0
  55. package/server/services/model-variant-catalog-service.mjs +1 -0
  56. package/server/services/plan-usage-contract.mjs +100 -5
  57. package/server/services/plan-usage-deepseek.mjs +931 -0
  58. package/server/services/plan-usage-service.mjs +431 -0
  59. package/server/services/provider-model-selection.mjs +1 -0
  60. package/server/services/provider-reasoning-effort.mjs +15 -2
  61. package/server/services/remote-relay-dispatcher.mjs +2 -1
  62. package/server/services/session-worker-launch-service.mjs +233 -82
  63. package/server/services/worker-secret-redaction.mjs +318 -0
  64. package/shared/context-window-fallbacks.mjs +3 -0
  65. package/shared/deepseek-api.mjs +137 -0
  66. package/shared/deepseek-models.mjs +114 -0
  67. package/shared/deepseek-pricing.mjs +146 -0
  68. package/shared/host-claude-session-env.mjs +101 -0
  69. package/shared/model-id.mjs +7 -0
  70. package/shared/preview-tool-core.mjs +1 -1
  71. package/shared/provider-routing.mjs +2 -2
  72. package/shared/remote-relay-contract.mjs +1 -1
  73. package/shared/steer-settle-failure.mjs +1 -0
  74. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  75. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // DeepSeek session worker: the Claude session worker with its CLI pointed at
3
+ // DeepSeek's Anthropic-compatible endpoint (deepseek-claude-backend.mjs). The
4
+ // relay's launch puts the key in DEEPSEEK_API_KEY (through the owner-only
5
+ // secret file under tmux) and names the worker's own Claude Code state
6
+ // directory in DEEPSEEK_CLAUDE_CONFIG_DIR.
7
+ import fs from 'node:fs';
8
+
9
+ import { installWorkerLogFile, installWorkerLogRedaction } from '../../shared/worker-runtime/worker-log-file.mjs';
10
+ import { runClaudeSessionWorker } from '../claude-worker/claude-session-worker-main.mjs';
11
+ import { createDeepSeekClaudeBackend } from './deepseek-claude-backend.mjs';
12
+
13
+ installWorkerLogFile();
14
+ // The key never shows in this worker's console or log, whatever the CLI or
15
+ // the agent prints (installed after the log file, so its copy is redacted too).
16
+ installWorkerLogRedaction({ secrets: [process.env.DEEPSEEK_API_KEY] });
17
+
18
+ const backend = createDeepSeekClaudeBackend({ env: process.env });
19
+
20
+ // The worker reads the CLI's state too (the transcript a resume needs, the
21
+ // workflow files): it has to look where its CLI writes, never in the host's
22
+ // ~/.claude. The directory holds sessions, so only this user may open it.
23
+ if (backend.configDir) {
24
+ fs.mkdirSync(backend.configDir, { recursive: true, mode: 0o700 });
25
+ try { fs.chmodSync(backend.configDir, 0o700); } catch {}
26
+ process.env.CLAUDE_CONFIG_DIR = backend.configDir;
27
+ }
28
+
29
+ runClaudeSessionWorker({
30
+ logTag: 'deepseek-worker',
31
+ workerName: 'deepseek-session-worker',
32
+ defaultModel: String(process.env.DEEPSEEK_RELAY_MODEL || '').trim() || backend.defaultModel,
33
+ backend,
34
+ }).catch((error) => {
35
+ console.error('deepseek-session-worker fatal:', error?.stack || error?.message || error);
36
+ process.exit(1);
37
+ });
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek usage totals (services/plan-usage-service.mjs, the
5
+ * DeepSeek card in Check Usage).
6
+ *
7
+ * `deepseek_usage_totals`: one row per DeepSeek model this relay's DeepSeek
8
+ * turns used, summed over every turn the workers reported
9
+ * (POST /api/deepseek-plan-usage). A row only ever grows.
10
+ *
11
+ * - `model`: the model's id as the CLI reported it, without Claude Code's
12
+ * `[1m]` suffix, lower case.
13
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: input DeepSeek served from
14
+ * its cache, input it did not, and output.
15
+ * - `turn_count`: the reports that named the model.
16
+ * - `cost_usd`, `cost_cny`: the estimate from shared/deepseek-pricing.mjs at
17
+ * the price of each turn's own time (peak or off-peak); NULL while no turn
18
+ * of the model could be priced (a model the price table does not know).
19
+ * - `first_seen_at`, `last_seen_at`: the earliest and latest turn time.
20
+ *
21
+ * `deepseek_usage_checkpoints`: what a worker last reported per
22
+ * (conversation, Claude Code session, model). A worker reports the CLI's
23
+ * running totals, and a CLI resumed on a session starts from that session's
24
+ * earlier totals, so the relay books only what a report adds to the totals
25
+ * last reported for the session: a restarted worker or a resumed CLI books
26
+ * nothing twice, and a new session starts from zero. A total below the
27
+ * checkpoint is a CLI counting from zero again: that report is booked in
28
+ * full and the checkpoint starts over from it.
29
+ *
30
+ * - `conversation_id`, `native_session_id` (the CLI's session id), `model`.
31
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: the totals last reported.
32
+ *
33
+ * Additive and idempotent. Safe to run on every boot.
34
+ *
35
+ * Usage: node server/migrations/0024-deepseek-usage-totals.mjs [path/to/copilot.db]
36
+ */
37
+
38
+ import { fileURLToPath } from 'url';
39
+ import path from 'path';
40
+ import process from 'process';
41
+ import Database from 'better-sqlite3';
42
+
43
+ export const DEEPSEEK_USAGE_TOTALS_TABLE = 'deepseek_usage_totals';
44
+ export const DEEPSEEK_USAGE_CHECKPOINTS_TABLE = 'deepseek_usage_checkpoints';
45
+
46
+ export const DEEPSEEK_USAGE_TOTALS_COLUMNS = Object.freeze([
47
+ 'model',
48
+ 'hit_tokens',
49
+ 'miss_tokens',
50
+ 'output_tokens',
51
+ 'turn_count',
52
+ 'cost_usd',
53
+ 'cost_cny',
54
+ 'first_seen_at',
55
+ 'last_seen_at',
56
+ 'updated_at',
57
+ ]);
58
+
59
+ export const DEEPSEEK_USAGE_CHECKPOINT_COLUMNS = Object.freeze([
60
+ 'conversation_id',
61
+ 'native_session_id',
62
+ 'model',
63
+ 'hit_tokens',
64
+ 'miss_tokens',
65
+ 'output_tokens',
66
+ 'updated_at',
67
+ ]);
68
+
69
+ function tableExists(db, name) {
70
+ return !!db
71
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
72
+ .get(name);
73
+ }
74
+
75
+ /** What keeps the service from using the table: '' when nothing does. */
76
+ export function deepseekUsageTotalsTableProblem(db) {
77
+ for (const [table, columns] of [
78
+ [DEEPSEEK_USAGE_TOTALS_TABLE, DEEPSEEK_USAGE_TOTALS_COLUMNS],
79
+ [DEEPSEEK_USAGE_CHECKPOINTS_TABLE, DEEPSEEK_USAGE_CHECKPOINT_COLUMNS],
80
+ ]) {
81
+ if (!tableExists(db, table)) return `no ${table} table`;
82
+ const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
83
+ const missing = columns.filter((name) => !present.has(name));
84
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
85
+ }
86
+ return '';
87
+ }
88
+
89
+ export function migrateDeepSeekUsageTotals(db) {
90
+ let tablesAdded = 0;
91
+ if (!tableExists(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
92
+ db.exec(`
93
+ CREATE TABLE ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} (
94
+ conversation_id TEXT NOT NULL,
95
+ native_session_id TEXT NOT NULL,
96
+ model TEXT NOT NULL,
97
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
98
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
99
+ output_tokens INTEGER NOT NULL DEFAULT 0,
100
+ updated_at TEXT NOT NULL,
101
+ PRIMARY KEY (conversation_id, native_session_id, model)
102
+ )
103
+ `);
104
+ tablesAdded += 1;
105
+ }
106
+ if (tableExists(db, DEEPSEEK_USAGE_TOTALS_TABLE)) return { applied: tablesAdded > 0, tablesAdded };
107
+ db.exec(`
108
+ CREATE TABLE ${DEEPSEEK_USAGE_TOTALS_TABLE} (
109
+ model TEXT PRIMARY KEY,
110
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
111
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
112
+ output_tokens INTEGER NOT NULL DEFAULT 0,
113
+ turn_count INTEGER NOT NULL DEFAULT 0,
114
+ cost_usd REAL,
115
+ cost_cny REAL,
116
+ first_seen_at TEXT NOT NULL,
117
+ last_seen_at TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL
119
+ )
120
+ `);
121
+ tablesAdded += 1;
122
+ return { applied: true, tablesAdded };
123
+ }
124
+
125
+ function defaultDbPath() {
126
+ const here = path.dirname(fileURLToPath(import.meta.url));
127
+ return path.resolve(here, '..', 'data', 'copilot.db');
128
+ }
129
+
130
+ export function migrate(dbPath, options = {}) {
131
+ const db = new Database(dbPath, options);
132
+ try {
133
+ return migrateDeepSeekUsageTotals(db);
134
+ } finally {
135
+ db.close();
136
+ }
137
+ }
138
+
139
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
140
+ if (isMain) {
141
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
142
+ try {
143
+ const result = migrate(dbPath);
144
+ console.log(`Migration 0024 on ${dbPath}`);
145
+ console.log(result.applied ? ` deepseek usage totals: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
146
+ process.exit(0);
147
+ } catch (err) {
148
+ console.error(`Migration 0024 failed: ${err?.message || err}`);
149
+ process.exit(1);
150
+ }
151
+ }
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek web search and fetch counts, the daily spend, and the
5
+ * balance reference (services/plan-usage-service.mjs, the DeepSeek card in
6
+ * Check Usage). Builds on 0024.
7
+ *
8
+ * `deepseek_usage_totals` gains four columns:
9
+ * - `search_requests`, `fetch_requests`: Claude Code's web searches and
10
+ * fetches on DeepSeek's Search API, counted from the first turn.
11
+ * - `tools_cost_usd`, `tools_cost_cny`: what those cost, from
12
+ * shared/deepseek-pricing.mjs: 0 before DeepSeek started charging for
13
+ * them (2026-10-31 16:00 UTC), flat per request after. `cost_usd` and
14
+ * `cost_cny` stay the token estimate alone.
15
+ *
16
+ * `deepseek_usage_days`: the same sums per UTC day and model, booked in the
17
+ * same transaction as the totals, for the card's daily spend chart and this
18
+ * month's spend against the budget. History starts with this migration.
19
+ * - `day`: `YYYY-MM-DD` in UTC, from the turn's time.
20
+ * - the token, request, turn and cost columns as in the totals.
21
+ *
22
+ * `deepseek_balance_reference`: per currency, the balance after the latest
23
+ * top-up as the card's meter reads it (balance left of the last top-up).
24
+ * - `reference_total`: the balance after the latest rise; before the first
25
+ * rise is seen, the highest balance seen.
26
+ * - `last_total`: the balance last read, to see the next rise.
27
+ * - `reference_at`: when the reference was set.
28
+ *
29
+ * Additive and idempotent. Safe to run on every boot.
30
+ *
31
+ * Usage: node server/migrations/0025-deepseek-usage-requests-daily.mjs [path/to/copilot.db]
32
+ */
33
+
34
+ import { fileURLToPath } from 'url';
35
+ import path from 'path';
36
+ import process from 'process';
37
+ import Database from 'better-sqlite3';
38
+
39
+ import { DEEPSEEK_USAGE_TOTALS_TABLE, migrateDeepSeekUsageTotals } from './0024-deepseek-usage-totals.mjs';
40
+
41
+ export const DEEPSEEK_USAGE_DAYS_TABLE = 'deepseek_usage_days';
42
+ export const DEEPSEEK_BALANCE_REFERENCE_TABLE = 'deepseek_balance_reference';
43
+
44
+ // Added to deepseek_usage_totals, with their DDL.
45
+ export const DEEPSEEK_USAGE_REQUEST_COLUMNS = Object.freeze([
46
+ ['search_requests', 'INTEGER NOT NULL DEFAULT 0'],
47
+ ['fetch_requests', 'INTEGER NOT NULL DEFAULT 0'],
48
+ ['tools_cost_usd', 'REAL NOT NULL DEFAULT 0'],
49
+ ['tools_cost_cny', 'REAL NOT NULL DEFAULT 0'],
50
+ ]);
51
+
52
+ export const DEEPSEEK_USAGE_DAYS_COLUMNS = Object.freeze([
53
+ 'day',
54
+ 'model',
55
+ 'hit_tokens',
56
+ 'miss_tokens',
57
+ 'output_tokens',
58
+ 'search_requests',
59
+ 'fetch_requests',
60
+ 'turn_count',
61
+ 'cost_usd',
62
+ 'cost_cny',
63
+ 'tools_cost_usd',
64
+ 'tools_cost_cny',
65
+ 'updated_at',
66
+ ]);
67
+
68
+ export const DEEPSEEK_BALANCE_REFERENCE_COLUMNS = Object.freeze([
69
+ 'currency',
70
+ 'reference_total',
71
+ 'last_total',
72
+ 'reference_at',
73
+ 'updated_at',
74
+ ]);
75
+
76
+ function tableExists(db, name) {
77
+ return !!db
78
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
79
+ .get(name);
80
+ }
81
+
82
+ function columnNames(db, table) {
83
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
84
+ }
85
+
86
+ /** What keeps the service from using the columns and tables: '' when nothing does. */
87
+ export function deepseekUsageRequestsDailyProblem(db) {
88
+ if (!tableExists(db, DEEPSEEK_USAGE_TOTALS_TABLE)) return `no ${DEEPSEEK_USAGE_TOTALS_TABLE} table`;
89
+ const totals = columnNames(db, DEEPSEEK_USAGE_TOTALS_TABLE);
90
+ const missingColumns = DEEPSEEK_USAGE_REQUEST_COLUMNS.map(([name]) => name).filter((name) => !totals.has(name));
91
+ if (missingColumns.length) return `${DEEPSEEK_USAGE_TOTALS_TABLE} lacks ${missingColumns.join(', ')}`;
92
+ for (const [table, columns] of [
93
+ [DEEPSEEK_USAGE_DAYS_TABLE, DEEPSEEK_USAGE_DAYS_COLUMNS],
94
+ [DEEPSEEK_BALANCE_REFERENCE_TABLE, DEEPSEEK_BALANCE_REFERENCE_COLUMNS],
95
+ ]) {
96
+ if (!tableExists(db, table)) return `no ${table} table`;
97
+ const present = columnNames(db, table);
98
+ const missing = columns.filter((name) => !present.has(name));
99
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
100
+ }
101
+ return '';
102
+ }
103
+
104
+ export function migrateDeepSeekUsageRequestsDaily(db) {
105
+ // The columns go on 0024's table: a database that skipped 0024 gets it first.
106
+ migrateDeepSeekUsageTotals(db);
107
+ let columnsAdded = 0;
108
+ let tablesAdded = 0;
109
+ const present = columnNames(db, DEEPSEEK_USAGE_TOTALS_TABLE);
110
+ for (const [name, ddl] of DEEPSEEK_USAGE_REQUEST_COLUMNS) {
111
+ if (present.has(name)) continue;
112
+ db.exec(`ALTER TABLE ${DEEPSEEK_USAGE_TOTALS_TABLE} ADD COLUMN ${name} ${ddl}`);
113
+ columnsAdded += 1;
114
+ }
115
+ if (!tableExists(db, DEEPSEEK_USAGE_DAYS_TABLE)) {
116
+ db.exec(`
117
+ CREATE TABLE ${DEEPSEEK_USAGE_DAYS_TABLE} (
118
+ day TEXT NOT NULL,
119
+ model TEXT NOT NULL,
120
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
121
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
122
+ output_tokens INTEGER NOT NULL DEFAULT 0,
123
+ search_requests INTEGER NOT NULL DEFAULT 0,
124
+ fetch_requests INTEGER NOT NULL DEFAULT 0,
125
+ turn_count INTEGER NOT NULL DEFAULT 0,
126
+ cost_usd REAL,
127
+ cost_cny REAL,
128
+ tools_cost_usd REAL NOT NULL DEFAULT 0,
129
+ tools_cost_cny REAL NOT NULL DEFAULT 0,
130
+ updated_at TEXT NOT NULL,
131
+ PRIMARY KEY (day, model)
132
+ )
133
+ `);
134
+ tablesAdded += 1;
135
+ }
136
+ if (!tableExists(db, DEEPSEEK_BALANCE_REFERENCE_TABLE)) {
137
+ db.exec(`
138
+ CREATE TABLE ${DEEPSEEK_BALANCE_REFERENCE_TABLE} (
139
+ currency TEXT PRIMARY KEY,
140
+ reference_total REAL NOT NULL,
141
+ last_total REAL NOT NULL,
142
+ reference_at TEXT NOT NULL,
143
+ updated_at TEXT NOT NULL
144
+ )
145
+ `);
146
+ tablesAdded += 1;
147
+ }
148
+ return { applied: columnsAdded + tablesAdded > 0, columnsAdded, tablesAdded };
149
+ }
150
+
151
+ function defaultDbPath() {
152
+ const here = path.dirname(fileURLToPath(import.meta.url));
153
+ return path.resolve(here, '..', 'data', 'copilot.db');
154
+ }
155
+
156
+ export function migrate(dbPath, options = {}) {
157
+ const db = new Database(dbPath, options);
158
+ try {
159
+ return migrateDeepSeekUsageRequestsDaily(db);
160
+ } finally {
161
+ db.close();
162
+ }
163
+ }
164
+
165
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
166
+ if (isMain) {
167
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
168
+ try {
169
+ const result = migrate(dbPath);
170
+ console.log(`Migration 0025 on ${dbPath}`);
171
+ console.log(result.applied
172
+ ? ` deepseek usage requests and daily: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
173
+ : ' skipped: nothing to change');
174
+ process.exit(0);
175
+ } catch (err) {
176
+ console.error(`Migration 0025 failed: ${err?.message || err}`);
177
+ process.exit(1);
178
+ }
179
+ }
@@ -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
+ }