@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
@@ -35,6 +35,7 @@ const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
35
35
  'claude-cloud': 'Claude Cloud',
36
36
  cursor: 'Cursor',
37
37
  grok: 'Grok',
38
+ deepseek: 'DeepSeek',
38
39
  }));
39
40
 
40
41
  // The composer speaks 'github'/'openai'/… while the usage report keys Copilot as
@@ -86,9 +87,9 @@ export function utilizationColor(utilization) {
86
87
  export function formatAmount(value, unit) {
87
88
  const n = toNullableNumber(value);
88
89
  if (n === null) return null;
89
- if (unit === 'usd') {
90
+ if (unit === 'usd' || unit === 'cny') {
90
91
  const rounded = Math.round(n * 100) / 100;
91
- return `${rounded < 0 ? '-' : ''}$${Math.abs(rounded).toFixed(2)}`;
92
+ return `${rounded < 0 ? '-' : ''}${unit === 'cny' ? '¥' : '$'}${Math.abs(rounded).toFixed(2)}`;
92
93
  }
93
94
  if (unit === 'percent') return `${Math.round(n * 10) / 10}%`;
94
95
  const abs = Math.abs(n);
@@ -189,15 +190,103 @@ function renderMeter(meter, now) {
189
190
  `;
190
191
  }
191
192
 
193
+ /**
194
+ * A chart from `buildChart` data: stacked `columns` (one per item, e.g. a
195
+ * day) or horizontal stacked `bars` (one per item, e.g. a model), drawn with
196
+ * plain HTML and CSS. Colours come from the theme's series variables (fixed
197
+ * order, so a series keeps its colour between reads); every item has a text
198
+ * equivalent as its label, a tooltip on hover, focus or tap, and the chart
199
+ * has a legend for more than one series and a table view of the same data.
200
+ */
201
+ function renderChart(chart) {
202
+ const series = Array.isArray(chart?.series) ? chart.series.filter((entry) => entry && entry.key) : [];
203
+ const items = Array.isArray(chart?.items) ? chart.items.filter((entry) => entry && entry.key) : [];
204
+ if (!series.length || !items.length) return '';
205
+ const unit = chart.unit || 'credits';
206
+ // Money under a cent keeps four decimals, as the card's estimates do.
207
+ const amount = (value) => {
208
+ const n = toNullableNumber(value) ?? 0;
209
+ if ((unit === 'usd' || unit === 'cny') && n !== 0 && Math.abs(n) < 0.01) {
210
+ return `${n < 0 ? '-' : ''}${unit === 'cny' ? '¥' : '$'}${Math.abs(n).toFixed(4)}`;
211
+ }
212
+ return formatAmount(n, unit) ?? '0';
213
+ };
214
+ const valueOf = (item, key) => Math.max(0, toNullableNumber(item.values?.[key]) ?? 0);
215
+ const totalOf = (item) => series.reduce((sum, entry) => sum + valueOf(item, entry.key), 0);
216
+ const seriesColor = (index) => `var(--plan-usage-series-${Math.min(index + 1, 5)})`;
217
+ const partsText = (item) => {
218
+ const total = totalOf(item);
219
+ return series
220
+ .map((entry) => ({ entry, value: valueOf(item, entry.key) }))
221
+ .filter(({ value }) => value > 0)
222
+ .map(({ entry, value }) => `${entry.label} ${amount(value)}${series.length > 1 && total > 0 ? ` (${Math.round((value / total) * 100)}%)` : ''}`)
223
+ .join(', ');
224
+ };
225
+ const itemText = (item) => {
226
+ const total = totalOf(item);
227
+ if (total <= 0) return `${item.label}: nothing`;
228
+ const parts = series.length > 1 ? partsText(item) : '';
229
+ return `${item.label}: ${amount(total)}${parts ? ` (${parts})` : ''}`;
230
+ };
231
+ const max = Math.max(0, ...items.map(totalOf));
232
+ const segments = (item, dimension) => series.map((entry, index) => {
233
+ const value = valueOf(item, entry.key);
234
+ if (value <= 0) return '';
235
+ const base = dimension === 'height' ? max : totalOf(item);
236
+ const share = base > 0 ? (value / base) * 100 : 0;
237
+ return `<span class="plan-usage-chart-seg" data-series="${escapeHtml(entry.key)}" style="${dimension}:${share.toFixed(2)}%;background:${seriesColor(index)}" title="${escapeHtml(`${entry.label} ${amount(value)}`)}"></span>`;
238
+ }).join('');
239
+
240
+ const plot = chart.kind === 'bars'
241
+ ? `<div class="plan-usage-chart-bars">${items.map((item) => `
242
+ <div class="plan-usage-chart-row" tabindex="0" aria-label="${escapeHtml(itemText(item))}">
243
+ <span class="plan-usage-chart-row-label">${escapeHtml(item.label)}</span>
244
+ <span class="plan-usage-chart-bar" aria-hidden="true">${segments(item, 'width')}</span>
245
+ <span class="plan-usage-chart-row-value">${escapeHtml(amount(totalOf(item)))}</span>
246
+ <span class="plan-usage-chart-tip" role="tooltip">${escapeHtml(itemText(item))}</span>
247
+ </div>`).join('')}</div>`
248
+ : `<div class="plan-usage-chart-columns" style="--plan-usage-columns:${items.length}">${items.map((item) => `
249
+ <div class="plan-usage-chart-col${totalOf(item) > 0 ? '' : ' plan-usage-chart-col-empty'}" tabindex="0" aria-label="${escapeHtml(itemText(item))}" data-item="${escapeHtml(item.key)}">
250
+ <span class="plan-usage-chart-stack" aria-hidden="true">${segments(item, 'height')}</span>
251
+ <span class="plan-usage-chart-tip" role="tooltip">${escapeHtml(itemText(item))}</span>
252
+ </div>`).join('')}
253
+ <span class="plan-usage-chart-axis" aria-hidden="true"><span>${escapeHtml(items[0].label)}</span><span>${escapeHtml(items[items.length - 1].label)}</span></span>
254
+ </div>`;
255
+
256
+ const legend = series.length > 1
257
+ ? `<ul class="plan-usage-chart-legend">${series.map((entry, index) => `<li><span class="plan-usage-chart-swatch" style="background:${seriesColor(index)}" aria-hidden="true"></span>${escapeHtml(entry.label)}</li>`).join('')}</ul>`
258
+ : '';
259
+ const table = `
260
+ <details class="plan-usage-chart-table">
261
+ <summary>As a table</summary>
262
+ <table class="plan-usage-detail-table">
263
+ <thead><tr><th scope="col"></th>${series.map((entry) => `<th scope="col">${escapeHtml(entry.label)}</th>`).join('')}${series.length > 1 ? '<th scope="col">Total</th>' : ''}</tr></thead>
264
+ <tbody>${items.filter((item) => totalOf(item) > 0).map((item) => `<tr><th scope="row">${escapeHtml(item.label)}</th>${series.map((entry) => `<td class="plan-usage-detail-value">${escapeHtml(amount(valueOf(item, entry.key)))}</td>`).join('')}${series.length > 1 ? `<td class="plan-usage-detail-value">${escapeHtml(amount(totalOf(item)))}</td>` : ''}</tr>`).join('')}</tbody>
265
+ </table>
266
+ </details>`;
267
+ return `
268
+ <figure class="plan-usage-chart plan-usage-chart-kind-${chart.kind === 'bars' ? 'bars' : 'columns'}" data-chart-id="${escapeHtml(chart.id)}" role="group" aria-label="${escapeHtml(chart.label)}">
269
+ <figcaption class="plan-usage-chart-head">
270
+ <span class="plan-usage-chart-label">${escapeHtml(chart.label)}</span>
271
+ ${chart.summary ? `<span class="plan-usage-chart-summary">${escapeHtml(chart.summary)}</span>` : ''}
272
+ </figcaption>
273
+ ${plot}
274
+ ${legend}
275
+ ${chart.note ? `<div class="plan-usage-meter-note">${escapeHtml(chart.note)}</div>` : ''}
276
+ ${table}
277
+ </figure>
278
+ `;
279
+ }
280
+
192
281
  function renderDetailSection(section) {
193
282
  const rows = section.rows.map((row) => `
194
283
  <tr>
195
284
  <td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
196
- <td class="plan-usage-detail-value">${escapeHtml(row.value)}</td>
285
+ <td class="plan-usage-detail-value">${String(row.value).split(' · ').map((part) => `<span class="plan-usage-detail-part">${escapeHtml(part)}</span>`).join(' · ')}</td>
197
286
  </tr>
198
287
  `).join('');
199
288
  return `
200
- <details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}">
289
+ <details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}"${section.open ? ' open' : ''}>
201
290
  <summary>${escapeHtml(section.label)}</summary>
202
291
  ${section.note ? `<div class="plan-usage-detail-note">${escapeHtml(section.note)}</div>` : ''}
203
292
  <table class="plan-usage-detail-table"><tbody>${rows}</tbody></table>
@@ -205,9 +294,59 @@ function renderDetailSection(section) {
205
294
  `;
206
295
  }
207
296
 
208
- /** A line of text on the card itself, with an optional link behind it. */
209
- function renderNote(note) {
297
+ /**
298
+ * Hours of the day the card note names, shown in the reader's own time zone.
299
+ * `utcRanges` are `[start, end)` whole hours in UTC; the day is `now`'s, so
300
+ * daylight-saving time is the reader's current one. "03:00–06:00 and
301
+ * 08:00–12:00 in your time zone" for `[[1, 4], [6, 10]]` read in Berlin in
302
+ * summer. `timeZone` is for tests; the browser leaves it to Intl.
303
+ */
304
+ export function formatLocalHourRanges(utcRanges, { now = new Date(), timeZone = undefined } = {}) {
305
+ if (!Array.isArray(utcRanges) || !utcRanges.length) return null;
306
+ let formatter;
307
+ try {
308
+ formatter = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
309
+ } catch {
310
+ return null;
311
+ }
312
+ const day = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
313
+ const clock = (hour) => formatter.format(new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), hour, 0, 0)));
314
+ const parts = [];
315
+ for (const range of utcRanges) {
316
+ if (!Array.isArray(range) || range.length !== 2) return null;
317
+ const start = Number(range[0]);
318
+ const end = Number(range[1]);
319
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > 24 || start >= end) return null;
320
+ parts.push(`${clock(start)}–${clock(end)}`);
321
+ }
322
+ const joined = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];
323
+ return `${joined} in your time zone`;
324
+ }
325
+
326
+ /** The note's text with its UTC hours replaced by the reader's local ones, when it names any. */
327
+ function noteTextForReader(note, { now, timeZone } = {}) {
210
328
  const text = String(note?.text ?? '').trim();
329
+ const utcText = String(note?.hours?.utcText ?? '').trim();
330
+ if (!text || !utcText || !text.includes(utcText)) return text;
331
+ const local = formatLocalHourRanges(note.hours.utcRanges, { now, timeZone });
332
+ return local ? text.split(utcText).join(local) : text;
333
+ }
334
+
335
+ /**
336
+ * A note that is a figure (buildCardNote's label and value): one row of the
337
+ * card's figures, the hint under the label, the whole sentence as its title.
338
+ */
339
+ function renderFact(note) {
340
+ const hint = String(note?.hint ?? '').trim();
341
+ return `<div class="plan-usage-fact" data-note-id="${escapeHtml(note?.id ?? '')}" title="${escapeHtml(String(note?.text ?? '').trim())}">`
342
+ + `<dt>${escapeHtml(note.label)}${hint ? `<span class="plan-usage-fact-hint">${escapeHtml(hint)}</span>` : ''}</dt>`
343
+ + `<dd>${escapeHtml(note.value)}</dd></div>`;
344
+ }
345
+
346
+ const isFactNote = (note) => Boolean(String(note?.label ?? '').trim() && String(note?.value ?? '').trim());
347
+
348
+ function renderNote(note, { now, timeZone } = {}) {
349
+ const text = noteTextForReader(note, { now, timeZone });
211
350
  if (!text) return '';
212
351
  const url = String(note?.link?.url ?? '').trim();
213
352
  const label = String(note?.link?.label ?? '').trim();
@@ -215,7 +354,11 @@ function renderNote(note) {
215
354
  const link = label && /^https:\/\//i.test(url)
216
355
  ? ` <a class="plan-usage-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
217
356
  : '';
218
- return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}">${escapeHtml(text)}${link}</div>`;
357
+ // The UTC wording stays on the element for a reader who wants it.
358
+ const utcTitle = note?.hours?.utcText && text !== String(note.text ?? '').trim()
359
+ ? ` title="${escapeHtml(String(note.text).trim())}"`
360
+ : '';
361
+ return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}"${utcTitle}>${escapeHtml(text)}${link}</div>`;
219
362
  }
