@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
@@ -46,6 +46,14 @@ import { stopSessionWorkerProcesses } from '../services/session-worker-stop-serv
46
46
  import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
47
47
  import { createCliInstallService } from '../services/cli-install-service.mjs';
48
48
  import { createGrokAuthService } from '../services/grok-auth-service.mjs';
49
+ import { parseDeepSeekSettingsUpdateRequest } from '../services/deepseek-settings-service.mjs';
50
+ import {
51
+ DEEPSEEK_PROVIDER_TYPE,
52
+ DEEPSEEK_REASONING_EFFORTS,
53
+ DEFAULT_DEEPSEEK_MODEL,
54
+ DEFAULT_DEEPSEEK_REASONING_EFFORT,
55
+ deepseekReasoningEffortFor,
56
+ } from '../../shared/deepseek-models.mjs';
49
57
  import {
50
58
  isWithinAllowedPrefix,
51
59
  readWorkspaceRootPathFromBody,
@@ -133,6 +141,10 @@ import {
133
141
  derivePwaShortName,
134
142
  } from '../../shared/pwa-app-name.mjs';
135
143
  import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
144
+ import { removeClaudeSessionLeftovers } from '../services/claude-session-leftovers.mjs';
145
+ import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs';
146
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
147
+ import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
136
148
 
137
149
  export { mapUsageSnapshotRow };
138
150
 
@@ -794,6 +806,55 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
794
806
  };
795
807
  }
796
808
 
