@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,471 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The DeepSeek platform's usage export, imported so the usage card in Check
5
+ * Usage shows what DeepSeek charged instead of the relay's estimate.
6
+ *
7
+ * The platform's usage page exports two CSV files for a date range:
8
+ * - the amounts: one row per period, model, API key and type
9
+ * (`input_cache_hit_tokens`, `input_cache_miss_tokens`, `output_tokens`,
10
+ * `request_count`) with the price per unit and the amount;
11
+ * - the costs: one row per period, model and wallet with the cost and its
12
+ * currency, for the whole account.
13
+ *
14
+ * The import keeps one row per export day, model and key in its own table
15
+ * (migrations/0030-deepseek-usage-actuals.mjs) with the key masked as
16
+ * `sk-XXXXX…YYYY`; the day is the date of the period's start in the export's
17
+ * time zone. Its cost is amount × price summed; before anything is written
18
+ * the sum over all keys of each period and model has to match the cost file,
19
+ * or the import is refused whole. A day with a period that has not ended yet
20
+ * (today's, in an export taken during the day) is left out: it would only be
21
+ * part of the day. Importing the same files again changes nothing; a later
22
+ * export replaces each key's rows of the days it covers, also when it was
23
+ * taken in another time zone.
24
+ *
25
+ * The card counts only the rows of the key saved in Settings → Providers →
26
+ * DeepSeek (`readActuals`), so the other keys of the account stay out of it.
27
+ * The relay's own usage tables are never touched.
28
+ *
29
+ * CLI: npm run deepseek:import -- amount.csv cost.csv [path/to/copilot.db]
30
+ */
31
+
32
+ import { fileURLToPath } from 'url';
33
+ import fs from 'fs';
34
+ import path from 'path';
35
+ import process from 'process';
36
+ import Database from 'better-sqlite3';
37
+
38
+ import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
39
+ import { DEEPSEEK_USAGE_ACTUALS_TABLE, migrateDeepSeekUsageActuals } from '../migrations/0030-deepseek-usage-actuals.mjs';
40
+
41
+ // Per period and model, the amounts' cost and the cost file may differ by
42
+ // this much (the prices have up to eight decimals).
43
+ export const DEEPSEEK_IMPORT_COST_TOLERANCE = 1e-6;
44
+
45
+ const AMOUNT_HEADERS = Object.freeze(['start_time_iso', 'end_time_iso', 'model', 'api_key_name', 'api_key', 'type', 'price', 'amount']);
46
+ const COST_HEADERS = Object.freeze(['start_time_iso', 'end_time_iso', 'model', 'cost', 'currency']);
47
+
48
+ // The amount types that are token or request counts, and their columns.
49
+ const TYPE_FIELDS = Object.freeze({
50
+ input_cache_hit_tokens: 'hitTokens',
51
+ input_cache_miss_tokens: 'missTokens',
52
+ output_tokens: 'outputTokens',
53
+ request_count: 'requestCount',
54
+ });
55
+
56
+ const MODEL_PATTERN = /^[a-z0-9][a-z0-9._:/-]{0,127}$/;
57
+ const ISO_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-](\d{2}):(\d{2}))$/;
58
+ // A plain decimal: no sign, exponent, hex or blanks.
59
+ const DECIMAL = /^\d+(?:\.\d+)?$/;
60
+
61
+ const tenDecimals = (value) => Math.round(Number(value) * 1e10) / 1e10;
62
+
63
+ /**
64
+ * A key as `sk-XXXXX…YYYY`: the five characters after `sk-` and the last
65
+ * four, the same for the export's masked key and the saved one. '' for
66
+ * anything that is not a DeepSeek key.
67
+ */
68
+ export function deepseekKeyFingerprint(value) {
69
+ const key = String(value ?? '').trim();
70
+ if (!/^sk-[A-Za-z0-9]{5}/.test(key) || key.length < 12) return '';
71
+ const tail = key.slice(-4);
72
+ if (!/^[A-Za-z0-9]{4}$/.test(tail)) return '';
73
+ return `${key.slice(0, 8)}…${tail}`;
74
+ }
75
+
76
+ /** The rows of a CSV text: a BOM and CRLF line ends are fine, quoted fields too. */
77
+ export function parseCsv(text) {
78
+ const source = String(text ?? '').replace(/^/, '');
79
+ const rows = [];
80
+ let row = [];
81
+ let field = '';
82
+ let quoted = false;
83
+ for (let index = 0; index < source.length; index += 1) {
84
+ const char = source[index];
85
+ if (quoted) {
86
+ if (char === '"') {
87
+ if (source[index + 1] === '"') {
88
+ field += '"';
89
+ index += 1;
90
+ } else {
91
+ quoted = false;
92
+ }
93
+ } else {
94
+ field += char;
95
+ }
96
+ } else if (char === '"' && field === '') {
97
+ quoted = true;
98
+ } else if (char === ',') {
99
+ row.push(field);
100
+ field = '';
101
+ } else if (char === '\n' || char === '\r') {
102
+ if (char === '\r' && source[index + 1] === '\n') index += 1;
103
+ row.push(field);
104
+ rows.push(row);
105
+ row = [];
106
+ field = '';
107
+ } else {
108
+ field += char;
109
+ }
110
+ }
111
+ if (field !== '' || row.length) {
112
+ row.push(field);
113
+ rows.push(row);
114
+ }
115
+ return rows.filter((entry) => entry.some((value) => value.trim() !== ''));
116
+ }
117
+
118
+ /** `[{ header: value }]` for a CSV with these headers, or `{ error }`. */
119
+ function readTable(text, required, label) {
120
+ const rows = parseCsv(text);
121
+ if (!rows.length) return { error: `The ${label} file is empty.` };
122
+ const headers = rows[0].map((value) => value.trim());
123
+ const missing = required.filter((name) => !headers.includes(name));
124
+ if (missing.length) {
125
+ return { error: `The ${label} file is not a DeepSeek usage export: it lacks the column${missing.length === 1 ? '' : 's'} ${missing.join(', ')}.` };
126
+ }
127
+ return {
128
+ rows: rows.slice(1).map((values, index) => {
129
+ const entry = { line: index + 2 };
130
+ headers.forEach((name, column) => { entry[name] = String(values[column] ?? '').trim(); });
131
+ return entry;
132
+ }),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * An ISO time with its offset whose fields are a real date and time
138
+ * (2026-02-30 or 25:00 is not): `{ date, ms }`, or null.
139
+ */
140
+ export function readIsoWithOffset(value) {
141
+ const match = ISO_WITH_OFFSET.exec(String(value ?? ''));
142
+ if (!match) return null;
143
+ const [, year, month, dayOfMonth, hours, minutes, seconds = '00', zone, zoneHours = '00', zoneMinutes = '00'] = match;
144
+ const fields = [year, month, dayOfMonth, hours, minutes, seconds].map(Number);
145
+ const local = new Date(Date.UTC(fields[0], fields[1] - 1, fields[2], fields[3], fields[4], fields[5]));
146
+ const sameFields = local.getUTCFullYear() === fields[0]
147
+ && local.getUTCMonth() === fields[1] - 1
148
+ && local.getUTCDate() === fields[2]
149
+ && local.getUTCHours() === fields[3]
150
+ && local.getUTCMinutes() === fields[4]
151
+ && local.getUTCSeconds() === fields[5];
152
+ if (!sameFields || Number(zoneHours) > 14 || Number(zoneMinutes) > 59) return null;
153
+ const ms = Date.parse(value);
154
+ if (!Number.isFinite(ms)) return null;
155
+ return { date: `${year}-${month}-${dayOfMonth}`, ms, zone };
156
+ }
157
+
158
+ function period(entry, label) {
159
+ const start = readIsoWithOffset(entry.start_time_iso);
160
+ const end = readIsoWithOffset(entry.end_time_iso);
161
+ if (!start || !end || end.ms <= start.ms) {
162
+ return { error: `Line ${entry.line} of the ${label} file has no readable period.` };
163
+ }
164
+ return { start: entry.start_time_iso, end: entry.end_time_iso, endMs: end.ms, day: start.date };
165
+ }
166
+
167
+ /** A plain decimal, null for an empty field, NaN for anything else. */
168
+ export function readDecimal(value) {
169
+ if (value === '') return null;
170
+ if (!DECIMAL.test(value)) return NaN;
171
+ const parsed = Number(value);
172
+ return Number.isFinite(parsed) ? parsed : NaN;
173
+ }
174
+
175
+ const number = readDecimal;
176
+
177
+ /**
178
+ * The two files, checked against each other: `{ ok: true, rows, skipped }`
179
+ * with one row per export day, model and key, or `{ ok: false, error }`.
180
+ * `rows` leaves out the days with a period that ends after `now`; `skipped`
181
+ * names those periods.
182
+ */
183
+ export function parseDeepSeekUsageExport({ amount, cost, now = new Date() } = {}) {
184
+ const amounts = readTable(amount, AMOUNT_HEADERS, 'amount');
185
+ if (amounts.error) return { ok: false, error: amounts.error };
186
+ const costs = readTable(cost, COST_HEADERS, 'cost');
187
+ if (costs.error) return { ok: false, error: costs.error };
188
+ const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
189
+
190
+ const rows = new Map();
191
+ const computed = new Map();
192
+ const skipped = new Set();
193
+ const unfinishedDays = new Set();
194
+ for (const entry of amounts.rows) {
195
+ const when = period(entry, 'amount');
196
+ if (when.error) return { ok: false, error: when.error };
197
+ const model = entry.model.toLowerCase();
198
+ if (!MODEL_PATTERN.test(model)) return { ok: false, error: `Line ${entry.line} of the amount file has no readable model.` };
199
+ const keyFp = deepseekKeyFingerprint(entry.api_key);
200
+ if (!keyFp) return { ok: false, error: `Line ${entry.line} of the amount file has no readable API key.` };
201
+ const price = number(entry.price);
202
+ const quantity = number(entry.amount);
203
+ if (Number.isNaN(price) || quantity === null || Number.isNaN(quantity)) {
204
+ return { ok: false, error: `Line ${entry.line} of the amount file has no readable price or amount.` };
205
+ }
206
+ if (when.endMs > nowMs) {
207
+ skipped.add(when.start);
208
+ unfinishedDays.add(when.day);
209
+ continue;
210
+ }
211
+ const periodKey = `${when.start}\u0000${model}`;
212
+ const rowKey = `${when.day}\u0000${model}\u0000${keyFp}`;
213
+ const row = rows.get(rowKey) || {
214
+ periodStart: when.start,
215
+ periodEnd: when.end,
216
+ day: when.day,
217
+ model,
218
+ keyFp,
219
+ keyName: entry.api_key_name,
220
+ hitTokens: 0,
221
+ missTokens: 0,
222
+ outputTokens: 0,
223
+ requestCount: 0,
224
+ cost: 0,
225
+ currency: '',
226
+ };
227
+ if (when.start < row.periodStart) row.periodStart = when.start;
228
+ if (when.end > row.periodEnd) row.periodEnd = when.end;
229
+ const field = TYPE_FIELDS[entry.type];
230
+ if (field) row[field] += Math.round(quantity);
231
+ const rowCost = price === null ? 0 : quantity * price;
232
+ row.cost += rowCost;
233
+ rows.set(rowKey, row);
234
+ computed.set(periodKey, (computed.get(periodKey) || 0) + rowCost);
235
+ }
236
+
237
+ const charged = new Map();
238
+ const currencies = new Map();
239
+ const allCurrencies = new Set();
240
+ for (const entry of costs.rows) {
241
+ const when = period(entry, 'cost');
242
+ if (when.error) return { ok: false, error: when.error };
243
+ if (when.endMs > nowMs) continue;
244
+ const model = entry.model.toLowerCase();
245
+ const value = number(entry.cost);
246
+ if (value === null || Number.isNaN(value)) return { ok: false, error: `Line ${entry.line} of the cost file has no readable cost.` };
247
+ const currency = entry.currency.toUpperCase();
248
+ if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: `Line ${entry.line} of the cost file has no readable currency.` };
249
+ const periodKey = `${when.start}\u0000${model}`;
250
+ charged.set(periodKey, (charged.get(periodKey) || 0) + value);
251
+ const seen = currencies.get(periodKey) || new Set();
252
+ seen.add(currency);
253
+ currencies.set(periodKey, seen);
254
+ allCurrencies.add(currency);
255
+ }
256
+
257
+ // Every period and model of either file: the amounts' cost has to be what
258
+ // DeepSeek charged.
259
+ for (const periodKey of new Set([...computed.keys(), ...charged.keys()])) {
260
+ const [start, model] = periodKey.split('\u0000');
261
+ const fromAmounts = computed.get(periodKey) || 0;
262
+ const fromCosts = charged.get(periodKey) || 0;
263
+ if (Math.abs(fromAmounts - fromCosts) > DEEPSEEK_IMPORT_COST_TOLERANCE) {
264
+ return {
265
+ ok: false,
266
+ error: `The files do not match for ${model} from ${start}: the amounts come to ${tenDecimals(fromAmounts)}, the cost file says ${tenDecimals(fromCosts)}. Export both files for the same range and import them together.`,
267
+ };
268
+ }
269
+ if ((currencies.get(periodKey)?.size || 0) > 1) {
270
+ return { ok: false, error: `The cost file has more than one currency for ${model} from ${start}.` };
271
+ }
272
+ }
273
+
274
+ const soleCurrency = allCurrencies.size === 1 ? [...allCurrencies][0] : '';
275
+ // A day's periods share their currency (a day is one period in the
276
+ // platform's export); one with two is refused.
277
+ const dayCurrencies = new Map();
278
+ for (const [periodKey, seen] of currencies) {
279
+ const [start, model] = periodKey.split('\u0000');
280
+ const when = readIsoWithOffset(start);
281
+ const dayKey = `${when.date}\u0000${model}`;
282
+ const all = dayCurrencies.get(dayKey) || new Set();
283
+ for (const value of seen) all.add(value);
284
+ if (all.size > 1) return { ok: false, error: `The cost file has more than one currency for ${model} on ${when.date}.` };
285
+ dayCurrencies.set(dayKey, all);
286
+ }
287
+ const list = [...rows.values()]
288
+ .filter((row) => !unfinishedDays.has(row.day))
289
+ .map((row) => {
290
+ const seen = dayCurrencies.get(`${row.day}\u0000${row.model}`);
291
+ const currency = seen ? [...seen][0] : soleCurrency;
292
+ return { ...row, cost: tenDecimals(row.cost), currency };
293
+ });
294
+ list.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : a.model < b.model ? -1 : a.model > b.model ? 1 : a.keyFp < b.keyFp ? -1 : a.keyFp > b.keyFp ? 1 : 0));
295
+ return { ok: true, rows: list, skipped: [...skipped].sort() };
296
+ }
297
+
298
+ /**
299
+ * @param {object} options
300
+ * @param {object} options.db better-sqlite3 database
301
+ * @param {Function} [options.getApiKey] the saved DeepSeek key, or ''
302
+ * @param {Function} [options.now]
303
+ */
304
+ export function createDeepSeekUsageImportService({ db, getApiKey = () => '', now = () => new Date() } = {}) {
305
+ if (!db || typeof db.prepare !== 'function') throw new Error('createDeepSeekUsageImportService requires a database');
306
+ migrateDeepSeekUsageActuals(db);
307
+ const clearKeyDay = db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE} WHERE key_fp = ? AND day = ?`);
308
+ const upsert = db.prepare(`
309
+ INSERT OR REPLACE INTO ${DEEPSEEK_USAGE_ACTUALS_TABLE}
310
+ (period_start, period_end, day, model, key_fp, key_name, hit_tokens, miss_tokens, output_tokens, request_count, cost, currency, imported_at)
311
+ VALUES (@periodStart, @periodEnd, @day, @model, @keyFp, @keyName, @hitTokens, @missTokens, @outputTokens, @requestCount, @cost, @currency, @importedAt)
312
+ `);
313
+ // Each key's days in the export replace what an earlier import had for
314
+ // them, whatever time zone that one was taken in.
315
+ const writeAll = db.transaction((rows, importedAt) => {
316
+ for (const keyDay of new Set(rows.map((row) => `${row.keyFp}\u0000${row.day}`))) {
317
+ clearKeyDay.run(...keyDay.split('\u0000'));
318
+ }
319
+ for (const row of rows) upsert.run({ ...row, importedAt });
320
+ });
321
+ const listForKey = db.prepare(`
322
+ SELECT day, model, currency,
323
+ SUM(hit_tokens) AS hit_tokens, SUM(miss_tokens) AS miss_tokens, SUM(output_tokens) AS output_tokens,
324
+ SUM(request_count) AS request_count, SUM(cost) AS cost, MAX(period_end) AS period_end
325
+ FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE}
326
+ WHERE key_fp = ?
327
+ GROUP BY day, model, currency
328
+ ORDER BY day, model
329
+ `);
330
+ // The days the imports cover, for every key: on them the export has what
331
+ // the account was charged, so the saved key's absence is a day without spend.
332
+ const listCoveredDays = db.prepare(`
333
+ SELECT DISTINCT day, currency FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE} ORDER BY day, currency
334
+ `);
335
+
336
+ function savedKeyFp() {
337
+ try {
338
+ return deepseekKeyFingerprint(getApiKey?.() || '');
339
+ } catch {
340
+ return '';
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Imports the two CSV texts: `{ ok: true, summary }` or `{ ok: false,
346
+ * error }`. The summary: `{ rows, days, models, keys: [{ keyFp, keyName,
347
+ * rows }], skippedPeriods, through, savedKeyFp, savedKeyMatched,
348
+ * currencies, cardCurrency, currencyMatched }`. `cardCurrency` is the
349
+ * usage card's currency when the caller knows it (null otherwise);
350
+ * `currencyMatched` says whether the card can use the saved key's rows.
351
+ */
352
+ function importExport({ amount, cost, cardCurrency = null } = {}) {
353
+ const clock = now();
354
+ const parsed = parseDeepSeekUsageExport({ amount, cost, now: clock });
355
+ if (!parsed.ok) return parsed;
356
+ writeAll(parsed.rows, (clock instanceof Date ? clock : new Date(clock)).toISOString());
357
+ const keys = new Map();
358
+ for (const row of parsed.rows) {
359
+ const entry = keys.get(row.keyFp) || { keyFp: row.keyFp, keyName: row.keyName, rows: 0 };
360
+ entry.rows += 1;
361
+ keys.set(row.keyFp, entry);
362
+ }
363
+ const saved = savedKeyFp();
364
+ const card = /^[A-Z]{3}$/.test(String(cardCurrency || '').toUpperCase()) ? String(cardCurrency).toUpperCase() : null;
365
+ const savedRows = parsed.rows.filter((row) => row.keyFp === saved);
366
+ const currencies = [...new Set(parsed.rows.map((row) => row.currency).filter(Boolean))].sort();
367
+ return {
368
+ ok: true,
369
+ summary: {
370
+ rows: parsed.rows.length,
371
+ days: [...new Set(parsed.rows.map((row) => row.day))],
372
+ models: [...new Set(parsed.rows.map((row) => row.model))].sort(),
373
+ keys: [...keys.values()],
374
+ skippedPeriods: parsed.skipped,
375
+ through: parsed.rows.reduce((latest, row) => (row.periodEnd > latest ? row.periodEnd : latest), '') || null,
376
+ savedKeyFp: saved || null,
377
+ savedKeyMatched: !!saved && keys.has(saved),
378
+ currencies,
379
+ cardCurrency: card,
380
+ currencyMatched: card ? savedRows.some((row) => row.currency === card) : null,
381
+ },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * The saved key's imported figures per export day and model, for the
387
+ * usage card: `{ keyFp, through, rows: [{ day, model, hitTokens,
388
+ * missTokens, outputTokens, requestCount, cost, currency }], coveredDays:
389
+ * [{ day, currency }] }`, or null without a saved key or rows for it.
390
+ * `coveredDays` are the days any import covers, for any key: the card
391
+ * replaces the estimate on those days only.
392
+ */
393
+ function readActuals() {
394
+ const keyFp = savedKeyFp();
395
+ if (!keyFp) return null;
396
+ const rows = listForKey.all(keyFp);
397
+ if (!rows.length) return null;
398
+ return {
399
+ keyFp,
400
+ through: rows.reduce((latest, row) => (row.period_end > latest ? row.period_end : latest), ''),
401
+ rows: rows.map((row) => ({
402
+ day: row.day,
403
+ model: row.model,
404
+ hitTokens: Number(row.hit_tokens) || 0,
405
+ missTokens: Number(row.miss_tokens) || 0,
406
+ outputTokens: Number(row.output_tokens) || 0,
407
+ requestCount: Number(row.request_count) || 0,
408
+ cost: tenDecimals(row.cost || 0),
409
+ currency: row.currency,
410
+ })),
411
+ coveredDays: listCoveredDays.all().map((row) => ({ day: row.day, currency: row.currency })),
412
+ };
413
+ }
414
+
415
+ return { importExport, readActuals };
416
+ }
417
+
418
+ function defaultDbPath() {
419
+ const here = path.dirname(fileURLToPath(import.meta.url));
420
+ return path.resolve(here, '..', 'data', 'copilot.db');
421
+ }
422
+
423
+ /** The CLI's work: reads the files, imports them into the database at `dbPath`. */
424
+ export function importDeepSeekUsageFiles({ amountPath, costPath, dbPath = defaultDbPath(), now = () => new Date() } = {}) {
425
+ const amount = fs.readFileSync(amountPath, 'utf8');
426
+ const cost = fs.readFileSync(costPath, 'utf8');
427
+ const db = new Database(dbPath);
428
+ try {
429
+ // The saved key, read straight from the app settings table like the relay does.
430
+ const getApiKey = () => {
431
+ try {
432
+ return String(db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(DEEPSEEK_API_KEY_SETTING_KEY)?.value || '').trim();
433
+ } catch {
434
+ return '';
435
+ }
436
+ };
437
+ return createDeepSeekUsageImportService({ db, getApiKey, now }).importExport({ amount, cost });
438
+ } finally {
439
+ db.close();
440
+ }
441
+ }
442
+
443
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
444
+ if (isMain) {
445
+ const [amountArg, costArg, dbArg] = process.argv.slice(2);
446
+ if (!amountArg || !costArg) {
447
+ console.error('Usage: npm run deepseek:import -- amount.csv cost.csv [path/to/copilot.db]');
448
+ process.exit(2);
449
+ }
450
+ const dbPath = dbArg ? path.resolve(dbArg) : defaultDbPath();
451
+ try {
452
+ const result = importDeepSeekUsageFiles({ amountPath: path.resolve(amountArg), costPath: path.resolve(costArg), dbPath });
453
+ if (!result.ok) {
454
+ console.error(`DeepSeek usage import refused: ${result.error}`);
455
+ process.exit(1);
456
+ }
457
+ const { summary } = result;
458
+ console.log(`DeepSeek usage import on ${dbPath}`);
459
+ console.log(` rows ${summary.rows}, days ${summary.days.join(', ') || 'none'}, models ${summary.models.join(', ') || 'none'}`);
460
+ for (const key of summary.keys) console.log(` key ${key.keyFp}${key.keyName ? ` (${key.keyName})` : ''}: ${key.rows} rows`);
461
+ if (summary.currencies.length) console.log(` currency ${summary.currencies.join(', ')}: the usage card uses the rows in its own currency (the balance's) only`);
462
+ if (summary.skippedPeriods.length) console.log(` left out, not ended yet: ${summary.skippedPeriods.join(', ')}`);
463
+ console.log(summary.savedKeyFp
464
+ ? ` saved key ${summary.savedKeyFp}: ${summary.savedKeyMatched ? 'in the export, the card counts its rows' : 'not in the export, the card is unchanged'}`
465
+ : ' no saved DeepSeek key: the card counts nothing until one is saved');
466
+ process.exit(0);
467
+ } catch (err) {
468
+ console.error(`DeepSeek usage import failed: ${err?.message || err}`);
469
+ process.exit(1);
470
+ }
471
+ }
@@ -130,6 +130,7 @@ export function modelProviderForId(modelId) {
130
130
  if (text.startsWith('mai-')) return 'microsoft';
131
131
  if (text.startsWith('grok-')) return 'xai';
132
132
  if (text.startsWith('kimi-')) return 'moonshot';
133
+ if (text.startsWith('deepseek-')) return 'deepseek';
133
134
  return 'other';
134
135
  }