220
363
 
221
364
  /**
@@ -240,10 +383,18 @@ function renderAccountLine(account) {
240
383
  return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
241
384
  }
242
385
 
243
- function renderCard(card, now, { active = true } = {}) {
386
+ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
244
387
  const meters = Array.isArray(card.meters) ? card.meters : [];
388
+ const charts = Array.isArray(card.charts) ? card.charts.filter(Boolean) : [];
245
389
  const details = Array.isArray(card.details) ? card.details : [];
246
- const notes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
390
+ const allNotes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
391
+ // Figures go in one list under the bars; sentences stay notes, and on a
392
+ // card with figures they come last, after the tables, so the numbers read
393
+ // first.
394
+ const facts = allNotes.filter(isFactNote);
395
+ const notes = allNotes.filter((note) => !isFactNote(note));
396
+ const notesHtml = notes.map((note) => renderNote(note, { now, timeZone })).join('');
397
+ const detailsHtml = details.map(renderDetailSection).join('');
247
398
  const links = Array.isArray(card.links) ? card.links : [];
248
399
 
249
400
  const statusBadge = STATUS_BADGES[card.status];
@@ -281,8 +432,9 @@ function renderCard(card, now, { active = true } = {}) {
281
432
  </header>
282
433
  ${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
283
434
  ${metersHtml}
284
- ${notes.map(renderNote).join('')}
285
- ${details.map(renderDetailSection).join('')}
435
+ ${facts.length ? `<dl class="plan-usage-facts">${facts.map(renderFact).join('')}</dl>` : ''}
436
+ ${charts.map(renderChart).join('')}
437
+ ${facts.length ? `${detailsHtml}${notesHtml}` : `${notesHtml}${detailsHtml}`}
286
438
  <div class="plan-usage-card-foot">
287
439
  ${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
288
440
  ${links.map((link) => `<a class="plan-usage-link" href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.label)}</a>`).join('')}
@@ -306,13 +458,13 @@ function renderTab(card, activeProvider) {
306
458
  * tab when that provider has a card
307
459
  * @returns {string} modal body HTML, or '' when there is nothing to render
308
460
  */
