@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,931 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DeepSeek usage for the Check Usage card.
5
+ *
6
+ * DeepSeek is pay-as-you-go: there is no plan, no window and no allowance,
7
+ * so the card has no meters. It shows two things:
8
+ * - the account balance from `GET /user/balance` (deepseek-balance-service.mjs),
9
+ * read live with the saved key and kept for a minute;
10
+ * - what this relay's DeepSeek turns have cost, estimated from the tokens
11
+ * the workers report after each turn (POST /api/deepseek-plan-usage) and
12
+ * DeepSeek's published prices (shared/deepseek-pricing.mjs), in the
13
+ * balance's currency (USD when there is no balance to go by).
14
+ *
15
+ * Money is shown as notes and detail rows: a meter's units have no CNY, and a
16
+ * balance has nothing to be measured against.
17
+ */
18
+
19
+ import {
20
+ SOURCE_CACHE,
21
+ SOURCE_LIVE,
22
+ SOURCE_WORKER,
23
+ STATUS_NOT_CONFIGURED,
24
+ STATUS_OK,
25
+ STATUS_PARTIAL,
26
+ STATUS_UNAVAILABLE,
27
+ buildChart,
28
+ buildDetailSection,
29
+ buildMeter,
30
+ buildProviderCard,
31
+ buildUnavailableCard,
32
+ formatUtcHourRanges,
33
+ pickNonNegative,
34
+ toFiniteNumber,
35
+ toIsoTimestamp,
36
+ toTrimmedString,
37
+ } from './plan-usage-contract.mjs';
38
+ import { DEEPSEEK_PROVIDER_LABEL, DEEPSEEK_PROVIDER_TYPE, DEFAULT_DEEPSEEK_MODELS, deepseekModelLabel } from '../../shared/deepseek-models.mjs';
39
+ import {
40
+ DEEPSEEK_PEAK_HOURS_UTC,
41
+ DEEPSEEK_PRICE_CURRENCIES,
42
+ DEEPSEEK_PRICING_SOURCE,
43
+ DEEPSEEK_SEARCH_PRICING,
44
+ deepseekPriceFor,
45
+ } from '../../shared/deepseek-pricing.mjs';
46
+
47
+ export const DEEPSEEK_USAGE_URL = 'https://platform.deepseek.com/usage';
48
+ export const DEEPSEEK_TOP_UP_URL = 'https://platform.deepseek.com/top_up';
49
+
50
+ // Same wording in docs/web-ui.md and the CHANGELOG.
51
+ export const DEEPSEEK_HOLIDAY_NOTE = 'On Chinese public holidays DeepSeek charges off-peak prices all day; the estimate doesn\'t know those dates, so turns at peak hours on a holiday are counted at double the real price.';
52
+ // What runs on DeepSeek's servers and what is billed: only WebSearch is a
53
+ // DeepSeek server tool (a server-side fetch tool is refused with a 422), and
54
+ // count_tokens requests are free (a session start makes some; a logging
55
+ // proxy and the DeepSeek dashboard agreed with the estimate, 2026-10-25).
56
+ export const DEEPSEEK_SEARCH_NOTE = `Web searches run on DeepSeek's servers and are counted at $${DEEPSEEK_SEARCH_PRICING.perThousand.USD} / ¥${DEEPSEEK_SEARCH_PRICING.perThousand.CNY} per 1,000 from ${DEEPSEEK_SEARCH_PRICING.chargedFrom.replace('T', ' ').slice(0, 16)} UTC; WebFetch reads the page on the relay host and costs only tokens. DeepSeek does not bill count_tokens requests (Claude Code sends some at each session start).`;
57
+
58
+ // A turn time this far ahead of the relay's clock is the worker's clock
59
+ // being off, not a turn: the relay's own time stands in.
60
+ const FUTURE_TOLERANCE_MS = 5 * 60 * 1000;
61
+ const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._:/-]{0,127}$/;
62
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
63
+
64
+ function modelKey(value) {
65
+ const id = String(value ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
66
+ return MODEL_ID_PATTERN.test(id) ? id : '';
67
+ }
68
+
69
+ function tokens(value) {
70
+ const number = pickNonNegative(value);
71
+ return number === null ? 0 : Math.round(number);
72
+ }
73
+
74
+ /**
75
+ * One worker report: `{ claudeNativeSessionId, runId?, at, totals: [{
76
+ * model, hitTokens, missTokens, outputTokens }], requests?: [{ model,
77
+ * searchRequests, fetchRequests }] }`. The totals are the CLI's RUNNING
78
+ * totals per model for that CLI session (the relay diffs them against the
79
+ * last report of the session). `runId` names the worker's CLI process; with
80
+ * it, the requests are the process's running counts of web searches and
81
+ * fetches, without it (an older worker) the turn's counts. Returns `{ ok:
82
+ * true, report: { nativeSessionId, runId, at, totals, requests } }` or `{
83
+ * ok: false, error }`. A missing `at` is the relay's time; an unreadable one
84
+ * is refused.
85
+ */
86
+ export function normalizeDeepSeekUsageReport(body = {}, { now = () => new Date() } = {}) {
87
+ const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
88
+ const clock = now();
89
+ const nowMs = clock instanceof Date && !Number.isNaN(clock.getTime()) ? clock.getTime() : Date.now();
90
+ let at = new Date(nowMs).toISOString();
91
+ if (payload.at !== undefined && payload.at !== null && payload.at !== '') {
92
+ const parsed = toIsoTimestamp(payload.at);
93
+ if (!parsed) return { ok: false, error: 'at must be an ISO time' };
94
+ if (Date.parse(parsed) <= nowMs + FUTURE_TOLERANCE_MS) at = parsed;
95
+ }
96
+ const nativeSessionId = String(payload.claudeNativeSessionId ?? '').trim();
97
+ if (!SESSION_ID_PATTERN.test(nativeSessionId)) return { ok: false, error: 'Missing or invalid claudeNativeSessionId' };
98
+ let runId = '';
99
+ if (payload.runId !== undefined && payload.runId !== null && payload.runId !== '') {
100
+ runId = String(payload.runId).trim();
101
+ if (!SESSION_ID_PATTERN.test(runId)) return { ok: false, error: 'runId must be a short id' };
102
+ }
103
+ if (!Array.isArray(payload.totals)) return { ok: false, error: 'totals must be a list' };
104
+ const byModel = new Map();
105
+ for (const entry of payload.totals) {
106
+ if (!entry || typeof entry !== 'object') return { ok: false, error: 'Every totals entry must be an object' };
107
+ const model = modelKey(entry.model);
108
+ if (!model) return { ok: false, error: 'Every totals entry needs a valid model id' };
109
+ const current = byModel.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
110
+ current.hitTokens += tokens(entry.hitTokens);
111
+ current.missTokens += tokens(entry.missTokens);
112
+ current.outputTokens += tokens(entry.outputTokens);
113
+ byModel.set(model, current);
114
+ }
115
+ const totals = [...byModel.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0);
116
+ let requests = [];
117
+ if (payload.requests !== undefined && payload.requests !== null) {
118
+ if (!Array.isArray(payload.requests)) return { ok: false, error: 'requests must be a list' };
119
+ const requestsByModel = new Map();
120
+ for (const entry of payload.requests) {
121
+ if (!entry || typeof entry !== 'object') return { ok: false, error: 'Every requests entry must be an object' };
122
+ const model = modelKey(entry.model);
123
+ if (!model) return { ok: false, error: 'Every requests entry needs a valid model id' };
124
+ const current = requestsByModel.get(model) || { model, searchRequests: 0, fetchRequests: 0 };
125
+ current.searchRequests += tokens(entry.searchRequests);
126
+ current.fetchRequests += tokens(entry.fetchRequests);
127
+ requestsByModel.set(model, current);
128
+ }
129
+ requests = [...requestsByModel.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0);
130
+ }
131
+ if (!totals.length && !requests.length) return { ok: false, error: 'Missing usable usage metrics' };
132
+ return { ok: true, report: { nativeSessionId, runId, at, totals, requests } };
133
+ }
134
+
135
+ function currencySymbol(currency) {
136
+ if (currency === 'CNY') return '¥';
137
+ if (currency === 'USD') return '$';
138
+ return `${currency} `;
139
+ }
140
+
141
+ /** "$1.23"; an estimate under one cent keeps four decimals ("¥0.0042"). */
142
+ export function formatDeepSeekMoney(value, currency, { estimate = false } = {}) {
143
+ const number = toFiniteNumber(value);
144
+ if (number === null) return null;
145
+ const digits = estimate && number !== 0 && Math.abs(number) < 0.01 ? 4 : 2;
146
+ return `${number < 0 ? '-' : ''}${currencySymbol(currency)}${Math.abs(number).toFixed(digits)}`;
147
+ }
148
+
149
+ function formatTokens(value) {
150
+ const number = toFiniteNumber(value) || 0;
151
+ if (number < 1000) return String(Math.round(number));
152
+ if (number < 1_000_000) return `${(number / 1000).toFixed(1)}k`;
153
+ return `${(number / 1_000_000).toFixed(1)}M`;
154
+ }
155
+
156
+ function day(value) {
157
+ const iso = toIsoTimestamp(value);
158
+ return iso ? iso.slice(0, 10) : null;
159
+ }
160
+
161
+ const hasMoney = (entry) => (toFiniteNumber(entry?.total) || 0) > 0;
162
+
163
+ /** The balance the spend is shown against: one with money on it, else the first. */
164
+ function primaryBalance(balances) {
165
+ const list = (Array.isArray(balances) ? balances : []).filter((entry) => toTrimmedString(entry?.currency));
166
+ return list.find(hasMoney) || list[0] || null;
167
+ }
168
+
169
+ /**
170
+ * The balances worth a line: when one currency has money on it, a zero
171
+ * balance in another says nothing ("Balance (USD): $0.00" next to ¥49.14).
172
+ * All of them when none has money, so an empty account still shows as such.
173
+ */
174
+ export function shownBalances(balances) {
175
+ const list = (Array.isArray(balances) ? balances : []).filter((entry) => toTrimmedString(entry?.currency));
176
+ return list.some(hasMoney) ? list.filter(hasMoney) : list;
177
+ }
178
+
179
+ function balanceNote(entry, { readAt = null } = {}) {
180
+ const currency = String(entry.currency).toUpperCase();
181
+ const total = formatDeepSeekMoney(entry.total, currency);
182
+ if (total === null) return null;
183
+ const parts = [
184
+ formatDeepSeekMoney(entry.toppedUp, currency) !== null ? `topped up ${formatDeepSeekMoney(entry.toppedUp, currency)}` : null,
185
+ formatDeepSeekMoney(entry.granted, currency) !== null ? `granted ${formatDeepSeekMoney(entry.granted, currency)}` : null,
186
+ ].filter(Boolean);
187
+ const hint = [parts.join(' · '), readAt ? `as read ${readAt}` : ''].filter(Boolean).join(', ');
188
+ return {
189
+ id: `deepseek-balance-${currency.toLowerCase()}`,
190
+ text: `Balance (${currency}): ${total}${parts.length ? ` (${parts.join(', ')})` : ''}${readAt ? `, as read ${readAt}` : ''}`,
191
+ label: `Balance (${currency})`,
192
+ value: total,
193
+ hint,
194
+ };
195
+ }
196
+
197
+ function normalizeTotals(totals) {
198
+ const models = (Array.isArray(totals?.models) ? totals.models : [])
199
+ .map((entry) => ({
200
+ model: toTrimmedString(entry?.model),
201
+ hitTokens: toFiniteNumber(entry?.hitTokens) || 0,
202
+ missTokens: toFiniteNumber(entry?.missTokens) || 0,
203
+ outputTokens: toFiniteNumber(entry?.outputTokens) || 0,
204
+ searchRequests: toFiniteNumber(entry?.searchRequests) || 0,
205
+ fetchRequests: toFiniteNumber(entry?.fetchRequests) || 0,
206
+ turnCount: toFiniteNumber(entry?.turnCount) || 0,
207
+ // The requests the imported export counted (overlayDeepSeekActuals).
208
+ requestCount: toFiniteNumber(entry?.requestCount) || 0,
209
+ costUsd: toFiniteNumber(entry?.costUsd),
210
+ costCny: toFiniteNumber(entry?.costCny),
211
+ toolsCostUsd: toFiniteNumber(entry?.toolsCostUsd) || 0,
212
+ toolsCostCny: toFiniteNumber(entry?.toolsCostCny) || 0,
213
+ firstSeenAt: toIsoTimestamp(entry?.firstSeenAt),
214
+ lastSeenAt: toIsoTimestamp(entry?.lastSeenAt),
215
+ }))
216
+ .filter((entry) => entry.model && (entry.turnCount > 0 || entry.requestCount > 0));
217
+ const times = (key) => models.map((entry) => entry[key]).filter(Boolean).sort();
218
+ return { models, firstSeenAt: times('firstSeenAt')[0] || null, lastSeenAt: times('lastSeenAt').pop() || null };
219
+ }
220
+
221
+ function balanceProblem(balance) {
222
+ if (!balance || balance.ok !== false) return null;
223
+ if (balance.code === 'not_configured') {
224
+ return { status: STATUS_NOT_CONFIGURED, message: 'No DeepSeek API key is saved. Add one in Settings → Providers → DeepSeek.' };
225
+ }
226
+ const reason = toTrimmedString(balance.error) || 'DeepSeek did not answer';
227
+ return { status: STATUS_PARTIAL, message: `The balance could not be read: ${reason}` };
228
+ }
229
+
230
+ const DAY_MS = 86_400_000;
231
+ const CHART_DAYS = 30;
232
+ // How many models get a colour of their own on the charts; the rest share one.
233
+ const CHART_MODEL_SERIES = 4;
234
+ // A Sunday noon: off-peak. Peak prices are exactly double for every part of a
235
+ // price, so the split of a cost into cache hit, cache miss and output is the
236
+ // same at any time; the off-peak prices give the proportions.
237
+ const OFF_PEAK_REFERENCE_AT = '2026-01-04T12:00:00.000Z';
238
+
239
+ const SHARE_SERIES = Object.freeze([
240
+ { key: 'hit', label: 'Cache hit' },
241
+ { key: 'miss', label: 'Cache miss' },
242
+ { key: 'output', label: 'Output' },
243
+ { key: 'tools', label: 'Search' },
244
+ ]);
245
+
246
+ function utcDay(time) {
247
+ return new Date(time).toISOString().slice(0, 10);
248
+ }
249
+
250
+ /** "Oct 21" for a `YYYY-MM-DD` day, in English and UTC like the booking. */
251
+ function dayLabel(day) {
252
+ const date = new Date(`${day}T00:00:00.000Z`);
253
+ if (Number.isNaN(date.getTime())) return day;
254
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
255
+ }
256
+
257
+ /**
258
+ * The charts' model order, fixed so a model keeps its colour between reads:
259
+ * the models DeepSeek is known for first, in their order, then the others
260
+ * alphabetically. Beyond `CHART_MODEL_SERIES` models they fold into "Other".
261
+ */
262
+ function chartModelSeries(models) {
263
+ const known = DEFAULT_DEEPSEEK_MODELS.map((model) => model.toLowerCase());
264
+ const keys = [...new Set(models.map((model) => String(model).toLowerCase()))]
265
+ .sort((a, b) => {
266
+ const ka = known.indexOf(a);
267
+ const kb = known.indexOf(b);
268
+ if (ka !== -1 || kb !== -1) return (ka === -1 ? known.length : ka) - (kb === -1 ? known.length : kb);
269
+ return a < b ? -1 : a > b ? 1 : 0;
270
+ });
271
+ const own = keys.length > CHART_MODEL_SERIES ? keys.slice(0, CHART_MODEL_SERIES - 1) : keys;
272
+ const seriesKeyOf = (model) => (own.includes(String(model).toLowerCase()) ? String(model).toLowerCase() : 'other');
273
+ const series = own.map((key) => ({ key, label: deepseekModelLabel(key) }));
274
+ if (own.length < keys.length) series.push({ key: 'other', label: 'Other models' });
275
+ return { series, seriesKeyOf };
276
+ }
277
+
278
+ /**
279
+ * Whether the starting balance from the DeepSeek tab (`{ amount, setAt }`)
280
+ * is what the balance bar measures from: until a top-up (or a grant) after
281
+ * it was set replaces it. From that rise on, the balance after it is the
282
+ * reference again, as without a starting balance. `{ applies, replacedAt }`.
283
+ *
284
+ * The relay sees a rise only at its next balance read (`reference.roseAt`),
285
+ * which can be after the setting even when the top-up was before it: top up,
286
+ * enter the new balance, then open the card. So a rise first seen after the
287
+ * setting replaces it only when the balance before the rise was read after
288
+ * the setting too (`reference.roseFromAt`: the setting already covered that
289
+ * balance, the money came later). Saving a starting balance reads the
290
+ * balance right then (sessions-routes.mjs), so every later top-up has such a
291
+ * read before it. Only a rise whose earlier read is not known (a reference
292
+ * from before that was noted) falls back to the balance: it replaces the
293
+ * starting balance when the balance after it is above it.
294
+ */
295
+ export function deepseekStartingBalanceState(startingBalance, reference) {
296
+ const amount = toFiniteNumber(startingBalance?.amount);
297
+ if (amount === null || amount <= 0) return { applies: false, replacedAt: null };
298
+ const setAt = Date.parse(String(startingBalance?.setAt ?? ''));
299
+ const roseAt = Date.parse(String(reference?.roseAt ?? ''));
300
+ if (!Number.isFinite(setAt) || !Number.isFinite(roseAt) || roseAt <= setAt) return { applies: true, replacedAt: null };
301
+ const roseFromAt = Date.parse(String(reference?.roseFromAt ?? ''));
302
+ const afterRise = toFiniteNumber(reference?.referenceTotal);
303
+ const replaced = Number.isFinite(roseFromAt) ? roseFromAt >= setAt : (afterRise !== null && afterRise > amount);
304
+ if (replaced) {
305
+ return { applies: false, replacedAt: new Date(roseAt).toISOString() };
306
+ }
307
+ return { applies: true, replacedAt: null };
308
+ }
309
+
310
+ /**
311
+ * The currency the starting balance is in, and the only balance bar it
312
+ * applies to: the balance's main currency (the one with money on it, else
313
+ * the first), upper case; null without a balance.
314
+ */
315
+ export function deepseekStartingBalanceCurrency(balances) {
316
+ const currency = toTrimmedString(primaryBalance(balances)?.currency);
317
+ return currency ? currency.toUpperCase() : null;
318
+ }
319
+
320
+ /**
321
+ * What the DeepSeek tab says under the starting balance once a rise has
322
+ * replaced it, or null while it applies (or none is set). Only a rise of the
323
+ * balance's main currency counts, as for the bar: from `balances` (the last
324
+ * balance read), or without one from the references' last balances.
325
+ */
326
+ export function deepseekStartingBalanceNote(startingBalance, references = [], { balances = null } = {}) {
327
+ const amount = toFiniteNumber(startingBalance?.amount);
328
+ if (amount === null || amount <= 0) return null;
329
+ const list = (Array.isArray(references) ? references : []).filter((reference) => toTrimmedString(reference?.currency));
330
+ const currency = deepseekStartingBalanceCurrency(
331
+ Array.isArray(balances) && balances.length
332
+ ? balances
333
+ : list.map((reference) => ({ currency: reference.currency, total: reference.lastTotal })),
334
+ );
335
+ const reference = list.find((entry) => String(entry.currency).toUpperCase() === currency);
336
+ const replaced = reference ? deepseekStartingBalanceState(startingBalance, reference).replacedAt : null;
337
+ return replaced
338
+ ? `No longer used: the balance rose on ${day(replaced)} (a top-up), so the bar measures from the balance after it. Enter an amount again to measure from it.`
339
+ : null;
340
+ }
341
+
342
+ /**
343
+ * The balance meter of a currency: what is left of the balance after its
344
+ * last rise (`reference`, kept by the relay), or of the starting balance from
345
+ * the DeepSeek tab while it applies (deepseekStartingBalanceState), as a bar
346
+ * that fills with the spend like the Claude bars do.
347
+ */
348
+ function balanceMeter(entry, reference, startingBalance = null) {
349
+ const currency = String(entry.currency).toUpperCase();
350
+ const total = toFiniteNumber(entry.total);
351
+ const unit = currency.toLowerCase();
352
+ if (total === null || !DEEPSEEK_PRICE_CURRENCIES.includes(currency)) return null;
353
+ if (startingBalance && deepseekStartingBalanceState(startingBalance, reference).applies) {
354
+ const amount = toFiniteNumber(startingBalance.amount);
355
+ return buildMeter({
356
+ id: `deepseek-balance-${unit}`,
357
+ label: `Balance (${currency})`,
358
+ unit,
359
+ used: Math.max(0, amount - total),
360
+ allowance: amount,
361
+ remaining: total,
362
+ note: `Measured from ${formatDeepSeekMoney(amount, currency)}, the starting balance set on the DeepSeek tab.`,
363
+ });
364
+ }
365
+ const referenceTotal = toFiniteNumber(reference?.referenceTotal);
366
+ if (referenceTotal === null || referenceTotal <= 0) return null;
367
+ const since = day(reference.referenceAt);
368
+ const atReference = formatDeepSeekMoney(referenceTotal, currency);
369
+ return buildMeter({
370
+ id: `deepseek-balance-${unit}`,
371
+ label: `Balance (${currency})`,
372
+ unit,
373
+ used: Math.max(0, referenceTotal - total),
374
+ allowance: referenceTotal,
375
+ remaining: total,
376
+ note: total >= referenceTotal
377
+ ? `Measured from ${atReference}, the highest balance seen${since ? ` (${since})` : ''}.`
378
+ : `Measured from ${atReference}, the balance after its last rise${since ? ` (${since})` : ''}.`,
379
+ });
380
+ }
381
+
382
+ /** The first day of the next UTC month, as the budget meter's reset. */
383
+ function nextMonthStart(today) {
384
+ const year = Number(today.slice(0, 4));
385
+ const month = Number(today.slice(5, 7));
386
+ return new Date(Date.UTC(year, month, 1)).toISOString();
387
+ }
388
+
389
+ function spendOf(row, costKey, toolsCostKey) {
390
+ return (row[costKey] ?? 0) + (row[toolsCostKey] || 0);
391
+ }
392
+
393
+ /** The daily and per-model rows' cost fields for a currency. */
394
+ function costKeysFor(currency) {
395
+ return currency === 'CNY'
396
+ ? { costKey: 'costCny', toolsCostKey: 'toolsCostCny' }
397
+ : { costKey: 'costUsd', toolsCostKey: 'toolsCostUsd' };
398
+ }
399
+
400
+ function priceCurrency(value) {
401
+ const currency = String(value || '').toUpperCase();
402
+ return DEEPSEEK_PRICE_CURRENCIES.includes(currency) ? currency : null;
403
+ }
404
+
405
+ /**
406
+ * The optional monthly budget, against this calendar month's spend (UTC)
407
+ * from the daily table in the budget's own currency: amber from 75%, red
408
+ * from 90% or over. The label names the currency, so a budget saved in one
409
+ * is not read in the other.
410
+ */
411
+ function budgetMeter({ budget, days, today, currency, actualDays = [] }) {
412
+ const allowance = toFiniteNumber(budget);
413
+ if (allowance === null || allowance <= 0) return null;
414
+ const { costKey, toolsCostKey } = costKeysFor(currency);
415
+ const monthStart = `${today.slice(0, 7)}-01`;
416
+ const spent = days.filter((row) => row.day >= monthStart && row.day <= today)
417
+ .reduce((sum, row) => sum + spendOf(row, costKey, toolsCostKey), 0);
418
+ // With export days in the month the spend is partly DeepSeek's own: the
419
+ // note says which, the "estimated" flag would say all of it is.
420
+ const actual = actualDays.some((value) => value >= monthStart && value <= today);
421
+ return buildMeter({
422
+ id: 'deepseek-monthly-budget',
423
+ label: `Monthly budget (${currency})`,
424
+ unit: currency.toLowerCase(),
425
+ used: Math.round(spent * 100) / 100,
426
+ allowance,
427
+ remaining: Math.round((allowance - spent) * 100) / 100,
428
+ resetAt: nextMonthStart(today),
429
+ estimated: !actual,
430
+ note: actual
431
+ ? `Spend since ${dayLabel(monthStart)}: actual on the days of the DeepSeek export, estimated on the others; starts over on the 1st.`
432
+ : `This relay's estimated spend since ${dayLabel(monthStart)} (UTC); starts over on the 1st.`,
433
+ });
434
+ }
435
+
436
+ /**
437
+ * The last 30 days' spend per day, stacked by model. A day without turns is
438
+ * an empty column, so the chart always spans the 30 days.
439
+ */
440
+ function dailyChart({ days, firstDay, today, unit, costKey, toolsCostKey, actual = false }) {
441
+ const end = Date.parse(`${today}T00:00:00.000Z`);
442
+ const start = end - (CHART_DAYS - 1) * DAY_MS;
443
+ const startDay = utcDay(start);
444
+ const rows = days.filter((row) => row.day >= startDay && row.day <= today);
445
+ if (!rows.some((row) => spendOf(row, costKey, toolsCostKey) > 0)) return null;
446
+ const { series, seriesKeyOf } = chartModelSeries(rows.map((row) => row.model));
447
+ const items = [];
448
+ for (let time = start; time <= end; time += DAY_MS) {
449
+ const key = utcDay(time);
450
+ const values = {};
451
+ for (const row of rows) {
452
+ if (row.day !== key) continue;
453
+ const seriesKey = seriesKeyOf(row.model);
454
+ values[seriesKey] = (values[seriesKey] || 0) + spendOf(row, costKey, toolsCostKey);
455
+ }
456
+ items.push({ key, label: dayLabel(key), values });
457
+ }
458
+ // The daily history began with the relay that keeps it; the totals above
459
+ // may reach further back.
460
+ const shortHistory = firstDay && firstDay > startDay;
461
+ return buildChart({
462
+ id: 'deepseek-daily-spend',
463
+ label: `Daily spend, last ${CHART_DAYS} days`,
464
+ kind: 'columns',
465
+ unit,
466
+ series,
467
+ items,
468
+ note: actual
469
+ ? `Actual from the DeepSeek export on the days it covers, by its days; estimated on the others, by UTC day, from the turns of this relay${shortHistory ? ` (kept by day since ${dayLabel(firstDay)})` : ''}.`
470
+ : (shortHistory
471
+ ? `Daily history on this relay starts ${dayLabel(firstDay)}; earlier turns are only in the totals.`
472
+ : 'Estimated, by UTC day, from the turns of this relay.'),
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Where the money went per model: the cost split into cache hits, cache
478
+ * misses and output (and the searches and fetches once there are any), from
479
+ * the token counts at DeepSeek's prices. The summary says what the cache
480
+ * saved: the hit tokens at the miss price, less what they cost.
481
+ */
482
+ function sharesChart({ models, currency, unit, costKey, toolsCostKey }) {
483
+ const priced = models.filter((entry) => entry[costKey] !== null && entry[costKey] > 0);
484
+ if (!priced.length) return null;
485
+ const anyTools = models.some((entry) => entry[toolsCostKey] > 0);
486
+ let saved = 0;
487
+ const items = priced.map((entry) => {
488
+ const price = deepseekPriceFor({ model: entry.model, at: OFF_PEAK_REFERENCE_AT, currency });
489
+ const values = { hit: 0, miss: 0, output: 0, tools: entry[toolsCostKey] || 0 };
490
+ if (price) {
491
+ const weights = {
492
+ hit: entry.hitTokens * price.hit,
493
+ miss: entry.missTokens * price.miss,
494
+ output: entry.outputTokens * price.output,
495
+ };
496
+ const weightSum = weights.hit + weights.miss + weights.output;
497
+ if (weightSum > 0) {
498
+ for (const key of ['hit', 'miss', 'output']) values[key] = entry[costKey] * (weights[key] / weightSum);
499
+ saved += values.hit * (price.miss / price.hit - 1);
500
+ }
501
+ } else {
502
+ // No price for the split: the whole cost in one piece.
503
+ values.miss = entry[costKey];
504
+ }
505
+ return { key: entry.model.toLowerCase(), label: deepseekModelLabel(entry.model), values };
506
+ });
507
+ return buildChart({
508
+ id: 'deepseek-spend-shares',
509
+ label: 'Where the money goes',
510
+ kind: 'bars',
511
+ unit,
512
+ series: SHARE_SERIES.filter((entry) => entry.key !== 'tools' || anyTools),
513
+ items,
514
+ summary: saved > 0 ? `Cache saved about ${formatDeepSeekMoney(saved, currency, { estimate: true })}` : null,
515
+ note: 'Cache saved: the cache hits at the cache-miss price, less what they cost.',
516
+ });
517
+ }
518
+
519
+ const tenDecimals = (value) => Math.round(Number(value) * 1e10) / 1e10;
520
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
521
+
522
+ // A failed read still shows the last balance that was read, with its time.
523
+ function balanceShown(balance) {
524
+ return balance?.ok === true ? balance : (balance?.previous?.ok === true ? balance.previous : null);
525
+ }
526
+
527
+ /**
528
+ * The usage card's currency for a balance read (deepseek-balance-service.mjs
529
+ * getBalance()): the balance's with money on it, USD without one. The
530
+ * import uses it to say whether the card can use the export's rows.
531
+ */
532
+ export function deepseekCardCurrency(balance) {
533
+ const shown = balanceShown(balance);
534
+ return priceCurrency(primaryBalance(Array.isArray(shown?.balances) ? shown.balances : [])?.currency) || 'USD';
535
+ }
536
+
537
+ /** "2026-10-25 00:00 UTC+01:00" for an export period's ISO time with its offset. */
538
+ function exportTime(value) {
539
+ const text = toTrimmedString(value);
540
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.exec(text || '');
541
+ if (!match) return text || '';
542
+ return `${match[1]} ${match[2]} UTC${match[3] === 'Z' ? '' : match[3]}`;
543
+ }
544
+
545
+ /**
546
+ * The imported DeepSeek export over the estimate, at read time
547
+ * (services/deepseek-usage-import.mjs; nothing booked is changed). `actuals`
548
+ * is `readActuals()`: the saved key's `{ through, rows: [{ day, model,
549
+ * hitTokens, missTokens, outputTokens, requestCount, cost, currency }],
550
+ * coveredDays: [{ day, currency }] }` per export day. Rows in another
551
+ * currency than the card's are not used.
552
+ *
553
+ * The export covers the days the imports have rows for, for any key
554
+ * (`coveredDays`; without it, the saved key's days), so a gap between two
555
+ * imports keeps its estimate. On a covered day each model's daily row is
556
+ * the export's (none in the export is none spent), with the web search
557
+ * estimate dropped, since the export's cost is everything charged.
558
+ * Each model's totals lose the estimate of the covered days and gain the
559
+ * export's.
560
+ *
561
+ * The totals hold every turn, the daily rows only start with `days.firstDay`
562
+ * (when the relay began keeping them, with no backfill), so a covered day
563
+ * before it has no estimate to take out and that first day only part of
564
+ * one: those days, the first included, are left out, the first day named
565
+ * in `actual.leftOutBefore` (or `actual.leftOutAll` with no daily history and
566
+ * turns in the totals). Returns `{ totals, days, actual }` in the shapes the
567
+ * card takes, `actual` null when nothing was used.
568
+ */
569
+ export function overlayDeepSeekActuals({ totals = null, days = null, actuals = null, currency = 'USD' } = {}) {
570
+ const usable = (Array.isArray(actuals?.rows) ? actuals.rows : [])
571
+ .map((row) => ({
572
+ day: toTrimmedString(row?.day),
573
+ model: String(toTrimmedString(row?.model) || '').toLowerCase(),
574
+ hitTokens: toFiniteNumber(row?.hitTokens) || 0,
575
+ missTokens: toFiniteNumber(row?.missTokens) || 0,
576
+ outputTokens: toFiniteNumber(row?.outputTokens) || 0,
577
+ requestCount: toFiniteNumber(row?.requestCount) || 0,
578
+ cost: toFiniteNumber(row?.cost) || 0,
579
+ currency: String(row?.currency || '').toUpperCase(),
580
+ }))
581
+ .filter((row) => row.currency === currency && DAY_PATTERN.test(row.day || '') && row.model);
582
+ if (!usable.length) return { totals, days, actual: null };
583
+ const { costKey, toolsCostKey } = costKeysFor(currency);
584
+ // The days before the daily history have no estimate to replace, and its
585
+ // first day only the turns booked after the upgrade (no backfill): the
586
+ // earlier ones are only in the totals. Both are left out.
587
+ const historyStart = toTrimmedString(days?.firstDay);
588
+ const hasTurns = (Array.isArray(totals?.models) ? totals.models : []).some((entry) => (toFiniteNumber(entry?.turnCount) || 0) > 0);
589
+ const inHistory = (value) => (historyStart ? value > historyStart : !hasTurns);
590
+ const coveredList = Array.isArray(actuals?.coveredDays)
591
+ ? actuals.coveredDays
592
+ .filter((entry) => String(entry?.currency || '').toUpperCase() === currency)
593
+ .map((entry) => toTrimmedString(entry?.day))
594
+ : [];
595
+ const allCovered = new Set([...coveredList, ...usable.map((row) => row.day)].filter((value) => DAY_PATTERN.test(value || '')));
596
+ const coveredDays = new Set([...allCovered].filter(inHistory));
597
+ const leftOut = allCovered.size - coveredDays.size;
598
+ const leftOutNote = leftOut ? (historyStart ? { leftOutBefore: historyStart } : { leftOutAll: true }) : {};
599
+ const used = usable.filter((row) => coveredDays.has(row.day));
600
+ if (!used.length) return { totals, days, actual: null, ...(leftOut ? { leftOut: leftOutNote } : {}) };
601
+ const dayList = [...coveredDays].sort();
602
+ const firstDay = dayList[0];
603
+ const lastDay = dayList[dayList.length - 1];
604
+ const covered = (value) => coveredDays.has(value);
605
+ const keyOf = (dayValue, model) => `${dayValue}\u0000${model}`;
606
+ const actualByDay = new Map();
607
+ for (const row of used) {
608
+ const key = keyOf(row.day, row.model);
609
+ const current = actualByDay.get(key);
610
+ if (!current) actualByDay.set(key, { ...row });
611
+ else for (const field of ['hitTokens', 'missTokens', 'outputTokens', 'requestCount', 'cost']) current[field] += row[field];
612
+ }
613
+
614
+ // The daily rows: the export's on the days it covers.
615
+ const removed = new Map();
616
+ const add = (map, model, entry) => {
617
+ const current = map.get(model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, searchRequests: 0, fetchRequests: 0, requestCount: 0, cost: 0, toolsCost: 0, firstDay: null, lastDay: null, priced: false };
618
+ for (const field of ['hitTokens', 'missTokens', 'outputTokens', 'searchRequests', 'fetchRequests', 'requestCount', 'cost', 'toolsCost']) current[field] += entry[field] || 0;
619
+ if (entry.priced) current.priced = true;
620
+ if (entry.day && (!current.firstDay || entry.day < current.firstDay)) current.firstDay = entry.day;
621
+ if (entry.day && (!current.lastDay || entry.day > current.lastDay)) current.lastDay = entry.day;
622
+ map.set(model, current);
623
+ };
624
+ const usedDays = new Set();
625
+ const outDays = [];
626
+ for (const row of Array.isArray(days?.days) ? days.days : []) {
627
+ const rowDay = toTrimmedString(row?.day) || '';
628
+ const model = String(toTrimmedString(row?.model) || '').toLowerCase();
629
+ if (!covered(rowDay) || !model) {
630
+ outDays.push(row);
631
+ continue;
632
+ }
633
+ add(removed, model, {
634
+ hitTokens: toFiniteNumber(row.hitTokens) || 0,
635
+ missTokens: toFiniteNumber(row.missTokens) || 0,
636
+ outputTokens: toFiniteNumber(row.outputTokens) || 0,
637
+ searchRequests: toFiniteNumber(row.searchRequests) || 0,
638
+ fetchRequests: toFiniteNumber(row.fetchRequests) || 0,
639
+ cost: toFiniteNumber(row[costKey]) || 0,
640
+ toolsCost: toFiniteNumber(row[toolsCostKey]) || 0,
641
+ });
642
+ const actual = actualByDay.get(keyOf(rowDay, model));
643
+ if (actual) usedDays.add(keyOf(rowDay, model));
644
+ outDays.push({
645
+ ...row,
646
+ hitTokens: actual?.hitTokens || 0,
647
+ missTokens: actual?.missTokens || 0,
648
+ outputTokens: actual?.outputTokens || 0,
649
+ searchRequests: 0,
650
+ fetchRequests: 0,
651
+ requestCount: actual?.requestCount || 0,
652
+ [costKey]: tenDecimals(actual?.cost || 0),
653
+ [toolsCostKey]: 0,
654
+ });
655
+ }
656
+ const added = new Map();
657
+ for (const [key, actual] of actualByDay) {
658
+ add(added, actual.model, { ...actual, priced: true });
659
+ if (usedDays.has(key)) continue;
660
+ outDays.push({
661
+ day: actual.day,
662
+ model: actual.model,
663
+ hitTokens: actual.hitTokens,
664
+ missTokens: actual.missTokens,
665
+ outputTokens: actual.outputTokens,
666
+ searchRequests: 0,
667
+ fetchRequests: 0,
668
+ requestCount: actual.requestCount,
669
+ costUsd: null,
670
+ costCny: null,
671
+ toolsCostUsd: 0,
672
+ toolsCostCny: 0,
673
+ [costKey]: tenDecimals(actual.cost),
674
+ });
675
+ }
676
+ outDays.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : String(a.model) < String(b.model) ? -1 : String(a.model) > String(b.model) ? 1 : 0));
677
+
678
+ // The totals: less the covered days' estimate, plus the export.
679
+ const keep = (value) => Math.max(0, tenDecimals(value));
680
+ const seen = new Set();
681
+ const models = (Array.isArray(totals?.models) ? totals.models : []).map((entry) => {
682
+ const model = String(toTrimmedString(entry?.model) || '').toLowerCase();
683
+ seen.add(model);
684
+ const minus = removed.get(model);
685
+ const plus = added.get(model);
686
+ if (!minus && !plus) return entry;
687
+ const less = (field) => (toFiniteNumber(entry?.[field]) || 0) - (minus?.[field] || 0) + (plus?.[field] || 0);
688
+ const estimate = toFiniteNumber(entry?.[costKey]);
689
+ return {
690
+ ...entry,
691
+ hitTokens: keep(less('hitTokens')),
692
+ missTokens: keep(less('missTokens')),
693
+ outputTokens: keep(less('outputTokens')),
694
+ searchRequests: keep((toFiniteNumber(entry?.searchRequests) || 0) - (minus?.searchRequests || 0)),
695
+ fetchRequests: keep((toFiniteNumber(entry?.fetchRequests) || 0) - (minus?.fetchRequests || 0)),
696
+ requestCount: plus?.requestCount || 0,
697
+ [costKey]: estimate === null && !plus ? null : keep((estimate || 0) - (minus?.cost || 0) + (plus?.cost || 0)),
698
+ [toolsCostKey]: keep((toFiniteNumber(entry?.[toolsCostKey]) || 0) - (minus?.toolsCost || 0)),
699
+ };
700
+ });
701
+ for (const [model, plus] of added) {
702
+ if (seen.has(model)) continue;
703
+ models.push({
704
+ model,
705
+ hitTokens: plus.hitTokens,
706
+ missTokens: plus.missTokens,
707
+ outputTokens: plus.outputTokens,
708
+ searchRequests: 0,
709
+ fetchRequests: 0,
710
+ turnCount: 0,
711
+ requestCount: plus.requestCount,
712
+ costUsd: null,
713
+ costCny: null,
714
+ toolsCostUsd: 0,
715
+ toolsCostCny: 0,
716
+ [costKey]: tenDecimals(plus.cost),
717
+ firstSeenAt: `${plus.firstDay}T00:00:00.000Z`,
718
+ lastSeenAt: `${plus.lastDay}T00:00:00.000Z`,
719
+ });
720
+ }
721
+ return {
722
+ totals: { ...(totals || {}), models },
723
+ days: { ...(days || {}), days: outDays },
724
+ actual: { firstDay, lastDay, days: dayList, through: toTrimmedString(actuals?.through) || null, ...leftOutNote },
725
+ };
726
+ }
727
+
728
+ /**
729
+ * @param {object} options
730
+ * @param {object|null} options.balance deepseek-balance-service.mjs answer:
731
+ * `{ ok: true, isAvailable, balances, fetchedAt, cached }`,
732
+ * `{ ok: false, code, error, fetchedAt, previous }`, or null (not read)
733
+ * @param {Array} [options.balanceReferences] planUsageService.recordDeepSeekBalance():
734
+ * `[{ currency, referenceTotal, lastTotal, referenceAt }]`
735
+ * @param {object|null} options.totals planUsageService.readDeepSeekUsageTotals()
736
+ * @param {object|null} [options.days] planUsageService.readDeepSeekUsageDays():
737
+ * `{ days, firstDay }` reaching back to the first of this month or 30 days
738
+ * @param {number|null} [options.monthlyBudget] the DeepSeek tab's budget
739
+ * @param {string|null} [options.monthlyBudgetCurrency] its currency, `USD` or
740
+ * `CNY`; null for a budget saved before the currency was stored, which is
741
+ * read in the balance's currency
742
+ * @param {object|null} [options.startingBalance] the DeepSeek tab's
743
+ * starting balance, `{ amount, setAt }`, in the balance's currency: the
744
+ * bar of the balance's currency measures from it while it applies
745
+ * @param {string} [options.now] the report's time (UTC today for the charts)
746
+ * @param {boolean} options.configured DeepSeek is on
747
+ * @param {object|null} [options.actuals] the imported export's figures of the
748
+ * saved key (deepseek-usage-import.mjs readActuals()), laid over the
749
+ * estimate by overlayDeepSeekActuals; null for the estimate alone
750
+ */
751
+ export function buildDeepSeekPlanCard({
752
+ balance = null,
753
+ balanceReferences = [],
754
+ totals = null,
755
+ days = null,
756
+ monthlyBudget = null,
757
+ monthlyBudgetCurrency = null,
758
+ startingBalance = null,
759
+ now = null,
760
+ configured = true,
761
+ actuals = null,
762
+ } = {}) {
763
+ if (!configured) return null;
764
+ const today = day(now) || new Date().toISOString().slice(0, 10);
765
+ const references = (Array.isArray(balanceReferences) ? balanceReferences : [])
766
+ .filter((entry) => toTrimmedString(entry?.currency));
767
+ const problem = balanceProblem(balance);
768
+ const shownBalance = balanceShown(balance);
769
+ const balances = Array.isArray(shownBalance?.balances) ? shownBalance.balances : [];
770
+ const currency = deepseekCardCurrency(balance);
771
+ const overlay = overlayDeepSeekActuals({ totals, days, actuals, currency });
772
+ const actual = overlay.actual;
773
+ const spend = normalizeTotals(overlay.totals);
774
+ const dayRows = (Array.isArray(overlay.days?.days) ? overlay.days.days : [])
775
+ .map((row) => ({
776
+ day: toTrimmedString(row?.day),
777
+ model: toTrimmedString(row?.model),
778
+ costUsd: toFiniteNumber(row?.costUsd),
779
+ costCny: toFiniteNumber(row?.costCny),
780
+ toolsCostUsd: toFiniteNumber(row?.toolsCostUsd) || 0,
781
+ toolsCostCny: toFiniteNumber(row?.toolsCostCny) || 0,
782
+ }))
783
+ .filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.day) && row.model);
784
+ const links = [
785
+ { label: 'DeepSeek usage', url: DEEPSEEK_USAGE_URL },
786
+ { label: 'Top up', url: DEEPSEEK_TOP_UP_URL },
787
+ ];
788
+
789
+ if (!balances.length && !spend.models.length) {
790
+ return buildUnavailableCard({
791
+ provider: DEEPSEEK_PROVIDER_TYPE,
792
+ label: DEEPSEEK_PROVIDER_LABEL,
793
+ status: problem?.status || STATUS_UNAVAILABLE,
794
+ message: problem?.message || 'No DeepSeek balance or usage yet. The balance is read with the saved key; the spend is counted from DeepSeek turns on this relay.',
795
+ links,
796
+ capturedAt: balance?.fetchedAt || null,
797
+ });
798
+ }
799
+
800
+ const readAt = shownBalance && shownBalance !== balance && shownBalance.fetchedAt
801
+ ? `${String(shownBalance.fetchedAt).replace('T', ' ').slice(0, 16)} UTC`
802
+ : null;
803
+ const notes = shownBalances(balances).map((entry) => balanceNote(entry, { readAt })).filter(Boolean);
804
+ let message = problem?.message || null;
805
+ if (!problem && shownBalance && shownBalance.isAvailable === false) {
806
+ message = 'DeepSeek reports this balance as not enough for API calls: DeepSeek turns fail until it is topped up.';
807
+ notes.push({ id: 'deepseek-top-up', text: 'Add money to the account to use DeepSeek again.', link: { label: 'Top up', url: DEEPSEEK_TOP_UP_URL } });
808
+ }
809
+
810
+ const { costKey, toolsCostKey } = costKeysFor(currency);
811
+ const unit = currency.toLowerCase();
812
+ const priced = spend.models.filter((entry) => entry[costKey] !== null);
813
+ // The balance bar of each currency with money on it, against the balance
814
+ // after its last rise (the balance's main currency against the starting
815
+ // balance while it applies, as the DeepSeek tab's note judges it); then the
816
+ // budget, when one is set, in its own currency.
817
+ const startingCurrency = deepseekStartingBalanceCurrency(balances);
818
+ const meters = shownBalances(balances).filter(hasMoney)
819
+ .map((entry) => balanceMeter(
820
+ entry,
821
+ references.find((ref) => String(ref.currency).toUpperCase() === String(entry.currency).toUpperCase()),
822
+ String(entry.currency).toUpperCase() === startingCurrency ? startingBalance : null,
823
+ ))
824
+ .filter(Boolean);
825
+ const budgetCurrency = priceCurrency(monthlyBudgetCurrency) || currency;
826
+ meters.push(budgetMeter({ budget: monthlyBudget, days: dayRows, today, currency: budgetCurrency, actualDays: actual && budgetCurrency === currency ? actual.days : [] }));
827
+ const charts = [
828
+ dailyChart({ days: dayRows, firstDay: toTrimmedString(days?.firstDay), today, unit, costKey, toolsCostKey, actual: !!actual }),
829
+ sharesChart({ models: spend.models, currency, unit, costKey, toolsCostKey }),
830
+ ];
831
+ const details = [];
832
+ if (spend.models.length) {
833
+ // The web searches of every model, in one line: they are priced the same
834
+ // whatever the model (0 before DeepSeek charged for them). The fetch count
835
+ // stays in the books and shows only if DeepSeek ever reports one: its API
836
+ // refuses a server-side fetch tool, and WebFetch is plain tokens.
837
+ const searches = spend.models.reduce((sum, entry) => sum + entry.searchRequests, 0);
838
+ const fetches = spend.models.reduce((sum, entry) => sum + entry.fetchRequests, 0);
839
+ const toolsTotal = spend.models.reduce((sum, entry) => sum + entry[toolsCostKey], 0);
840
+ const total = priced.reduce((sum, entry) => sum + entry[costKey], 0) + toolsTotal;
841
+ const unpriced = spend.models.length - priced.length;
842
+ // With an export, the spend starts at its first day if that is earlier.
843
+ const since = [day(spend.firstSeenAt), actual?.firstDay].filter(Boolean).sort()[0] || '';
844
+ const spendValue = priced.length || toolsTotal > 0 ? formatDeepSeekMoney(total, currency, { estimate: true }) : 'no price known';
845
+ const unpricedText = unpriced ? `${unpriced} model${unpriced === 1 ? '' : 's'} without a known price not included` : '';
846
+ const spendLabel = actual ? 'Spend, actual and estimated' : 'Estimated spend on this relay';
847
+ notes.push({
848
+ id: 'deepseek-spend',
849
+ text: `${spendLabel}: ${spendValue}${since ? ` since ${since}` : ''}${unpricedText ? ` (${unpricedText})` : ''}`,
850
+ label: spendLabel,
851
+ value: spendValue,
852
+ hint: [since ? `since ${since}` : '', unpricedText].filter(Boolean).join(', '),
853
+ });
854
+ const count = (number, noun) => `${Math.round(number)} ${noun}${number === 1 ? '' : 'es'}`;
855
+ details.push(buildDetailSection({
856
+ id: 'deepseek-spend-by-model',
857
+ label: actual ? 'Spend by model, actual and estimated' : 'Estimated spend by model',
858
+ rows: [
859
+ ...spend.models.map((entry) => ({
860
+ label: deepseekModelLabel(entry.model),
861
+ value: entry[costKey] !== null ? formatDeepSeekMoney(entry[costKey], currency, { estimate: true }) : 'no price known',
862
+ hint: [
863
+ entry.turnCount > 0 ? `${Math.round(entry.turnCount)} turn${entry.turnCount === 1 ? '' : 's'}${day(entry.firstSeenAt) ? ` since ${day(entry.firstSeenAt)}` : ''}` : '',
864
+ entry.requestCount > 0 ? `${Math.round(entry.requestCount)} request${entry.requestCount === 1 ? '' : 's'} in the export` : '',
865
+ ].filter(Boolean).join(' · '),
866
+ })),
867
+ ...(searches + fetches > 0 ? [{
868
+ label: 'Search',
869
+ value: formatDeepSeekMoney(toolsTotal, currency, { estimate: true }),
870
+ hint: fetches > 0 ? `${count(searches, 'search')} · ${count(fetches, 'fetch')}` : count(searches, 'search'),
871
+ }] : []),
872
+ ],
873
+ note: actual
874
+ ? 'The export\'s figures are the saved key\'s, from wherever it was used; the estimate counts DeepSeek turns on this relay only.'
875
+ : 'DeepSeek turns on this relay only; the DeepSeek platform\'s usage page has every request on the account.',
876
+ open: true,
877
+ }));
878
+ details.push(buildDetailSection({
879
+ id: 'deepseek-tokens-by-model',
880
+ label: 'Tokens by model',
881
+ rows: spend.models.map((entry) => {
882
+ const input = entry.hitTokens + entry.missTokens;
883
+ return {
884
+ label: deepseekModelLabel(entry.model),
885
+ value: `cache hit ${formatTokens(entry.hitTokens)} · cache miss ${formatTokens(entry.missTokens)} · output ${formatTokens(entry.outputTokens)}`,
886
+ hint: input > 0 ? `${Math.round((entry.hitTokens / input) * 100)}% of input from the cache` : null,
887
+ };
888
+ }),
889
+ open: true,
890
+ }));
891
+ const leftOut = actual || overlay.leftOut || {};
892
+ const leftOutText = leftOut.leftOutBefore
893
+ ? ` Export days through ${dayLabel(leftOut.leftOutBefore)}, the day this relay's daily history starts, are left out: their turns are not all in that history, only in the estimate.`
894
+ : (leftOut.leftOutAll ? ' The export is left out: this relay keeps no daily history to set it against.' : '');
895
+ if (actual) {
896
+ notes.push({
897
+ id: 'deepseek-actual',
898
+ text: `Actual figures from the DeepSeek export on the days it covers (the last through ${exportTime(actual.through) || actual.lastDay}), estimated on the others. On export days the export's cost includes the web searches. Export days follow the export's time zone, the estimate's UTC.${leftOutText}`,
899
+ link: { label: 'DeepSeek usage', url: DEEPSEEK_USAGE_URL },
900
+ });
901
+ } else if (leftOutText) {
902
+ notes.push({ id: 'deepseek-actual', text: leftOutText.trim(), link: { label: 'DeepSeek usage', url: DEEPSEEK_USAGE_URL } });
903
+ }
904
+ notes.push({
905
+ id: 'deepseek-estimate',
906
+ text: `Estimated from DeepSeek's published prices (${DEEPSEEK_PRICING_SOURCE.date}); peak or off-peak by the time of each turn. Peak is ${formatUtcHourRanges(DEEPSEEK_PEAK_HOURS_UTC)} on weekdays (Monday to Friday by UTC). ${DEEPSEEK_HOLIDAY_NOTE} ${DEEPSEEK_SEARCH_NOTE}`,
907
+ link: { label: 'Prices', url: DEEPSEEK_PRICING_SOURCE.url },
908
+ // The browser shows the peak hours in its own time zone.
909
+ hours: { utcRanges: DEEPSEEK_PEAK_HOURS_UTC },
910
+ });
911
+ }
912
+
913
+ let source = SOURCE_WORKER;
914
+ if (balance?.ok === true) source = balance.cached === true ? SOURCE_CACHE : SOURCE_LIVE;
915
+ else if (shownBalance) source = SOURCE_CACHE;
916
+
917
+ return buildProviderCard({
918
+ provider: DEEPSEEK_PROVIDER_TYPE,
919
+ label: DEEPSEEK_PROVIDER_LABEL,
920
+ status: problem && problem.status !== STATUS_NOT_CONFIGURED ? STATUS_PARTIAL : (problem?.status || STATUS_OK),
921
+ message,
922
+ source,
923
+ stale: !!shownBalance && shownBalance !== balance,
924
+ capturedAt: shownBalance?.fetchedAt || spend.lastSeenAt || null,
925
+ meters: meters.filter(Boolean),
926
+ charts: charts.filter(Boolean),
927
+ details: details.filter(Boolean),
928
+ notes,
929
+ links,
930
+ });
931
+ }