135
136
 
@@ -9,7 +9,7 @@
9
9
  * while context usage describes one session's token window.
10
10
  *
11
11
  * Every provider normalizer in `plan-usage-<provider>.mjs` emits the same
12
- * `{ card: { meters, details, notes, links } }` shape so the browser renderer
12
+ * `{ card: { meters, charts, details, notes, links } }` shape so the browser renderer
13
13
  * stays provider-agnostic, and so a provider that can only report *some* of
14
14
  * its limits degrades to fewer meters rather than to a special-cased layout.
15
15
  */
@@ -148,7 +148,7 @@ export function toSeverity(value) {
148
148
  * @param {object} spec
149
149
  * @param {string} spec.id stable key, used by tests and DOM ids
150
150
  * @param {string} spec.label human label
151
- * @param {'credits'|'requests'|'usd'|'percent'|'tokens'} [spec.unit]
151
+ * @param {'credits'|'requests'|'usd'|'cny'|'percent'|'tokens'} [spec.unit]
152
152
  * @param {boolean} [spec.unlimited] plan grants unlimited use of this bucket
153
153
  * @param {boolean} [spec.estimated] derived/manual rather than provider-authoritative
154
154
  * @param {string} [spec.severity] the provider's own word for how close the limit is
@@ -201,25 +201,67 @@ export function buildMeter({
201
201
  };
202
202
  }
203
203
 
204
+ /**
205
+ * Hour ranges of a day as `[start, end)` whole hours (0–24), or null when the
206
+ * input is not a list of such pairs.
207
+ */
208
+ export function normalizeHourRanges(value) {
209
+ if (!Array.isArray(value) || !value.length) return null;
210
+ const ranges = [];
211
+ for (const entry of value) {
212
+ if (!Array.isArray(entry) || entry.length !== 2) return null;
213
+ const start = toFiniteNumber(entry[0]);
214
+ const end = toFiniteNumber(entry[1]);
215
+ if (start === null || end === null || !Number.isInteger(start) || !Number.isInteger(end)) return null;
216
+ if (start < 0 || end > 24 || start >= end) return null;
217
+ ranges.push([start, end]);
218
+ }
219
+ return ranges;
220
+ }
221
+
222
+ /** "01:00–04:00 and 06:00–10:00 UTC" for `[[1, 4], [6, 10]]`. */
223
+ export function formatUtcHourRanges(ranges) {
224
+ const list = normalizeHourRanges(ranges);
225
+ if (!list) return null;
226
+ const clock = (hour) => `${String(hour % 24).padStart(2, '0')}:00`;
227
+ const parts = list.map(([start, end]) => `${clock(start)}–${clock(end)}`);
228
+ const joined = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];
229
+ return `${joined} UTC`;
230
+ }
231
+
204
232
  /**
205
233
  * A line of text on the card itself (not inside a collapsed section), with an
206
234
  * optional link behind it. Same link rule as the card's footer: https only.
235
+ *
236
+ * `hours`: `{ utcRanges: [[start, end], …] }` when the text names hours of the
237
+ * day. The text itself carries them in UTC (`formatUtcHourRanges`), complete
238
+ * for any reader; the note then also carries the ranges as data, with the
239
+ * exact UTC wording, so the browser can show them in its own time zone.
207
240
  */