309
- export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '' } = {}) {
461
+ export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '', timeZone = undefined } = {}) {
310
462
  const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
311
463
  if (!providers.length) return '';
312
464
  const active = resolveActiveUsageProvider(providers, activeProvider);
313
465
  const tabs = providers.map((card) => renderTab(card, active)).join('');
314
466
  const cards = providers
315
- .map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active }))
467
+ .map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active, timeZone }))
316
468
  .join('');
317
469
  return `<div class="plan-usage"><div class="plan-usage-tab-strip" role="tablist" aria-label="Usage providers">${tabs}</div>${cards}</div>`;
318
470
  }
@@ -1,7 +1,7 @@
1
1
  // Which providers are known to be ready to run a chat, from what the client
2
2
  // already loads: the provider settings payloads and the model catalog. No DOM.
3
3
  //
4
- // OpenAI, Claude, Claude Cloud, Cursor and Grok say so themselves: their
4
+ // OpenAI, Claude, Claude Cloud, Cursor, Grok and DeepSeek say so themselves: their
5
5
  // settings carry `enabled`. Copilot has no such flag and the relay never asks
6
6
  // whether the Copilot CLI is signed in, so the best signal is indirect: has a
7
7
  // real Copilot model list ever reached the catalog? A fresh database is seeded