809
+ export function buildModelCatalogWithDeepSeekProvider(modelState = {}, deepseekSettings = {}) {
810
+ const configured = deepseekSettings?.enabled === true;
811
+ const model = String(deepseekSettings?.model || '').trim();
812
+ if (!configured || !model) return { ...modelState };
813
+ const deepseekModels = [];
814
+ const seenDeepSeekModels = new Set();
815
+ const listed = Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [];
816
+ for (const candidate of [model, ...listed]) {
817
+ const modelId = String(candidate || '').trim();
818
+ const lowerId = modelId.toLowerCase();
819
+ if (!modelId || seenDeepSeekModels.has(lowerId)) continue;
820
+ seenDeepSeekModels.add(lowerId);
821
+ deepseekModels.push(modelId);
822
+ }
823
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...deepseekModels]));
824
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
825
+ const deepseekReasoningByModel = {};
826
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
827
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
828
+ // DeepSeek's ids are its own ("deepseek-*"): no other provider lists them,
829
+ // so the provider tag is never shared with Copilot.
830
+ for (const deepseekModel of deepseekModels) {
831
+ const lowerKey = deepseekModel.toLowerCase();
832
+ deepseekReasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
833
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
834
+ reasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
835
+ }
836
+ const existingProviders = Array.isArray(providersByModel[deepseekModel])
837
+ ? providersByModel[deepseekModel]
838
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
839
+ providersByModel[deepseekModel] = Array.from(new Set([...existingProviders, DEEPSEEK_PROVIDER_TYPE]));
840
+ modelMetadataByModel[deepseekModel] = {
841
+ ...(modelMetadataByModel[deepseekModel] || {}),
842
+ provider: modelMetadataByModel[deepseekModel]?.provider || DEEPSEEK_PROVIDER_TYPE,
843
+ };
844
+ }
845
+ return {
846
+ ...modelState,
847
+ models,
848
+ reasoningByModel,
849
+ reasoningByProvider: {
850
+ ...(modelState?.reasoningByProvider || {}),
851
+ [DEEPSEEK_PROVIDER_TYPE]: deepseekReasoningByModel,
852
+ },
853
+ modelMetadataByModel,
854
+ providersByModel,
855
+ };
856
+ }
857
+
797
858
  function normalizeRequestedProviderType(value = '') {
798
859
  const normalized = String(value || '').trim().toLowerCase();
799
860
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -803,6 +864,7 @@ function normalizeRequestedProviderType(value = '') {
803
864
  if (normalized === 'claude-cloud') return 'claude-cloud';
804
865
  if (normalized === 'cursor') return 'cursor';
805
866
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
867
+ if (normalized === 'deepseek') return 'deepseek';
806
868
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
807
869
  return '';
808
870
  }
@@ -1421,6 +1483,8 @@ export function buildConversationSessionRootPayload({
1421
1483
  claudeNativeSessionId = '',
1422
1484
  workspaceRootPath = '',
1423
1485
  resolveClaudeSessionRoot = null,
1486
+ // The same Claude Code layout, in the DeepSeek workers' own config dir.
1487
+ resolveDeepSeekSessionRoot = null,
1424
1488
  resolveCursorSessionRoot = null,
1425
1489
  } = {}) {
1426
1490
  const sid = String(sdkSessionId || '').trim() || String(conversationId || '').trim();
@@ -1443,10 +1507,12 @@ export function buildConversationSessionRootPayload({
1443
1507
  // Claude sessions have no ~/.copilot/session-state entry — only the Copilot
1444
1508
  // CLI creates those — so they resolve against the Agent SDK's own layout and
1445
1509
  // never fall through to the branch below.
1446
- if (String(providerType || '').trim().toLowerCase() === 'claude') {
1510
+ const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
1511
+ if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek') {
1447
1512
  const nativeSessionId = String(claudeNativeSessionId || '').trim();
1448
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return null;
1449
- const claudeRoot = resolveClaudeSessionRoot({
1513
+ const resolveRoot = claudeCodeProvider === 'deepseek' ? resolveDeepSeekSessionRoot : resolveClaudeSessionRoot;
1514
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return null;
1515
+ const claudeRoot = resolveRoot({
1450
1516
  claudeNativeSessionId: nativeSessionId,
1451
1517
  workspaceRootPath: String(workspaceRootPath || '').trim(),
1452
1518
  });
@@ -2068,6 +2134,10 @@ export function registerSessionsRoutes(app, deps) {
2068
2134
  // is simply off.
2069
2135
  claudeCloudSettingsService = null,
2070
2136
  claudeCloudSessionService = null,
2137
+ // DeepSeek (provider `deepseek`): its key, models, base URL and
2138
+ // attribution (services/deepseek-settings-service.mjs). Absent in older
2139
+ // wiring and most tests, where the provider is simply off.
2140
+ deepseekSettingsService = null,
2071
2141
  reconcileUnstartedConversationProviders = async () => ({
2072
2142
  updatedUnstartedConversations: 0,
2073
2143
  skippedStartedConversations: 0,
@@ -2108,6 +2178,13 @@ export function registerSessionsRoutes(app, deps) {
2108
2178
  // nothing while Claude Cloud is off, and absent (tests, older wiring) the
2109
2179
  // Claude card stays the last turn's snapshot.
2110
2180
  claudeAccountUsageService = null,
2181
+ // Optional DeepSeek balance (deepseek-balance-service.mjs); it reads
2182
+ // nothing while DeepSeek is off, and absent (tests, older wiring) the
2183
+ // DeepSeek card shows the relay's estimated spend alone.
2184
+ deepseekBalanceService = null,
2185
+ // Optional DeepSeek usage export (deepseek-usage-import.mjs): its figures
2186
+ // replace the estimate on the days it covers; absent, the card is the estimate.
2187
+ deepseekUsageImportService = null,
2111
2188
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
2112
2189
  setCursorPlanAllowanceSettings = () => ({ ok: false, error: 'Cursor allowance settings are unavailable' }),
2113
2190
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
@@ -2139,6 +2216,10 @@ export function registerSessionsRoutes(app, deps) {
2139
2216
  sessionWorkerStopOverrides = null,
2140
2217
  resolveSessionStateRoot,
2141
2218
  resolveClaudeSessionRoot = null,
2219
+ resolveDeepSeekSessionRoot = null,
2220
+ // DeepSeek's Claude Code config directory, which the relay owns: a deleted
2221
+ // conversation's leftovers there go with it (claude-session-leftovers.mjs).
2222
+ deepseekClaudeConfigDir = '',
2142
2223
  resolveCursorSessionRoot = null,
2143
2224
  getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
2144
2225
  setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
@@ -2356,6 +2437,9 @@ export function registerSessionsRoutes(app, deps) {
2356
2437
  FROM message_usage_snapshots
2357
2438
  WHERE conversation_id = ?
2358
2439
  `);
2440
+ // Asked on each delete: the usage service creates the table at its own
2441
+ // start, which may come after these routes'.
2442
+ const findDeepSeekUsageCheckpointsTable = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`);
2359
2443
  const hardDeleteConversationRows = db.transaction((conversationId) => {
2360
2444
  if (typeof stmts.deleteConvQuestions?.run === 'function') {
2361
2445
  stmts.deleteConvQuestions.run(conversationId);
@@ -2385,6 +2469,11 @@ export function registerSessionsRoutes(app, deps) {
2385
2469
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
2386
2470
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
2387
2471
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
2472
+ // A DeepSeek conversation's usage checkpoints are only good for diffing its
2473
+ // later reports; the totals and days they fed stay, as history.
2474
+ if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2475
+ db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2476
+ }
2388
2477
  // The share access log goes with it; the share rows follow the foreign key.
2389
2478
  shares().forgetConversation(conversationId);
2390
2479
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -2410,17 +2499,25 @@ export function registerSessionsRoutes(app, deps) {
2410
2499
  const sessionLaunchMutex = createKeyedMutex();
2411
2500
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2412
2501
 
2502
+ function getDeepSeekProviderSettings() {
2503
+ return deepseekSettingsService?.getSettings?.()
2504
+ || { configured: false, enabled: false, model: DEFAULT_DEEPSEEK_MODEL, models: [], availableModels: [] };
2505
+ }
2506
+
2413
2507
  // Layer every enabled provider's models onto the base Copilot catalog.
2414
2508
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2415
- return buildModelCatalogWithGrokProvider(
2416
- buildModelCatalogWithCursorProvider(
2417
- buildModelCatalogWithClaudeProvider(
2418
- buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2419
- getClaudeProviderSettings(),
2509
+ return buildModelCatalogWithDeepSeekProvider(
2510
+ buildModelCatalogWithGrokProvider(
2511
+ buildModelCatalogWithCursorProvider(
2512
+ buildModelCatalogWithClaudeProvider(
2513
+ buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2514
+ getClaudeProviderSettings(),
2515
+ ),
2516
+ getCursorProviderSettings(),
2420
2517
  ),
2421
- getCursorProviderSettings(),
2518
+ getGrokProviderSettings(),
2422
2519
  ),
2423
- getGrokProviderSettings(),
2520
+ getDeepSeekProviderSettings(),
2424
2521
  );
2425
2522
  }
2426
2523
 
@@ -2536,10 +2633,10 @@ export function registerSessionsRoutes(app, deps) {
2536
2633
  * it. Nothing else in the project folder is touched — it holds every other
2537
2634
  * session of that workspace and the project memory.
2538
2635
  */
2539
- function deleteClaudeCliSession({ conversation, runtimeSession }) {
2636
+ function deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot = resolveClaudeSessionRoot }) {
2540
2637
  const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2541
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
2542
- const anchors = resolveClaudeSessionRoot({
2638
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return { deleted: null };
2639
+ const anchors = resolveRoot({
2543
2640
  claudeNativeSessionId: nativeSessionId,
2544
2641
  workspaceRootPath: String(
2545
2642
  conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
@@ -2553,7 +2650,25 @@ export function registerSessionsRoutes(app, deps) {
2553
2650
  .filter((target) => path.dirname(target) === projectDir
2554
2651
  && [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
2555
2652
  for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
2556
- return { deleted: targets.length > 0 };
2653
+ return { deleted: targets.length > 0, projectDirPath: projectDir };
2654
+ }
2655
+
2656
+ /**
2657
+ * A DeepSeek conversation's Claude Code session: the transcript as for
2658
+ * Claude, then what the killed CLI left in the relay's own config directory
2659
+ * (its hook environment, registration, shell snapshot, and the project
2660
+ * folder once no session is left in it).
2661
+ */
2662
+ function deleteDeepSeekCliSession({ conversation, runtimeSession }) {
2663
+ const outcome = deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot: resolveDeepSeekSessionRoot });
2664
+ const configRoot = String(deepseekClaudeConfigDir || '').trim();
2665
+ const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2666
+ if (!configRoot || !nativeSessionId) return { deleted: outcome.deleted };
2667
+ const workspaceRoot = String(conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '').trim();
2668
+ const projectDirPath = outcome.projectDirPath
2669
+ || (workspaceRoot ? path.join(configRoot, 'projects', claudeProjectDirSlug(workspaceRoot)) : '');
2670
+ const removed = removeClaudeSessionLeftovers({ configRoot, nativeSessionId, projectDirPath });
2671
+ return { deleted: outcome.deleted === null && removed.length ? true : outcome.deleted };
2557
2672
  }
2558
2673
 
2559
2674
  /**
@@ -2594,6 +2709,7 @@ export function registerSessionsRoutes(app, deps) {
2594
2709
  const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2595
2710
  try {
2596
2711
  if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2712
+ if (provider === 'deepseek') return deleteDeepSeekCliSession({ conversation, runtimeSession });
2597
2713
  if (!sdkSessionId) return { deleted: null };
2598
2714
  if (!provider || provider === 'github' || provider === 'openai') {
2599
2715
  return await deleteCopilotCliSession({ sdkSessionId, conversationId });
@@ -3527,9 +3643,10 @@ export function registerSessionsRoutes(app, deps) {
3527
3643
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3528
3644
  // The cursor and grok workers post the same Claude-shaped usage blob, and
3529
3645
  // so does the Claude Cloud worker (the cloud session's context and token
3530
- // usage), so all four providers share the stored reader.
3646
+ // usage), and so does the DeepSeek worker (the Claude one, pointed at
3647
+ // DeepSeek), so all five providers share the stored reader.
3531
3648
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3532
- || providerType === 'grok' || providerType === 'claude-cloud';
3649
+ || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek';
3533
3650
 
3534
3651
  const parsed = usesStoredContextUsage
3535
3652
  ? (() => {
@@ -3558,11 +3675,12 @@ export function registerSessionsRoutes(app, deps) {
3558
3675
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3559
3676
  );
3560
3677
  const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
3561
- // Only the local Claude worker runs a CLI whose window the slider sets;
3562
- // it reports that CLI on its heartbeat's steering snapshot.
3678
+ // Only the local Claude worker (and the DeepSeek one, the same runner)
3679
+ // runs a CLI whose window the slider sets; it reports that CLI on its
3680
+ // heartbeat's steering snapshot.
3563
3681
  const windowStatus = resolveAutoCompactWindowStatus({
3564
3682
  storedWindow: autoCompactWindow,
3565
- steering: providerType === 'claude' && copilotSessionId
3683
+ steering: (providerType === 'claude' || providerType === 'deepseek') && copilotSessionId
3566
3684
  ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3567
3685
  : null,
3568
3686
  });
@@ -3813,6 +3931,7 @@ export function registerSessionsRoutes(app, deps) {
3813
3931
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
3814
3932
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
3815
3933
  resolveClaudeSessionRoot,
3934
+ resolveDeepSeekSessionRoot,
3816
3935
  resolveCursorSessionRoot,
3817
3936
  });
3818
3937
  const dbMessages = stmts.getMessages.all(conversationId);
@@ -4486,6 +4605,7 @@ export function registerSessionsRoutes(app, deps) {
4486
4605
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
4487
4606
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
4488
4607
  resolveClaudeSessionRoot,
4608
+ resolveDeepSeekSessionRoot,
4489
4609
  resolveCursorSessionRoot,
4490
4610
  });
4491
4611
  const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
@@ -6186,6 +6306,12 @@ export function registerSessionsRoutes(app, deps) {
6186
6306
  ...(Array.isArray(grokSettings?.models) ? grokSettings.models : []),
6187
6307
  ].map((value) => String(value || '').trim()).filter(Boolean);
6188
6308
  const availableGrokModels = new Set(grokModelCatalog.map((value) => value.toLowerCase()));
6309
+ const deepseekSettings = getDeepSeekProviderSettings();
6310
+ const deepseekModelCatalog = [
6311
+ String(deepseekSettings?.model || '').trim(),
6312
+ ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : []),
6313
+ ].map((value) => String(value || '').trim()).filter(Boolean);
6314
+ const availableDeepSeekModels = new Set(deepseekModelCatalog.map((value) => value.toLowerCase()));
6189
6315
  const useOpenAIProvider = requestedProviderType === 'openai'
6190
6316
  || requestedProviderType === 'openai-image'
6191
6317
  || (
@@ -6221,6 +6347,15 @@ export function registerSessionsRoutes(app, deps) {
6221
6347
  && availableGrokModels.has(requestedBootstrapModel.toLowerCase())
6222
6348
  )
6223
6349
  );
6350
+ const useDeepSeekProvider = !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && (
6351
+ requestedProviderType === 'deepseek'
6352
+ || (
6353
+ requestedProviderType === ''
6354
+ && requestedBootstrapModel
6355
+ && requestedBootstrapModel.toLowerCase() !== 'auto'
6356
+ && availableDeepSeekModels.has(requestedBootstrapModel.toLowerCase())
6357
+ )
6358
+ );
6224
6359
  // Managed providers resolve the request against their own catalog only.
6225
6360
  // Routing it through the merged Copilot catalog first (resolveBootstrapModelSelection)
6226
6361
  // silently substituted that catalog's current model whenever the requested
@@ -6266,7 +6401,13 @@ export function registerSessionsRoutes(app, deps) {
6266
6401
  configuredModel: grokSettings.model,
6267
6402
  availableModels: grokModelCatalog,
6268
6403
  })
6269
- : null)));
6404
+ : (useDeepSeekProvider
6405
+ ? resolveProviderModelSelection({
6406
+ requestedModel: providerRequestedModel(deepseekModelCatalog),
6407
+ configuredModel: deepseekSettings.model,
6408
+ availableModels: deepseekModelCatalog,
6409
+ })
6410
+ : null))));
6270
6411
  // The single rejection for "this provider does not offer that model".
6271
6412
  // Per-provider pre-checks used to duplicate it and had drifted apart in
6272
6413
  // wording, casing rules and whether they listed the alternatives.
@@ -6275,14 +6416,16 @@ export function registerSessionsRoutes(app, deps) {
6275
6416
  ? 'Claude Cloud'
6276
6417
  : (useOpenAIProvider
6277
6418
  ? 'OpenAI'
6278
- : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok')));
6419
+ : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : (useGrokProvider ? 'Grok' : 'DeepSeek'))));
6279
6420
  const providerCode = useClaudeCloudProvider
6280
6421
  ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
6281
6422
  : (useOpenAIProvider
6282
6423
  ? 'OPENAI_MODEL_UNAVAILABLE'
6283
6424
  : (useClaudeProvider
6284
6425
  ? 'CLAUDE_MODEL_UNAVAILABLE'
6285
- : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE')));
6426
+ : (useCursorProvider
6427
+ ? 'CURSOR_MODEL_UNAVAILABLE'
6428
+ : (useGrokProvider ? 'GROK_MODEL_UNAVAILABLE' : 'DEEPSEEK_MODEL_UNAVAILABLE'))));
6286
6429
  return res.status(400).json({
6287
6430
  ok: false,
6288
6431
  error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
@@ -6302,6 +6445,7 @@ export function registerSessionsRoutes(app, deps) {
6302
6445
  if (
6303
6446
  remoteRequest.remote
6304
6447
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6448
+ && !useDeepSeekProvider
6305
6449
  ) {
6306
6450
  const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
6307
6451
  const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
@@ -6367,6 +6511,15 @@ export function registerSessionsRoutes(app, deps) {
6367
6511
  code: 'GROK_NOT_CONFIGURED',
6368
6512
  });
6369
6513
  }
6514
+ if (useDeepSeekProvider && deepseekSettings?.enabled !== true) {
6515
+ return res.status(400).json({
6516
+ ok: false,
6517
+ error: deepseekSettings?.configured === true
6518
+ ? 'DeepSeek is switched off for new conversations'
6519
+ : 'DeepSeek API key is not configured',
6520
+ code: 'DEEPSEEK_NOT_CONFIGURED',
6521
+ });
6522
+ }
6370
6523
 
6371
6524
  // The session group an agent's create_session names (an id or an exact
6372
6525
  // name): resolved before anything is written, so an unknown one creates
@@ -6421,7 +6574,7 @@ export function registerSessionsRoutes(app, deps) {
6421
6574
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
6422
6575
  });
6423
6576
  }
6424
- if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
6577
+ if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && !useDeepSeekProvider) {
6425
6578
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
6426
6579
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
6427
6580
  : [];
@@ -6461,6 +6614,15 @@ export function registerSessionsRoutes(app, deps) {
6461
6614
  code: 'GROK_PROVIDER_REQUIRED',
6462
6615
  });
6463
6616
  }
6617
+ const deepseekOnlySelection = selectedProviders.length > 0
6618
+ && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === DEEPSEEK_PROVIDER_TYPE);
6619
+ if (deepseekOnlySelection) {
6620
+ return res.status(400).json({
6621
+ ok: false,
6622
+ error: `Model "${selectedModel}" requires the DeepSeek provider`,
6623
+ code: 'DEEPSEEK_PROVIDER_REQUIRED',
6624
+ });
6625
+ }
6464
6626
  }
6465
6627
 
6466
6628
  const bootstrapProviderType = useClaudeCloudProvider
@@ -6471,7 +6633,7 @@ export function registerSessionsRoutes(app, deps) {
6471
6633
  ? 'claude'
6472
6634
  : (useCursorProvider
6473
6635
  ? 'cursor'
6474
- : (useGrokProvider ? 'grok' : 'github'))));
6636
+ : (useGrokProvider ? 'grok' : (useDeepSeekProvider ? 'deepseek' : 'github')))));
6475
6637
  // Preferences are written in the same transaction as the conversation, so
6476
6638
  // the caller's current mode has to arrive with the request: without it every
6477
6639
  // new chat would be stored as the default one and reset the composer. An
@@ -6487,6 +6649,9 @@ export function registerSessionsRoutes(app, deps) {
6487
6649
  const bootstrapReasoning = resolveProviderReasoningEffort({
6488
6650
  requestedEffort: req.body?.reasoningEffort || copilotVariantEffort,
6489
6651
  strict: bootstrapProviderType === 'openai',
6652
+ ...(bootstrapProviderType === 'deepseek'
6653
+ ? { defaultEffort: DEFAULT_DEEPSEEK_REASONING_EFFORT, aliasFor: deepseekReasoningEffortFor }
6654
+ : {}),
6490
6655
  supportedEfforts: supportedReasoningEffortsForProviderModel({
6491
6656
  providerType: bootstrapProviderType,
6492
6657
  model: selectedModel,
@@ -6536,7 +6701,7 @@ export function registerSessionsRoutes(app, deps) {
6536
6701
  {
6537
6702
  assignConfiguredProvider: true,
6538
6703
  providerType: bootstrapProviderType,
6539
- providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
6704
+ providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider || useDeepSeekProvider) ? selectedModel : null,
6540
6705
  },
6541
6706
  );
6542
6707
  // What the cloud session will clone, stored with the conversation so
@@ -7511,6 +7676,40 @@ export function registerSessionsRoutes(app, deps) {
7511
7676
  });
7512
7677
  });
7513
7678
 
7679
+ // A rotated API key only reaches a worker's environment at spawn: live
7680
+ // workers of the provider keep the old key and fail each turn after the
7681
+ // rotation. Stops them; the primer or the next delivery respawns them with
7682
+ // the fresh key from settings.
7683
+ async function stopLiveProviderWorkersForKeyRotation(providerType) {
7684
+ const rows = db.prepare(`
7685
+ SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
7686
+ FROM runtime_sessions
7687
+ WHERE LOWER(COALESCE(provider_type, '')) = ?
7688
+ AND NULLIF(sdk_session_id, '') IS NOT NULL
7689
+ `).all(providerType);
7690
+ for (const row of rows) {
7691
+ const sid = String(row.sdk_session_id || '').trim();
7692
+ if (!sid) continue;
7693
+ const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7694
+ if (!workerState) continue;
7695
+ try {
7696
+ const stopped = await stopIdleWorkspaceRootSession({
7697
+ sdkSessionId: sid,
7698
+ worker: sessionWorkerRegistry?.getWorker?.(sid) || null,
7699
+ sessionWorkerSupervisor,
7700
+ sessionWorkerRegistry,
7701
+ sessionWorkerProcessInspector,
7702
+ });
7703
+ if (stopped.ok) {
7704
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
7705
+ console.log(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stopped for API key rotation`);
7706
+ }
7707
+ } catch (error) {
7708
+ console.warn(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stop failed on key rotation: ${error?.message || error}`);
7709
+ }
7710
+ }
7711
+ }
7712
+
7514
7713
  app.get('/api/settings/cursor', auth, (_req, res) => {
7515
7714
  const settings = getCursorProviderSettings();
7516
7715
  return res.json({
@@ -7542,33 +7741,7 @@ export function registerSessionsRoutes(app, deps) {
7542
7741
  // fail each turn after the rotation. Stop them now; the primer or the
7543
7742
  // next delivery respawns them with the fresh key from settings.
7544
7743
  if (parsed.apiKey && previous?.configured === true) {
7545
- const cursorSessionRows = db.prepare(`
7546
- SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
7547
- FROM runtime_sessions
7548
- WHERE LOWER(COALESCE(provider_type, '')) = 'cursor'
7549
- AND NULLIF(sdk_session_id, '') IS NOT NULL
7550
- `).all();
7551
- for (const row of cursorSessionRows) {
7552
- const sid = String(row.sdk_session_id || '').trim();
7553
- if (!sid) continue;
7554
- const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7555
- if (!workerState) continue;
7556
- try {
7557
- const stopped = await stopIdleWorkspaceRootSession({
7558
- sdkSessionId: sid,
7559
- worker: sessionWorkerRegistry?.getWorker?.(sid) || null,
7560
- sessionWorkerSupervisor,
7561
- sessionWorkerRegistry,
7562
- sessionWorkerProcessInspector,
7563
- });
7564
- if (stopped.ok) {
7565
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
7566
- console.log(`[sessions] cursor worker ${sid.slice(0, 8)} stopped for API key rotation`);
7567
- }
7568
- } catch (error) {
7569
- console.warn(`[sessions] cursor worker ${sid.slice(0, 8)} stop failed on key rotation: ${error?.message || error}`);
7570
- }
7571
- }
7744
+ await stopLiveProviderWorkersForKeyRotation('cursor');
7572
7745
  }
7573
7746
  const shouldDiscover = result.enabled === true && (
7574
7747
  !!parsed.apiKey
@@ -7626,6 +7799,96 @@ export function registerSessionsRoutes(app, deps) {
7626
7799
  });
7627
7800
  });
7628
7801
 
7802
+ // --- DeepSeek ----------------------------------------------------------
7803
+ // The key is written here and never read back: GET and every answer carry
7804
+ // `configured` only. A rotated key reaches a worker only at spawn, so the
7805
+ // live DeepSeek workers are stopped; the next message starts them again.
7806
+ // What the tab shows: the stored settings, and whether the starting
7807
+ // balance still applies (a top-up since it was set replaces it), judged
7808
+ // on the currency the card's bar uses: the last balance read's main one.
7809
+ function describeDeepSeekSettings() {
7810
+ const described = deepseekSettingsService.describe();
7811
+ let references = [];
7812
+ try {
7813
+ references = planUsageService?.readDeepSeekBalanceReferences?.() || [];
7814
+ } catch {}
7815
+ let balances = null;
7816
+ try {
7817
+ balances = deepseekBalanceService?.peekBalance?.()?.balances || null;
7818
+ } catch {}
7819
+ const startingBalanceNote = deepseekStartingBalanceNote(
7820
+ described.startingBalance ? { amount: described.startingBalance, setAt: described.startingBalanceSetAt } : null,
7821
+ references,
7822
+ { balances },
7823
+ );
7824
+ return { ...described, startingBalanceNote };
7825
+ }
7826
+
7827
+ app.get('/api/settings/deepseek', auth, (_req, res) => {
7828
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
7829
+ return res.json(describeDeepSeekSettings());
7830
+ });
7831
+
7832
+ app.post('/api/settings/deepseek', auth, async (req, res) => {
7833
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
7834
+ const parsed = parseDeepSeekSettingsUpdateRequest(req.body);
7835
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7836
+ // A starting balance a top-up has replaced is set anew when it is saved
7837
+ // again, the same amount too.
7838
+ const before = describeDeepSeekSettings();
7839
+ const startingBalanceReplaced = !!before.startingBalanceNote;
7840
+ const result = deepseekSettingsService.update(parsed, { startingBalanceReplaced });
7841
+ if (!result?.ok) {
7842
+ return res.status(result?.code === 'deepseek-key-removal-blocked' ? 409 : 400).json({
7843
+ error: result?.error || 'Failed to update DeepSeek settings',
7844
+ code: result?.code || null,
7845
+ activeConversationCount: Number(result?.activeConversationCount || 0),
7846
+ startedConversationCount: Number(result?.startedConversationCount || 0),
7847
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
7848
+ });
7849
+ }
7850
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(DEEPSEEK_PROVIDER_TYPE);
7851
+ const discovery = result.discover
7852
+ ? await deepseekSettingsService.refreshModels()
7853
+ : { ok: true, models: [], error: null };
7854
+ // A starting balance set anew: the balance is read right then and noted
7855
+ // as the latest read, so a later top-up is a rise from a balance read
7856
+ // after the setting, and one made before it is already in this read
7857
+ // (deepseekStartingBalanceState). A failed read changes nothing: the
7858
+ // setting then stays until a rise seen after a read that followed it.
7859
+ const setAt = result.settings?.startingBalanceSetAt || null;
7860
+ if (setAt && setAt !== before.startingBalanceSetAt) {
7861
+ try {
7862
+ const balance = await deepseekBalanceService?.getBalance?.({ force: true });
7863
+ planUsageService?.recordDeepSeekBalance?.(balance);
7864
+ } catch {}
7865
+ }
7866
+ // Removing the key moves unstarted DeepSeek conversations back to the
7867
+ // default provider, as for OpenAI and Cursor.
7868
+ const reconciliationResult = parsed.remove === true
7869
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: DEFAULT_DEEPSEEK_MODEL, provider: DEEPSEEK_PROVIDER_TYPE })
7870
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
7871
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
7872
+ const settingsPayload = {
7873
+ ...describeDeepSeekSettings(),
7874
+ reconciliation: {
7875
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
7876
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
7877
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
7878
+ failedConversations,
7879
+ },
7880
+ };
7881
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7882
+ io.emit('deepseek_settings_updated', settingsPayload);
7883
+ return res.json({
7884
+ ok: true,
7885
+ ...settingsPayload,
7886
+ warning: failedConversations.length
7887
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
7888
+ : (discovery?.ok ? null : (discovery?.error || 'DeepSeek model discovery failed')),
7889
+ });
7890
+ });
7891
+
7629
7892
  app.get('/api/settings/turn-ceiling', auth, (_req, res) => {
7630
7893
  res.json({
7631
7894
  ceilingMinutes: getTurnCeilingMinutes(),
@@ -8193,6 +8456,7 @@ export function registerSessionsRoutes(app, deps) {
8193
8456
  const claudeSettings = getClaudeProviderSettings();
8194
8457
  const cursorSettings = getCursorProviderSettings();
8195
8458
  const grokSettings = getGrokProviderSettings();
8459
+ const deepseekSettings = getDeepSeekProviderSettings();
8196
8460
  return {
8197
8461
  ...buildModelVariantCatalogPayload({
8198
8462
  rows,
@@ -8222,6 +8486,13 @@ export function registerSessionsRoutes(app, deps) {
8222
8486
  enabledModels: Array.isArray(grokSettings.models) ? grokSettings.models : [],
8223
8487
  }
8224
8488
  : null,
8489
+ deepseekModels: deepseekSettings?.enabled === true
8490
+ ? {
8491
+ defaultModel: deepseekSettings.model,
8492
+ availableModels: Array.isArray(deepseekSettings.availableModels) ? deepseekSettings.availableModels : [],
8493
+ enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
8494
+ }
8495
+ : null,
8225
8496
  };
8226
8497
  }
8227
8498
 
@@ -8240,6 +8511,7 @@ export function registerSessionsRoutes(app, deps) {
8240
8511
  const claudeSettings = getClaudeProviderSettings();
8241
8512
  const cursorSettings = getCursorProviderSettings();
8242
8513
  const grokSettings = getGrokProviderSettings();
8514
+ const deepseekSettings = getDeepSeekProviderSettings();
8243
8515
  const refreshTasks = [
8244
8516
  refreshModelVariantCatalogFromCli(),
8245
8517
  openAISettings?.enabled === true
@@ -8260,8 +8532,11 @@ export function registerSessionsRoutes(app, deps) {
8260
8532
  typeof copilotModelDiscoveryService?.refresh === 'function'
8261
8533
  ? copilotModelDiscoveryService.refresh('manual-refresh')
8262
8534
  : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8535
+ deepseekSettings?.enabled === true && deepseekSettingsService
8536
+ ? deepseekSettingsService.refreshModels()
8537
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8263
8538
  ];
8264
- const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh] = await Promise.allSettled(refreshTasks);
8539
+ const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh] = await Promise.allSettled(refreshTasks);
8265
8540
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
8266
8541
  const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
8267
8542
  ? openAIRefresh.value
@@ -8291,6 +8566,13 @@ export function registerSessionsRoutes(app, deps) {
8291
8566
  models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
8292
8567
  error: grokRefresh.reason?.message || 'Grok model discovery failed',
8293
8568
  };
8569
+ const deepseekModelDiscovery = deepseekRefresh.status === 'fulfilled'
8570
+ ? deepseekRefresh.value
8571
+ : {
8572
+ ok: false,
8573
+ models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
8574
+ error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
8575
+ };
8294
8576
  const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
8295
8577
  ? copilotDiscoveryRefresh.value
8296
8578
  : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
@@ -8304,6 +8586,7 @@ export function registerSessionsRoutes(app, deps) {
8304
8586
  claudeModelDiscovery,
8305
8587
  cursorModelDiscovery,
8306
8588
  grokModelDiscovery,
8589
+ deepseekModelDiscovery,
8307
8590
  copilotModelDiscovery,
8308
8591
  });
8309
8592
  } catch (error) {
@@ -8399,8 +8682,9 @@ export function registerSessionsRoutes(app, deps) {
8399
8682
  copilotBilling: null, // { value, at }
8400
8683
  grokBilling: null,
8401
8684
  cursorBilling: null,
8685
+ deepseekBalance: null,
8402
8686
  };
8403
- const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok'];
8687
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek'];
8404
8688
 
8405
8689
  function parseUsageProviders(raw) {
8406
8690
  const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
@@ -8455,6 +8739,16 @@ export function registerSessionsRoutes(app, deps) {
8455
8739
  const claudeSettings = getClaudeProviderSettings();
8456
8740
  const cursorSettings = getCursorProviderSettings();
8457
8741
  const grokSettings = getGrokProviderSettings();
8742
+ const deepseekSettings = getDeepSeekProviderSettings();
8743
+ const deepseekEnabled = deepseekSettings?.enabled === true;
8744
+ // An unreadable export is no reason to lose the card: the estimate stays.
8745
+ const readDeepSeekActuals = () => {
8746
+ try {
8747
+ return deepseekUsageImportService?.readActuals?.() ?? null;
8748
+ } catch {
8749
+ return null;
8750
+ }
8751
+ };
8458
8752
  let claudeCloudEnabled = false;
8459
8753
  try {
8460
8754
  claudeCloudEnabled = claudeCloudSettingsService?.getSettings?.()?.enabled === true;
@@ -8476,7 +8770,7 @@ export function registerSessionsRoutes(app, deps) {
8476
8770
  : Promise.resolve(null);
8477
8771
 
8478
8772
  // All reads side by side: the modal waits for the slowest, not the sum.
8479
- const [copilot, billingEntry, grokEntry, cursorEntry, claudeAccount] = await Promise.all([
8773
+ const [copilot, billingEntry, grokEntry, cursorEntry, deepseekEntry, claudeAccount] = await Promise.all([
8480
8774
  readCopilotSummary(),
8481
8775
  cachedOrLive('copilotBilling', live('github'), () => (
8482
8776
  typeof fetchCopilotBillingUsage === 'function'
@@ -8489,6 +8783,12 @@ export function registerSessionsRoutes(app, deps) {
8489
8783
  cachedOrLive('cursorBilling', live('cursor'), () => (
8490
8784
  cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function' ? fetchCursorDashboardUsage() : null
8491
8785
  )),
8786
+ // The service keeps its own minute of cache and answers a failure as
8787
+ // `{ ok: false, … }`, so a failed read replaces the earlier answer here
8788
+ // (which it carries as `previous`).
8789
+ cachedOrLive('deepseekBalance', live('deepseek'), () => (
8790
+ deepseekEnabled && typeof deepseekBalanceService?.getBalance === 'function' ? deepseekBalanceService.getBalance() : null
8791
+ )),
8492
8792
  claudeAccountPromise,
8493
8793
  ]);
8494
8794
 
@@ -8521,6 +8821,14 @@ export function registerSessionsRoutes(app, deps) {
8521
8821
  grokConfigured: grokSettings?.enabled === true,
8522
8822
  grokAllowances: getGrokPlanAllowanceSettings(),
8523
8823
  grokBilling: grokEntry.value,
8824
+ deepseekConfigured: deepseekEnabled,
8825
+ deepseekBalance: deepseekEnabled ? deepseekEntry.value : null,
8826
+ deepseekMonthlyBudget: deepseekEnabled ? (deepseekSettings?.monthlyBudget ?? null) : null,
8827
+ deepseekMonthlyBudgetCurrency: deepseekEnabled ? (deepseekSettings?.monthlyBudgetCurrency ?? null) : null,
8828
+ deepseekStartingBalance: deepseekEnabled && deepseekSettings?.startingBalance
8829
+ ? { amount: deepseekSettings.startingBalance, setAt: deepseekSettings.startingBalanceSetAt }
8830
+ : null,
8831
+ deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
8524
8832
  });
8525
8833
 
8526
8834
  // Legacy top-level fields stay alongside `providers` so an older cached
@@ -8535,6 +8843,7 @@ export function registerSessionsRoutes(app, deps) {
8535
8843
  'claude-cloud': claudeAccount?.fetchedAt || null,
8536
8844
  cursor: cursorEntry.at,
8537
8845
  grok: grokEntry.at,
8846
+ deepseek: deepseekEntry.value?.fetchedAt || deepseekEntry.at,
8538
8847
  },
8539
8848
  });
8540
8849
  });