208
- export function buildCardNote({ id, text, link = null } = {}) {
241
+ export function buildCardNote({ id, text, link = null, hours = null, label = null, value = null, hint = null } = {}) {
209
242
  const noteId = toTrimmedString(id);
210
243
  const noteText = toTrimmedString(text);
211
244
  if (!noteId || !noteText) return null;
212
245
  const linkLabel = toTrimmedString(link?.label);
213
246
  const url = toTrimmedString(link?.url);
247
+ const utcRanges = normalizeHourRanges(hours?.utcRanges);
248
+ const utcText = utcRanges ? formatUtcHourRanges(utcRanges) : null;
249
+ // A note that is a figure (a balance, a total) may say so as a label and a
250
+ // value, with a hint; the card shows it as a row of figures, and `text`
251
+ // stays the whole sentence for every other reader.
252
+ const factLabel = toTrimmedString(label);
253
+ const factValue = toTrimmedString(value);
214
254
  return {
215
255
  id: noteId,
216
256
  text: noteText,
217
257
  link: linkLabel && url && /^https:\/\//i.test(url) ? { label: linkLabel, url } : null,
258
+ hours: utcRanges && noteText.includes(utcText) ? { utcRanges, utcText } : null,
259
+ ...(factLabel && factValue ? { label: factLabel, value: factValue, hint: toTrimmedString(hint) } : {}),
218
260
  };
219
261
  }
220
262
 
221
263
  /** A collapsible block of label/value rows shown under the meters. */
222
- export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
264
+ export function buildDetailSection({ id, label, rows = [], note = null, open = false } = {}) {
223
265
  const sectionId = toTrimmedString(id);
224
266
  const sectionLabel = toTrimmedString(label);
225
267
  if (!sectionId || !sectionLabel) return null;
@@ -232,7 +274,57 @@ export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
232
274
  })