@@ -23,6 +23,7 @@ const SETTINGS_PROVIDERS = Object.freeze([
23
23
  ['claudeCloud', 'claude-cloud'],
24
24
  ['cursor', 'cursor'],
25
25
  ['grok', 'grok'],
26
+ ['deepseek', 'deepseek'],
26
27
  ]);
27
28
 
28
29
  /** Payloads whose absence means "could not be loaded" rather than "not offered". */
@@ -35,6 +36,7 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
35
36
  * @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
36
37
  * @param {object|null} [input.cursor]
37
38
  * @param {object|null} [input.grok]
39
+ * @param {object|null} [input.deepseek] null on a relay without DeepSeek: not offered, not unknown
38
40
  * @param {object|null} [input.catalog]
39
41
  * @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown' }}
40
42
  */
@@ -69,6 +71,7 @@ const inputs = {
69
71
  claudeCloud: undefined,
70
72
  cursor: undefined,
71
73
  grok: undefined,
74
+ deepseek: undefined,
72
75
  catalog: undefined,
73
76
  };
74
77
  let current = resolveProviderReadiness(inputs);
@@ -36,6 +36,14 @@ const RELAY_ERROR_CTAS = Object.freeze({
36
36
  'claude-account-on-hold': CLAUDE_SETTINGS_CTA,
37
37
  'claude-verification-required': CLAUDE_SETTINGS_CTA,
38
38
  'claude-account-access': CLAUDE_SETTINGS_CTA,
39
+ // A DeepSeek turn refused for its key or its balance (deepseek-claude-backend.mjs).
40
+ 'deepseek-api-key': Object.freeze([
41
+ Object.freeze({ action: 'open-deepseek-settings', label: 'DeepSeek settings' }),
42
+ ]),
43
+ 'deepseek-balance': Object.freeze([
44
+ Object.freeze({ action: 'open-deepseek-top-up', label: 'Top up DeepSeek' }),
45
+ Object.freeze({ action: 'open-deepseek-settings', label: 'DeepSeek settings' }),
46
+ ]),
39
47
  });
