@oar-sh/oar 0.9.15 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,342 @@
1
+ // Settings → Providers → DeepSeek: the key (written, never read back), the
2
+ // switch, the default model, the commit attribution, the monthly budget for
3
+ // the usage card with its currency, the starting balance its balance bar
4
+ // measures from and, under Advanced, the base URL. The
5
+ // relay discovers the models when a key is saved. Below them, the import of
6
+ // DeepSeek's usage export (two CSV files) for the usage card.
7
+
8
+ import { importDeepSeekUsageExport as postDeepSeekUsageExport, loadDeepSeekSettings, updateDeepSeekSettings } from './api-client.js';
9
+ import { showTransientRelayNotice } from './store.js';
10
+
11
+ const DEFAULT_MODEL = 'deepseek-flash';
12
+ const DEFAULT_BASE_URL = 'https://api.deepseek.com';
13
+
14
+ let updateInFlight = false;
15
+ let inputsDirty = false;
16
+ // The starting balance field was typed in since the tab was filled.
17
+ let startingBalanceEdited = false;
18
+ let state = {
19
+ configured: false,
20
+ enabled: false,
21
+ model: DEFAULT_MODEL,
22
+ availableModels: [DEFAULT_MODEL, 'deepseek-v4-pro'],
23
+ baseUrl: DEFAULT_BASE_URL,
24
+ attribution: 'oar',
25
+ monthlyBudget: null,
26
+ // 'USD' or 'CNY'; null for a budget saved before the currency was stored
27
+ // (the card reads it in the balance's currency), or no budget.
28
+ monthlyBudgetCurrency: null,
29
+ startingBalance: null,
30
+ // What the relay says of the starting balance (for example that a later
31
+ // top-up has replaced it), or null.
32
+ startingBalanceNote: null,
33
+ };
34
+
35
+ const BUDGET_CURRENCIES = ['USD', 'CNY'];
36
+
37
+ const CONTROL_IDS = [
38
+ 'deepseek-api-key-input',
39
+ 'deepseek-model-select',
40
+ 'deepseek-attribution-select',
41
+ 'deepseek-monthly-budget-input',
42
+ 'deepseek-monthly-budget-currency-select',
43
+ 'deepseek-starting-balance-input',
44
+ 'deepseek-base-url-input',
45
+ 'deepseek-enabled-toggle',
46
+ 'deepseek-save-btn',
47
+ 'deepseek-remove-btn',
48
+ ];
49
+
50
+ function element(id) {
51
+ return document.getElementById(id);
52
+ }
53
+
54
+ function ensureInputTracking() {
55
+ for (const id of ['deepseek-api-key-input', 'deepseek-model-select', 'deepseek-attribution-select', 'deepseek-monthly-budget-input', 'deepseek-monthly-budget-currency-select', 'deepseek-starting-balance-input', 'deepseek-base-url-input']) {
56
+ const input = element(id);
57
+ if (!input || input.dataset.deepseekDirtyTracking === '1') continue;
58
+ input.dataset.deepseekDirtyTracking = '1';
59
+ input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
60
+ inputsDirty = true;
61
+ if (id === 'deepseek-starting-balance-input') startingBalanceEdited = true;
62
+ });
63
+ }
64
+ }
65
+
66
+ function setControlsDisabled(disabled) {
67
+ for (const id of CONTROL_IDS) {
68
+ const control = element(id);
69
+ if (control) control.disabled = disabled;
70
+ }
71
+ }
72
+
73
+ function fillModelSelect(select) {
74
+ const models = Array.from(new Set([state.model, ...state.availableModels].filter(Boolean)));
75
+ select.innerHTML = '';
76
+ for (const model of models) {
77
+ const option = document.createElement('option');
78
+ option.value = model;
79
+ option.textContent = model;
80
+ select.appendChild(option);
81
+ }
82
+ select.value = state.model;
83
+ }
84
+
85
+ export function applyDeepSeekSettingsState(settings = {}, { resetInputs = false } = {}) {
86
+ ensureInputTracking();
87
+ const available = Array.isArray(settings?.availableModels)
88
+ ? settings.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
89
+ : state.availableModels;
90
+ state = {
91
+ configured: settings?.configured === true,
92
+ enabled: settings?.configured === true && settings?.enabled === true,
93
+ model: String(settings?.model || state.model || DEFAULT_MODEL).trim() || DEFAULT_MODEL,
94
+ availableModels: available.length ? available : state.availableModels,
95
+ baseUrl: String(settings?.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL,
96
+ attribution: settings?.attribution === 'off' ? 'off' : 'oar',
97
+ monthlyBudget: Number.isFinite(Number(settings?.monthlyBudget)) && Number(settings.monthlyBudget) > 0 ? Number(settings.monthlyBudget) : null,
98
+ monthlyBudgetCurrency: BUDGET_CURRENCIES.includes(String(settings?.monthlyBudgetCurrency || '').toUpperCase())
99
+ ? String(settings.monthlyBudgetCurrency).toUpperCase()
100
+ : null,
101
+ startingBalance: Number.isFinite(Number(settings?.startingBalance)) && Number(settings.startingBalance) > 0 ? Number(settings.startingBalance) : null,
102
+ startingBalanceNote: String(settings?.startingBalanceNote || '').trim() || null,
103
+ };
104
+ const keepInputs = inputsDirty && !resetInputs;
105
+ const keyInput = element('deepseek-api-key-input');
106
+ if (keyInput && !keepInputs) {
107
+ keyInput.value = '';
108
+ keyInput.placeholder = state.configured ? 'Saved API key (enter to replace)' : 'DeepSeek API key…';
109
+ }
110
+ const modelSelect = element('deepseek-model-select');
111
+ if (modelSelect && !keepInputs) fillModelSelect(modelSelect);
112
+ const attributionSelect = element('deepseek-attribution-select');
113
+ if (attributionSelect && !keepInputs) attributionSelect.value = state.attribution;
114
+ const budgetInput = element('deepseek-monthly-budget-input');
115
+ if (budgetInput && !keepInputs) budgetInput.value = state.monthlyBudget === null ? '' : String(state.monthlyBudget);
116
+ const budgetCurrencySelect = element('deepseek-monthly-budget-currency-select');
117
+ // A new budget is in dollars unless chosen otherwise; a budget saved
118
+ // without a currency stays in the balance's until one is chosen.
119
+ if (budgetCurrencySelect && !keepInputs) budgetCurrencySelect.value = state.monthlyBudgetCurrency || (state.monthlyBudget === null ? 'USD' : '');
120
+ const startingInput = element('deepseek-starting-balance-input');
121
+ if (startingInput && !keepInputs) startingInput.value = state.startingBalance === null ? '' : String(state.startingBalance);
122
+ const startingStatus = element('deepseek-starting-balance-status');
123
+ if (startingStatus) {
124
+ startingStatus.textContent = state.startingBalanceNote || '';
125
+ startingStatus.hidden = !state.startingBalanceNote;
126
+ }
127
+ const baseUrlInput = element('deepseek-base-url-input');
128
+ if (baseUrlInput && !keepInputs) baseUrlInput.value = state.baseUrl === DEFAULT_BASE_URL ? '' : state.baseUrl;
129
+ if (resetInputs) {
130
+ inputsDirty = false;
131
+ startingBalanceEdited = false;
132
+ }
133
+ const toggle = element('deepseek-enabled-toggle');
134
+ if (toggle) {
135
+ toggle.checked = state.enabled;
136
+ toggle.disabled = updateInFlight || !state.configured;
137
+ }
138
+ const removeButton = element('deepseek-remove-btn');
139
+ if (removeButton) removeButton.disabled = updateInFlight || !state.configured;
140
+ const status = element('deepseek-settings-status');
141
+ if (status) {
142
+ status.textContent = state.enabled
143
+ ? 'DeepSeek is enabled. Select DeepSeek in New Chat to use it.'
144
+ : (state.configured
145
+ ? 'API key saved but currently disabled. Enable it to allow DeepSeek selection in New Chat.'
146
+ : 'Not configured. New conversations use GitHub Copilot.');
147
+ status.dataset.state = state.enabled ? 'active' : (state.configured ? 'saved' : 'unconfigured');
148
+ }
149
+ window.syncAutoModelAvailability?.();
150
+ return state;
151
+ }
152
+
153
+ export async function refreshDeepSeekSettingsState() {
154
+ const settings = await loadDeepSeekSettings();
155
+ if (!settings) return null;
156
+ return applyDeepSeekSettingsState(settings);
157
+ }
158
+
159
+ /** Called when the Settings modal opens. */
160
+ export async function syncDeepSeekSettingsInputs() {
161
+ inputsDirty = false;
162
+ startingBalanceEdited = false;
163
+ ensureInputTracking();
164
+ const status = element('deepseek-settings-status');
165
+ const settings = await refreshDeepSeekSettingsState();
166
+ if (!settings && status) {
167
+ status.textContent = 'Unable to load DeepSeek settings.';
168
+ status.dataset.state = 'error';
169
+ }
170
+ }
171
+
172
+ async function runUpdate(payload, { successText, failureText, resetInputs = false }) {
173
+ if (updateInFlight) return null;
174
+ updateInFlight = true;
175
+ setControlsDisabled(true);
176
+ try {
177
+ const result = await updateDeepSeekSettings(payload);
178
+ if (!result) throw new Error(failureText);
179
+ applyDeepSeekSettingsState(result, { resetInputs });
180
+ const text = typeof successText === 'function' ? successText(result) : successText;
181
+ showTransientRelayNotice(result.warning ? `${text} ${result.warning}` : text, result.warning ? 8000 : 4000);
182
+ return result;
183
+ } catch (error) {
184
+ // A refused key removal (409) names the conversations in its message.
185
+ alert(error?.message || failureText);
186
+ return null;
187
+ } finally {
188
+ updateInFlight = false;
189
+ setControlsDisabled(false);
190
+ applyDeepSeekSettingsState(state);
191
+ }
192
+ }
193
+
194
+ export async function saveDeepSeekSettings() {
195
+ const apiKey = String(element('deepseek-api-key-input')?.value || '').trim();
196
+ if (!apiKey && !state.configured) {
197
+ alert('Enter a DeepSeek API key.');
198
+ return;
199
+ }
200
+ const baseUrlText = String(element('deepseek-base-url-input')?.value || '').trim();
201
+ const budgetText = String(element('deepseek-monthly-budget-input')?.value || '').trim();
202
+ const monthlyBudget = budgetText ? Number(budgetText) : null;
203
+ if (budgetText && !(Number.isFinite(monthlyBudget) && monthlyBudget >= 0)) {
204
+ alert('The monthly budget must be an amount of zero or more, or empty for no budget bar.');
205
+ return;
206
+ }
207
+ const startingText = String(element('deepseek-starting-balance-input')?.value || '').trim();
208
+ const startingBalance = startingText ? Number(startingText) : null;
209
+ if (startingText && !(Number.isFinite(startingBalance) && startingBalance >= 0)) {
210
+ alert('The starting balance must be an amount of zero or more, or empty to measure from the balance after the last top-up.');
211
+ return;
212
+ }
213
+ await runUpdate({
214
+ apiKey,
215
+ model: String(element('deepseek-model-select')?.value || state.model || DEFAULT_MODEL),
216
+ attribution: String(element('deepseek-attribution-select')?.value || state.attribution),
217
+ // Empty is DeepSeek's own endpoint.
218
+ baseUrl: baseUrlText || null,
219
+ // Empty (or zero) is no budget bar on the usage card. The currency goes
220
+ // with the amount; empty is the balance's currency.
221
+ monthlyBudget,
222
+ monthlyBudgetCurrency: String(element('deepseek-monthly-budget-currency-select')?.value || '').trim() || null,
223
+ // Empty (or zero): the balance bar measures from the balance after the
224
+ // last top-up the relay has seen (before one, the highest balance read).
225
+ startingBalance,
226
+ // Typed in since the tab was filled: only then does the relay set a
227
+ // starting balance a top-up has replaced anew (the same amount too). The
228
+ // relay decides "replaced" itself, so a tab opened before a top-up was
229
+ // seen does not bring the old amount back when another field is saved.
230
+ startingBalanceEdited,
231
+ enabled: state.configured ? state.enabled : true,
232
+ }, {
233
+ successText: (result) => `DeepSeek settings saved for ${result.model}.`,
234
+ failureText: 'Failed to save DeepSeek settings.',
235
+ resetInputs: true,
236
+ });
237
+ }
238
+
239
+ export async function toggleDeepSeekProvider(enabled) {
240
+ if (enabled && !state.configured) {
241
+ applyDeepSeekSettingsState(state);
242
+ alert('Save a DeepSeek API key before enabling DeepSeek.');
243
+ return;
244
+ }
245
+ await runUpdate({ enabled: enabled === true }, {
246
+ successText: (result) => (result.enabled ? 'DeepSeek enabled.' : 'DeepSeek disabled.'),
247
+ failureText: 'Failed to update the DeepSeek provider.',
248
+ });
249
+ }
250
+
251
+ export async function removeDeepSeekSettings() {
252
+ if (!state.configured) return;
253
+ if (!confirm('Remove the saved DeepSeek API key?')) return;
254
+ await runUpdate({ remove: true }, {
255
+ successText: 'DeepSeek API key removed. New conversations use GitHub Copilot.',
256
+ failureText: 'Failed to remove DeepSeek settings.',
257
+ resetInputs: true,
258
+ });
259
+ }
260
+
261
+ // The export's times keep its offset: "2026-10-26T00:00:00+01:00" reads
262
+ // "2026-10-26 00:00 (UTC+01:00)".
263
+ function exportTimeLabel(value) {
264
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(String(value || ''));
265
+ if (!match) return String(value || '');
266
+ const zone = !match[3] || match[3] === 'Z' ? 'UTC' : `UTC${match[3]}`;
267
+ return `${match[1]} ${match[2]} (${zone})`;
268
+ }
269
+
270
+ function listText(items) {
271
+ if (items.length <= 1) return items.join('');
272
+ return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
273
+ }
274
+
275
+ /** The import's answer, in the panel. */
276
+ export function deepseekUsageImportSummaryText(summary = {}) {
277
+ const days = Array.isArray(summary.days) ? summary.days : [];
278
+ const models = Array.isArray(summary.models) ? summary.models : [];
279
+ const keys = Array.isArray(summary.keys) ? summary.keys : [];
280
+ const rows = Number(summary.rows) || 0;
281
+ const parts = [];
282
+ if (!rows) {
283
+ parts.push('Nothing to import: the export has no period that has ended.');
284
+ } else {
285
+ const span = days.length > 1 ? `${days[0]} to ${days.at(-1)}` : (days[0] || '');
286
+ parts.push(`Imported ${rows} row${rows === 1 ? '' : 's'}${span ? ` for ${span}` : ''}${models.length ? `: ${listText(models)}` : ''}.`);
287
+ }
288
+ const skipped = Array.isArray(summary.skippedPeriods) ? summary.skippedPeriods.length : 0;
289
+ if (skipped) parts.push(`${skipped} period${skipped === 1 ? '' : 's'} not ended yet left out.`);
290
+ const currencies = Array.isArray(summary.currencies) ? summary.currencies : [];
291
+ const cardCurrency = summary.cardCurrency || '';
292
+ const exportCurrency = currencies.length ? listText(currencies) : '';
293
+ if (rows && summary.savedKeyMatched && summary.currencyMatched === false) {
294
+ parts.push(`The export is in ${exportCurrency || 'another currency'}, the usage card in ${cardCurrency} (the balance's): the card does not use these figures and stays estimated.`);
295
+ } else if (rows && summary.savedKeyMatched) {
296
+ const inCurrency = exportCurrency ? ` (${exportCurrency})` : '';
297
+ const cardNote = !cardCurrency && exportCurrency ? ` It uses them while the balance is in ${exportCurrency}.` : '';
298
+ parts.push(`The usage card shows the saved key's figures${inCurrency} on the export's days through ${exportTimeLabel(summary.through)}, estimated on the others.${cardNote}`);
299
+ } else if (rows && summary.savedKeyFp) {
300
+ parts.push(`The saved key (${summary.savedKeyFp}) is not in this export${keys.length ? `, only ${listText(keys.map((key) => key.keyFp))}` : ''}; the usage card stays estimated.`);
301
+ } else if (rows) {
302
+ parts.push('No DeepSeek key is saved: save the key this export belongs to and the usage card shows its figures.');
303
+ }
304
+ return parts.join(' ');
305
+ }
306
+
307
+ let importInFlight = false;
308
+
309
+ function setImportStatus(text, stateName) {
310
+ const status = element('deepseek-usage-import-status');
311
+ if (!status) return;
312
+ status.textContent = text;
313
+ status.dataset.state = stateName;
314
+ }
315
+
316
+ export async function importDeepSeekUsageExport() {
317
+ if (importInFlight) return null;
318
+ const amountFile = element('deepseek-usage-amount-input')?.files?.[0] || null;
319
+ const costFile = element('deepseek-usage-cost-input')?.files?.[0] || null;
320
+ if (!amountFile || !costFile) {
321
+ setImportStatus('Choose both files of the export: the amount file and the cost file.', 'error');
322
+ return null;
323
+ }
324
+ const button = element('deepseek-usage-import-btn');
325
+ importInFlight = true;
326
+ if (button) button.disabled = true;
327
+ setImportStatus('Importing…', 'saved');
328
+ try {
329
+ const [amount, cost] = await Promise.all([amountFile.text(), costFile.text()]);
330
+ const result = await postDeepSeekUsageExport({ amount, cost });
331
+ if (!result?.ok) throw new Error('The relay could not be reached.');
332
+ const used = result.summary?.savedKeyMatched && result.summary?.currencyMatched !== false;
333
+ setImportStatus(deepseekUsageImportSummaryText(result.summary), used ? 'active' : 'saved');
334
+ return result;
335
+ } catch (error) {
336
+ setImportStatus(String(error?.message || 'The import failed.'), 'error');
337
+ return null;
338
+ } finally {
339
+ importInFlight = false;
340
+ if (button) button.disabled = false;
341
+ }
342
+ }