233
275
  .filter(Boolean);
234
276
  if (!normalizedRows.length) return null;
235
- return { id: sectionId, label: sectionLabel, rows: normalizedRows, note: toTrimmedString(note) };
277
+ return { id: sectionId, label: sectionLabel, rows: normalizedRows, note: toTrimmedString(note), ...(open === true ? { open: true } : {}) };
278
+ }
279
+
280
+ export const CHART_KINDS = Object.freeze(['columns', 'bars']);
281
+
282
+ /**
283
+ * A small chart drawn by the browser from data, never from markup: stacked
284
+ * `columns` (one per item, e.g. days) or horizontal stacked `bars` (one per
285
+ * item, e.g. models). Every item holds one value per series, in `unit`
286
+ * (`buildMeter`'s units); the renderer draws the shares and writes the text
287
+ * equivalents, so the card payload carries numbers only.
288
+ *
289
+ * `series`: `[{ key, label }]` in the order the colours are assigned, which
290
+ * never changes between reads. `items`: `[{ key, label, values: { <series
291
+ * key>: number } }]`; a value that is missing or not a number counts as 0.
292
+ * `note`: one sentence under the chart. `summary`: a figure the chart adds
293
+ * up to, shown with it (e.g. "Cache saved about ¥1.20").
294
+ */
295
+ export function buildChart({ id, label, kind = 'columns', unit = 'credits', series = [], items = [], note = null, summary = null } = {}) {
296
+ const chartId = toTrimmedString(id);
297
+ const chartLabel = toTrimmedString(label);
298
+ if (!chartId || !chartLabel || !CHART_KINDS.includes(kind)) return null;
299
+ const normalizedSeries = (Array.isArray(series) ? series : [])
300
+ .map((entry) => ({ key: toTrimmedString(entry?.key), label: toTrimmedString(entry?.label) }))
301
+ .filter((entry) => entry.key && entry.label);
302
+ const keys = normalizedSeries.map((entry) => entry.key);
303
+ if (!keys.length || new Set(keys).size !== keys.length) return null;
304
+ const normalizedItems = (Array.isArray(items) ? items : [])
305
+ .map((item) => {
306
+ const itemKey = toTrimmedString(item?.key);
307
+ const itemLabel = toTrimmedString(item?.label);
308
+ if (!itemKey || !itemLabel) return null;
309
+ const values = {};
310
+ for (const key of keys) {
311
+ const value = toFiniteNumber(item?.values?.[key]);
312
+ values[key] = value === null || value < 0 ? 0 : value;
313
+ }
314
+ return { key: itemKey, label: itemLabel, values };
315
+ })
316
+ .filter(Boolean);
317
+ if (!normalizedItems.length) return null;
318
+ return {
319
+ id: chartId,
320
+ label: chartLabel,
321
+ kind,
322
+ unit: toTrimmedString(unit) || 'credits',
323
+ series: normalizedSeries,
324
+ items: normalizedItems,
325
+ note: toTrimmedString(note),
326
+ summary: toTrimmedString(summary),
327
+ };
236
328
  }