40
48
 
41
49
  const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;
@@ -55,6 +55,16 @@ import { refreshPushSettingsSection } from './push-settings.js';
55
55
  import { selectSettingsTab } from './settings-tabs.js';
56
56
  import { refreshClaudeAuthSection } from './claude-auth-ui.js';
57
57
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
58
+ import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
59
+
60
+ export {
61
+ applyDeepSeekSettingsState,
62
+ importDeepSeekUsageExport,
63
+ refreshDeepSeekSettingsState,
64
+ removeDeepSeekSettings,
65
+ saveDeepSeekSettings,
66
+ toggleDeepSeekProvider,
67
+ } from './deepseek-settings-ui.js';
58
68
  import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
59
69
  import { refreshCliInstallSections } from './cli-install-ui.js';
60
70
  import { refreshUpdateSection } from './update-ui.js';
@@ -1949,6 +1959,7 @@ export function openSettingsModal(tab, providerTab) {
1949
1959
  cursorSettingsInputsDirty = false;
1950
1960
  ensureCursorSettingsInputTracking();
1951
1961
  void syncCursorSettingsInputs();
1962
+ void syncDeepSeekSettingsInputs();
1952
1963
  void refreshCursorAllowanceState();
1953
1964
  void refreshCursorDashboardTokenState();
1954
1965
  void refreshGrokAllowanceState();
@@ -4,7 +4,7 @@ const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
4
4
  const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
- const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
7
+ const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor', 'deepseek'];
8
8
 
9
9
  let settingsTabsBound = false;
10
10
 
@@ -153,6 +153,7 @@ let deps = null;
153
153
  * @property {(payload?: object) => void} applyOpenAISettingsState
154
154
  * @property {(payload?: object) => void} applyClaudeSettingsState
155
155
  * @property {(payload?: object) => void} applyGrokSettingsState
156
+ * @property {(payload?: object) => void} applyDeepSeekSettingsState
156
157
  * @property {(payload?: object) => void} applyCursorSettingsState
157
158
  */
158
159
 
@@ -497,6 +498,15 @@ export async function connectSocket(overrideDeps) {
497
498
  .catch(() => {});
498
499
  }
499
500
  });
501
+ socket.on('deepseek_settings_updated', (payload) => {
502
+ const state = deps?.applyDeepSeekSettingsState?.(payload || {});
503
+ updateProviderReadinessInputs({ deepseek: state || payload || null });
504
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
505
+ void Promise.resolve()
506
+ .then(() => deps?.refreshCurrentView?.())
507
+ .catch(() => {});
508
+ }
509
+ });
500
510
  socket.on('cursor_settings_updated', (payload) => {
501
511
  const state = deps?.applyCursorSettingsState?.(payload || {});
502
512
  updateProviderReadinessInputs({ cursor: state || payload || null });