237
329
 
238
330
  export function buildProviderCard({
@@ -246,6 +338,7 @@ export function buildProviderCard({
246
338
  capturedAt = null,
247
339
  stale = false,
248
340
  meters = [],
341
+ charts = [],
249
342
  details = [],
250
343
  notes = [],
251
344
  links = [],
@@ -255,6 +348,7 @@ export function buildProviderCard({
255
348
  if (!providerId) return null;
256
349
 
257
350
  const normalizedMeters = (Array.isArray(meters) ? meters : []).filter(Boolean);
351
+ const normalizedCharts = (Array.isArray(charts) ? charts : []).filter(Boolean);
258
352
  const normalizedDetails = (Array.isArray(details) ? details : []).filter(Boolean);
259
353
  const normalizedNotes = (Array.isArray(notes) ? notes : []).map((note) => buildCardNote(note || {})).filter(Boolean);
260
354
  const normalizedLinks = (Array.isArray(links) ? links : [])
@@ -279,6 +373,7 @@ export function buildProviderCard({
279
373
  capturedAt: toIsoTimestamp(capturedAt),
280
374
  stale: stale === true,
281
375
  meters: normalizedMeters,
376
+ charts: normalizedCharts,
282
377
  details: normalizedDetails,
283
378
  notes: normalizedNotes,
284
379
  links: normalizedLinks,