@oar-sh/oar 0.9.16 → 0.9.18

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 (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -46,6 +46,19 @@ 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
+ CLAUDE_API_PROVIDER_LABEL,
52
+ CLAUDE_API_PROVIDER_TYPE,
53
+ parseClaudeApiSettingsUpdateRequest,
54
+ } from '../services/claude-api-settings-service.mjs';
55
+ import {
56
+ DEEPSEEK_PROVIDER_TYPE,
57
+ DEEPSEEK_REASONING_EFFORTS,
58
+ DEFAULT_DEEPSEEK_MODEL,
59
+ DEFAULT_DEEPSEEK_REASONING_EFFORT,
60
+ deepseekReasoningEffortFor,
61
+ } from '../../shared/deepseek-models.mjs';
49
62
  import {
50
63
  isWithinAllowedPrefix,
51
64
  readWorkspaceRootPathFromBody,
@@ -133,6 +146,10 @@ import {
133
146
  derivePwaShortName,
134
147
  } from '../../shared/pwa-app-name.mjs';
135
148
  import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
149
+ import { removeClaudeSessionLeftovers } from '../services/claude-session-leftovers.mjs';
150
+ import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs';
151
+ import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
152
+ import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
136
153
 
137
154
  export { mapUsageSnapshotRow };
138
155
 
@@ -794,6 +811,131 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
794
811
  };
795
812
  }
796
813
 
814
+ export function buildModelCatalogWithDeepSeekProvider(modelState = {}, deepseekSettings = {}) {
815
+ const configured = deepseekSettings?.enabled === true;
816
+ const model = String(deepseekSettings?.model || '').trim();
817
+ if (!configured || !model) return { ...modelState };
818
+ const deepseekModels = [];
819
+ const seenDeepSeekModels = new Set();
820
+ const listed = Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [];
821
+ for (const candidate of [model, ...listed]) {
822
+ const modelId = String(candidate || '').trim();
823
+ const lowerId = modelId.toLowerCase();
824
+ if (!modelId || seenDeepSeekModels.has(lowerId)) continue;
825
+ seenDeepSeekModels.add(lowerId);
826
+ deepseekModels.push(modelId);
827
+ }
828
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...deepseekModels]));
829
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
830
+ const deepseekReasoningByModel = {};
831
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
832
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
833
+ // DeepSeek's ids are its own ("deepseek-*"): no other provider lists them,
834
+ // so the provider tag is never shared with Copilot.
835
+ for (const deepseekModel of deepseekModels) {
836
+ const lowerKey = deepseekModel.toLowerCase();
837
+ deepseekReasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
838
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
839
+ reasoningByModel[lowerKey] = [...DEEPSEEK_REASONING_EFFORTS];
840
+ }
841
+ const existingProviders = Array.isArray(providersByModel[deepseekModel])
842
+ ? providersByModel[deepseekModel]
843
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
844
+ providersByModel[deepseekModel] = Array.from(new Set([...existingProviders, DEEPSEEK_PROVIDER_TYPE]));
845
+ modelMetadataByModel[deepseekModel] = {
846
+ ...(modelMetadataByModel[deepseekModel] || {}),
847
+ provider: modelMetadataByModel[deepseekModel]?.provider || DEEPSEEK_PROVIDER_TYPE,
848
+ };
849
+ }
850
+ return {
851
+ ...modelState,
852
+ models,
853
+ reasoningByModel,
854
+ reasoningByProvider: {
855
+ ...(modelState?.reasoningByProvider || {}),
856
+ [DEEPSEEK_PROVIDER_TYPE]: deepseekReasoningByModel,
857
+ },
858
+ modelMetadataByModel,
859
+ providersByModel,
860
+ };
861
+ }
862
+
863
+ /**
864
+ * Claude API (provider `claude-api`) serves the Claude provider's model ids
865
+ * on an API key. Its layer tags them `claude-api` beside whatever else serves
866
+ * them and keeps its own efforts and context tiers: `[1m]` variants fold into
867
+ * their base model as a long_context tier, as on the Claude layer. An id is
868
+ * never enough to pick this provider; only an explicit `claude-api` does.
869
+ */
870
+ export function buildModelCatalogWithClaudeApiProvider(modelState = {}, claudeApiSettings = {}) {
871
+ const configured = claudeApiSettings?.enabled === true;
872
+ const model = String(claudeApiSettings?.model || '').trim();
873
+ if (!configured || !model) return { ...modelState };
874
+ const listed = Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [];
875
+ const longContextBases = new Set();
876
+ const defaultContextBases = new Set();
877
+ const baseModels = [];
878
+ const seenBases = new Set();
879
+ for (const candidate of [model, ...listed]) {
880
+ const modelId = String(candidate || '').trim();
881
+ if (!modelId) continue;
882
+ const isLongContextVariant = CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN.test(modelId);
883
+ const baseId = modelId.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '');
884
+ if (!baseId) continue;
885
+ if (isLongContextVariant) longContextBases.add(baseId.toLowerCase());
886
+ else defaultContextBases.add(baseId.toLowerCase());
887
+ if (seenBases.has(baseId.toLowerCase())) continue;
888
+ seenBases.add(baseId.toLowerCase());
889
+ baseModels.push(baseId);
890
+ }
891
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...baseModels]));
892
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
893
+ const claudeApiReasoningByModel = {};
894
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
895
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
896
+ const claudeApiContextTiersByModel = {};
897
+ const effortsByModel = claudeApiSettings?.effortsByModel && typeof claudeApiSettings.effortsByModel === 'object'
898
+ ? claudeApiSettings.effortsByModel
899
+ : {};
900
+ for (const baseId of baseModels) {
901
+ const lowerKey = baseId.toLowerCase();
902
+ claudeApiContextTiersByModel[lowerKey] = [
903
+ ...(defaultContextBases.has(lowerKey) ? [{ value: 'default' }] : []),
904
+ ...(longContextBases.has(lowerKey) ? [{ value: 'long_context' }] : []),
905
+ ];
906
+ const efforts = Array.isArray(effortsByModel[lowerKey]) && effortsByModel[lowerKey].length
907
+ ? effortsByModel[lowerKey]
908
+ : effortsByModel[`${lowerKey}[1m]`];
909
+ const claudeApiEfforts = Array.isArray(efforts) && efforts.length
910
+ ? withClaudeUltracodeTier(efforts)
911
+ : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
912
+ claudeApiReasoningByModel[lowerKey] = [...claudeApiEfforts];
913
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
914
+ reasoningByModel[lowerKey] = [...claudeApiEfforts];
915
+ }
916
+ const existingProviders = Array.isArray(providersByModel[baseId])
917
+ ? providersByModel[baseId]
918
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
919
+ providersByModel[baseId] = Array.from(new Set([...existingProviders, CLAUDE_API_PROVIDER_TYPE]));
920
+ modelMetadataByModel[baseId] = {
921
+ ...(modelMetadataByModel[baseId] || {}),
922
+ provider: modelMetadataByModel[baseId]?.provider || CLAUDE_API_PROVIDER_TYPE,
923
+ };
924
+ }
925
+ return {
926
+ ...modelState,
927
+ models,
928
+ reasoningByModel,
929
+ reasoningByProvider: {
930
+ ...(modelState?.reasoningByProvider || {}),
931
+ [CLAUDE_API_PROVIDER_TYPE]: claudeApiReasoningByModel,
932
+ },
933
+ modelMetadataByModel,
934
+ providersByModel,
935
+ claudeApiContextTiersByModel,
936
+ };
937
+ }
938
+
797
939
  function normalizeRequestedProviderType(value = '') {
798
940
  const normalized = String(value || '').trim().toLowerCase();
799
941
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -803,6 +945,9 @@ function normalizeRequestedProviderType(value = '') {
803
945
  if (normalized === 'claude-cloud') return 'claude-cloud';
804
946
  if (normalized === 'cursor') return 'cursor';
805
947
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
948
+ if (normalized === 'deepseek') return 'deepseek';
949
+ // Its own provider, never read as `claude`.
950
+ if (normalized === 'claude-api') return 'claude-api';
806
951
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
807
952
  return '';
808
953
  }
@@ -1421,6 +1566,8 @@ export function buildConversationSessionRootPayload({
1421
1566
  claudeNativeSessionId = '',
1422
1567
  workspaceRootPath = '',
1423
1568
  resolveClaudeSessionRoot = null,
1569
+ // The same Claude Code layout, in the DeepSeek workers' own config dir.
1570
+ resolveDeepSeekSessionRoot = null,
1424
1571
  resolveCursorSessionRoot = null,
1425
1572
  } = {}) {
1426
1573
  const sid = String(sdkSessionId || '').trim() || String(conversationId || '').trim();
@@ -1442,11 +1589,14 @@ export function buildConversationSessionRootPayload({
1442
1589
  }
1443
1590
  // Claude sessions have no ~/.copilot/session-state entry — only the Copilot
1444
1591
  // CLI creates those — so they resolve against the Agent SDK's own layout and
1445
- // never fall through to the branch below.
1446
- if (String(providerType || '').trim().toLowerCase() === 'claude') {
1592
+ // never fall through to the branch below. A Claude API session is a Claude
1593
+ // session on an API key, in the same place.
1594
+ const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
1595
+ if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek' || claudeCodeProvider === 'claude-api') {
1447
1596
  const nativeSessionId = String(claudeNativeSessionId || '').trim();
1448
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return null;
1449
- const claudeRoot = resolveClaudeSessionRoot({
1597
+ const resolveRoot = claudeCodeProvider === 'deepseek' ? resolveDeepSeekSessionRoot : resolveClaudeSessionRoot;
1598
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return null;
1599
+ const claudeRoot = resolveRoot({
1450
1600
  claudeNativeSessionId: nativeSessionId,
1451
1601
  workspaceRootPath: String(workspaceRootPath || '').trim(),
1452
1602
  });
@@ -2068,6 +2218,24 @@ export function registerSessionsRoutes(app, deps) {
2068
2218
  // is simply off.
2069
2219
  claudeCloudSettingsService = null,
2070
2220
  claudeCloudSessionService = null,
2221
+ // DeepSeek (provider `deepseek`): its key, models, base URL and
2222
+ // attribution (services/deepseek-settings-service.mjs). Absent in older
2223
+ // wiring and most tests, where the provider is simply off.
2224
+ deepseekSettingsService = null,
2225
+ // Claude API (provider `claude-api`): its key, models and monthly cap
2226
+ // (services/claude-api-settings-service.mjs). Absent in older wiring and
2227
+ // most tests, where the provider is simply off.
2228
+ claudeApiSettingsService = null,
2229
+ // The Claude API card and monthly cap (services/plan-usage-claude-api.mjs),
2230
+ // and the cap's one seam (`{ reached, error? }`; a throw counts as
2231
+ // reached). Absent in older wiring and most tests: no card, no cap.
2232
+ claudeApiPlanUsage = null,
2233
+ getClaudeApiCapState = () => ({ reached: false }),
2234
+ // Asks whether the cap flipped after the Claude API settings changed.
2235
+ checkClaudeApiCap = () => {},
2236
+ // The once-per-episode notes for stopped self-started turns on the key
2237
+ // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2238
+ claudeApiStopLedger = null,
2071
2239
  reconcileUnstartedConversationProviders = async () => ({
2072
2240
  updatedUnstartedConversations: 0,
2073
2241
  skippedStartedConversations: 0,
@@ -2108,6 +2276,13 @@ export function registerSessionsRoutes(app, deps) {
2108
2276
  // nothing while Claude Cloud is off, and absent (tests, older wiring) the
2109
2277
  // Claude card stays the last turn's snapshot.
2110
2278
  claudeAccountUsageService = null,
2279
+ // Optional DeepSeek balance (deepseek-balance-service.mjs); it reads
2280
+ // nothing while DeepSeek is off, and absent (tests, older wiring) the
2281
+ // DeepSeek card shows the relay's estimated spend alone.
2282
+ deepseekBalanceService = null,
2283
+ // Optional DeepSeek usage export (deepseek-usage-import.mjs): its figures
2284
+ // replace the estimate on the days it covers; absent, the card is the estimate.
2285
+ deepseekUsageImportService = null,
2111
2286
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
2112
2287
  setCursorPlanAllowanceSettings = () => ({ ok: false, error: 'Cursor allowance settings are unavailable' }),
2113
2288
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
@@ -2139,6 +2314,10 @@ export function registerSessionsRoutes(app, deps) {
2139
2314
  sessionWorkerStopOverrides = null,
2140
2315
  resolveSessionStateRoot,
2141
2316
  resolveClaudeSessionRoot = null,
2317
+ resolveDeepSeekSessionRoot = null,
2318
+ // DeepSeek's Claude Code config directory, which the relay owns: a deleted
2319
+ // conversation's leftovers there go with it (claude-session-leftovers.mjs).
2320
+ deepseekClaudeConfigDir = '',
2142
2321
  resolveCursorSessionRoot = null,
2143
2322
  getTurnCeilingMinutes = () => DEFAULT_TURN_CEILING_MINUTES,
2144
2323
  setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
@@ -2167,6 +2346,7 @@ export function registerSessionsRoutes(app, deps) {
2167
2346
  hostSuspendService = null,
2168
2347
  collectHostActivity = null,
2169
2348
  usageLimitPauseService = null,
2349
+ claudeBillingSwitchService = null,
2170
2350
  hostSuspendPlatform = process.platform,
2171
2351
  // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2172
2352
  // conversations another relay's agent created, and the inbound switch.
@@ -2356,6 +2536,9 @@ export function registerSessionsRoutes(app, deps) {
2356
2536
  FROM message_usage_snapshots
2357
2537
  WHERE conversation_id = ?
2358
2538
  `);
2539
+ // Asked on each delete: the usage service creates the table at its own
2540
+ // start, which may come after these routes'.
2541
+ const findDeepSeekUsageCheckpointsTable = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`);
2359
2542
  const hardDeleteConversationRows = db.transaction((conversationId) => {
2360
2543
  if (typeof stmts.deleteConvQuestions?.run === 'function') {
2361
2544
  stmts.deleteConvQuestions.run(conversationId);
@@ -2385,6 +2568,11 @@ export function registerSessionsRoutes(app, deps) {
2385
2568
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
2386
2569
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
2387
2570
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
2571
+ // A DeepSeek conversation's usage checkpoints are only good for diffing its
2572
+ // later reports; the totals and days they fed stay, as history.
2573
+ if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2574
+ db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2575
+ }
2388
2576
  // The share access log goes with it; the share rows follow the foreign key.
2389
2577
  shares().forgetConversation(conversationId);
2390
2578
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -2410,17 +2598,33 @@ export function registerSessionsRoutes(app, deps) {
2410
2598
  const sessionLaunchMutex = createKeyedMutex();
2411
2599
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2412
2600
 
2601
+ function getDeepSeekProviderSettings() {
2602
+ return deepseekSettingsService?.getSettings?.()
2603
+ || { configured: false, enabled: false, model: DEFAULT_DEEPSEEK_MODEL, models: [], availableModels: [] };
2604
+ }
2605
+
2606
+ function getClaudeApiProviderSettings() {
2607
+ return claudeApiSettingsService?.getSettings?.()
2608
+ || { configured: false, enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} };
2609
+ }
2610
+
2413
2611
  // Layer every enabled provider's models onto the base Copilot catalog.
2414
2612
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2415
- return buildModelCatalogWithGrokProvider(
2416
- buildModelCatalogWithCursorProvider(
2417
- buildModelCatalogWithClaudeProvider(
2418
- buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2419
- getClaudeProviderSettings(),
2613
+ return buildModelCatalogWithClaudeApiProvider(
2614
+ buildModelCatalogWithDeepSeekProvider(
2615
+ buildModelCatalogWithGrokProvider(
2616
+ buildModelCatalogWithCursorProvider(
2617
+ buildModelCatalogWithClaudeProvider(
2618
+ buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2619
+ getClaudeProviderSettings(),
2620
+ ),
2621
+ getCursorProviderSettings(),
2622
+ ),
2623
+ getGrokProviderSettings(),
2420
2624
  ),
2421
- getCursorProviderSettings(),
2625
+ getDeepSeekProviderSettings(),
2422
2626
  ),
2423
- getGrokProviderSettings(),
2627
+ getClaudeApiProviderSettings(),
2424
2628
  );
2425
2629
  }
2426
2630
 
@@ -2536,10 +2740,10 @@ export function registerSessionsRoutes(app, deps) {
2536
2740
  * it. Nothing else in the project folder is touched — it holds every other
2537
2741
  * session of that workspace and the project memory.
2538
2742
  */
2539
- function deleteClaudeCliSession({ conversation, runtimeSession }) {
2743
+ function deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot = resolveClaudeSessionRoot }) {
2540
2744
  const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2541
- if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
2542
- const anchors = resolveClaudeSessionRoot({
2745
+ if (!nativeSessionId || typeof resolveRoot !== 'function') return { deleted: null };
2746
+ const anchors = resolveRoot({
2543
2747
  claudeNativeSessionId: nativeSessionId,
2544
2748
  workspaceRootPath: String(
2545
2749
  conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
@@ -2553,7 +2757,25 @@ export function registerSessionsRoutes(app, deps) {
2553
2757
  .filter((target) => path.dirname(target) === projectDir
2554
2758
  && [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
2555
2759
  for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
2556
- return { deleted: targets.length > 0 };
2760
+ return { deleted: targets.length > 0, projectDirPath: projectDir };
2761
+ }
2762
+
2763
+ /**
2764
+ * A DeepSeek conversation's Claude Code session: the transcript as for
2765
+ * Claude, then what the killed CLI left in the relay's own config directory
2766
+ * (its hook environment, registration, shell snapshot, and the project
2767
+ * folder once no session is left in it).
2768
+ */
2769
+ function deleteDeepSeekCliSession({ conversation, runtimeSession }) {
2770
+ const outcome = deleteClaudeCliSession({ conversation, runtimeSession, resolveRoot: resolveDeepSeekSessionRoot });
2771
+ const configRoot = String(deepseekClaudeConfigDir || '').trim();
2772
+ const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2773
+ if (!configRoot || !nativeSessionId) return { deleted: outcome.deleted };
2774
+ const workspaceRoot = String(conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '').trim();
2775
+ const projectDirPath = outcome.projectDirPath
2776
+ || (workspaceRoot ? path.join(configRoot, 'projects', claudeProjectDirSlug(workspaceRoot)) : '');
2777
+ const removed = removeClaudeSessionLeftovers({ configRoot, nativeSessionId, projectDirPath });
2778
+ return { deleted: outcome.deleted === null && removed.length ? true : outcome.deleted };
2557
2779
  }
2558
2780
 
2559
2781
  /**
@@ -2593,7 +2815,8 @@ export function registerSessionsRoutes(app, deps) {
2593
2815
  async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
2594
2816
  const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2595
2817
  try {
2596
- if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2818
+ if (provider === 'claude' || provider === 'claude-api') return deleteClaudeCliSession({ conversation, runtimeSession });
2819
+ if (provider === 'deepseek') return deleteDeepSeekCliSession({ conversation, runtimeSession });
2597
2820
  if (!sdkSessionId) return { deleted: null };
2598
2821
  if (!provider || provider === 'github' || provider === 'openai') {
2599
2822
  return await deleteCopilotCliSession({ sdkSessionId, conversationId });
@@ -2666,7 +2889,7 @@ export function registerSessionsRoutes(app, deps) {
2666
2889
  /**
2667
2890
  * The `group` field of a single conversation payload: the summary and the
2668
2891
  * conversation's role. The live members, with what each is doing, only
2669
- * when asked for (`groupMembers=1`, the remote_relay tool's read_session)
2892
+ * when asked for (`groupMembers=1`, the relay_tool's read_session)
2670
2893
  * and never for a paired relay's agent: the browser does not read them,
2671
2894
  * and archived members' titles stay on this relay.
2672
2895
  */
@@ -3527,9 +3750,12 @@ export function registerSessionsRoutes(app, deps) {
3527
3750
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3528
3751
  // The cursor and grok workers post the same Claude-shaped usage blob, and
3529
3752
  // so does the Claude Cloud worker (the cloud session's context and token
3530
- // usage), so all four providers share the stored reader.
3753
+ // usage), and so does the DeepSeek worker (the Claude one, pointed at
3754
+ // DeepSeek), and so does the Claude API one (the Claude worker on an API
3755
+ // key), so all six providers share the stored reader.
3531
3756
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3532
- || providerType === 'grok' || providerType === 'claude-cloud';
3757
+ || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek'
3758
+ || providerType === 'claude-api';
3533
3759
 
3534
3760
  const parsed = usesStoredContextUsage
3535
3761
  ? (() => {
@@ -3558,11 +3784,12 @@ export function registerSessionsRoutes(app, deps) {
3558
3784
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3559
3785
  );
3560
3786
  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.
3787
+ // Only the local Claude worker (and the DeepSeek and Claude API ones, the
3788
+ // same runner) runs a CLI whose window the slider sets; it reports that
3789
+ // CLI on its heartbeat's steering snapshot.
3563
3790
  const windowStatus = resolveAutoCompactWindowStatus({
3564
3791
  storedWindow: autoCompactWindow,
3565
- steering: providerType === 'claude' && copilotSessionId
3792
+ steering: (providerType === 'claude' || providerType === 'deepseek' || providerType === 'claude-api') && copilotSessionId
3566
3793
  ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3567
3794
  : null,
3568
3795
  });
@@ -3582,7 +3809,7 @@ export function registerSessionsRoutes(app, deps) {
3582
3809
  // What this folder's Claude commits say (the 🧠 modal's override row).
3583
3810
  // A Claude Cloud chat has the row when it was started from a folder,
3584
3811
  // with its own model in the example.
3585
- ...(providerType === 'claude'
3812
+ ...(providerType === 'claude' || providerType === 'claude-api'
3586
3813
  ? { attribution: workspaceRootAttributionFor(conversationRow) }
3587
3814
  : {}),
3588
3815
  ...(providerType === 'claude-cloud' && cloudConversationFolder(conversationRow)
@@ -3813,6 +4040,7 @@ export function registerSessionsRoutes(app, deps) {
3813
4040
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
3814
4041
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
3815
4042
  resolveClaudeSessionRoot,
4043
+ resolveDeepSeekSessionRoot,
3816
4044
  resolveCursorSessionRoot,
3817
4045
  });
3818
4046
  const dbMessages = stmts.getMessages.all(conversationId);
@@ -4486,6 +4714,7 @@ export function registerSessionsRoutes(app, deps) {
4486
4714
  claudeNativeSessionId: runtimeSession?.claude_native_session_id || '',
4487
4715
  workspaceRootPath: workspaceState?.currentWorkspaceRootPath || '',
4488
4716
  resolveClaudeSessionRoot,
4717
+ resolveDeepSeekSessionRoot,
4489
4718
  resolveCursorSessionRoot,
4490
4719
  });
4491
4720
  const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
@@ -4608,6 +4837,9 @@ export function registerSessionsRoutes(app, deps) {
4608
4837
  try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
4609
4838
  })(),
4610
4839
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4840
+ // A Claude chat's billing (subscription or API key) and whether the
4841
+ // composer's toggle may switch it now, with the reason when not.
4842
+ billing: claudeBillingSwitchService?.billingState?.(resolvedConversationId) || null,
4611
4843
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4612
4844
  // Every pin of the conversation, also those outside this history page.
4613
4845
  pins: listConversationPins(resolvedConversationId),
@@ -4894,7 +5126,7 @@ export function registerSessionsRoutes(app, deps) {
4894
5126
  return res.json({ ok: true, ...persisted });
4895
5127
  });
4896
5128
 
4897
- // ─── PATCH /api/conversation/:id/settings — the remote_relay tool's update_session ───
5129
+ // ─── PATCH /api/conversation/:id/settings — the relay_tool's update_session ───
4898
5130
  //
4899
5131
  // `{ settings: { model, effort, mode, auto_compact_window, title, group,
4900
5132
  // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
@@ -5015,7 +5247,7 @@ export function registerSessionsRoutes(app, deps) {
5015
5247
  plan.preferences.thinkingDisplay = wanted.thinking_display;
5016
5248
  change('thinking_display');
5017
5249
  }
5018
- if (provider !== 'claude' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5250
+ if (provider !== 'claude' && provider !== 'claude-api' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5019
5251
  plan.notes.push(`auto_compact_window and thinking apply to Claude sessions only; this one runs on ${provider}, so they are stored but change nothing there.`);
5020
5252
  }
5021
5253
  if (wanted.title !== undefined) {
@@ -6147,6 +6379,35 @@ export function registerSessionsRoutes(app, deps) {
6147
6379
  environmentId: cloudEnvironmentId,
6148
6380
  });
6149
6381
  }
6382
+ // Claude API is asked for by name only, like Claude Cloud: its model ids
6383
+ // are the Claude provider's own, so a model never selects it. Refused
6384
+ // first while it has no key or is switched off.
6385
+ const useClaudeApiProvider = requestedProviderType === CLAUDE_API_PROVIDER_TYPE;
6386
+ const claudeApiSettings = useClaudeApiProvider ? getClaudeApiProviderSettings() : null;
6387
+ if (useClaudeApiProvider && claudeApiSettings?.enabled !== true) {
6388
+ return res.status(400).json({
6389
+ ok: false,
6390
+ error: claudeApiSettings?.configured === true
6391
+ ? 'Claude API is switched off for new conversations.'
6392
+ : 'Claude API key is not configured.',
6393
+ code: 'CLAUDE_API_NOT_CONFIGURED',
6394
+ });
6395
+ }
6396
+ // This month's cap (plan decision 12): no new Claude API chat while it is
6397
+ // reached, for New Chat and an agent's create_session alike. A cap that
6398
+ // cannot be read counts as reached.
6399
+ if (useClaudeApiProvider) {
6400
+ let cap = null;
6401
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
6402
+ if (cap?.reached === true) {
6403
+ return res.status(409).json({
6404
+ ok: false,
6405
+ // The remedy names the place, so the reason does not.
6406
+ error: `${String(cap.reason || cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} No Claude API chat was started; start it on the Claude subscription, or raise the cap in Settings → Providers → Claude API.`,
6407
+ code: 'CLAUDE_API_CAP_REACHED',
6408
+ });
6409
+ }
6410
+ }
6150
6411
  const modelState = buildModelCatalogWithProviders(
6151
6412
  getModelCatalogState(),
6152
6413
  openAISettings,
@@ -6186,6 +6447,19 @@ export function registerSessionsRoutes(app, deps) {
6186
6447
  ...(Array.isArray(grokSettings?.models) ? grokSettings.models : []),
6187
6448
  ].map((value) => String(value || '').trim()).filter(Boolean);
6188
6449
  const availableGrokModels = new Set(grokModelCatalog.map((value) => value.toLowerCase()));
6450
+ const deepseekSettings = getDeepSeekProviderSettings();
6451
+ const deepseekModelCatalog = [
6452
+ String(deepseekSettings?.model || '').trim(),
6453
+ ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : []),
6454
+ ].map((value) => String(value || '').trim()).filter(Boolean);
6455
+ const availableDeepSeekModels = new Set(deepseekModelCatalog.map((value) => value.toLowerCase()));
6456
+ // Base ids of "[1m]" variants count too, as for the Claude provider.
6457
+ const availableClaudeApiModels = new Set((useClaudeApiProvider ? [
6458
+ String(claudeApiSettings?.model || '').trim(),
6459
+ ...(Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : []),
6460
+ ] : []).map((value) => String(value || '').trim()).filter(Boolean)
6461
+ .flatMap((value) => [value, value.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '')])
6462
+ .filter(Boolean));
6189
6463
  const useOpenAIProvider = requestedProviderType === 'openai'
6190
6464
  || requestedProviderType === 'openai-image'
6191
6465
  || (
@@ -6221,6 +6495,15 @@ export function registerSessionsRoutes(app, deps) {
6221
6495
  && availableGrokModels.has(requestedBootstrapModel.toLowerCase())
6222
6496
  )
6223
6497
  );
6498
+ const useDeepSeekProvider = !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && (
6499
+ requestedProviderType === 'deepseek'
6500
+ || (
6501
+ requestedProviderType === ''
6502
+ && requestedBootstrapModel
6503
+ && requestedBootstrapModel.toLowerCase() !== 'auto'
6504
+ && availableDeepSeekModels.has(requestedBootstrapModel.toLowerCase())
6505
+ )
6506
+ );
6224
6507
  // Managed providers resolve the request against their own catalog only.
6225
6508
  // Routing it through the merged Copilot catalog first (resolveBootstrapModelSelection)
6226
6509
  // silently substituted that catalog's current model whenever the requested
@@ -6241,6 +6524,12 @@ export function registerSessionsRoutes(app, deps) {
6241
6524
  configuredModel: claudeCloudSettings.defaultModel,
6242
6525
  availableModels: Array.isArray(claudeCloudSettings.models) ? claudeCloudSettings.models : [],
6243
6526
  })
6527
+ : useClaudeApiProvider
6528
+ ? resolveProviderModelSelection({
6529
+ requestedModel: providerRequestedModel(availableClaudeApiModels),
6530
+ configuredModel: claudeApiSettings.model,
6531
+ availableModels: availableClaudeApiModels,
6532
+ })
6244
6533
  : useOpenAIProvider
6245
6534
  ? resolveProviderModelSelection({
6246
6535
  requestedModel: providerRequestedModel(availableOpenAIModels),
@@ -6266,23 +6555,35 @@ export function registerSessionsRoutes(app, deps) {
6266
6555
  configuredModel: grokSettings.model,
6267
6556
  availableModels: grokModelCatalog,
6268
6557
  })
6269
- : null)));
6558
+ : (useDeepSeekProvider
6559
+ ? resolveProviderModelSelection({
6560
+ requestedModel: providerRequestedModel(deepseekModelCatalog),
6561
+ configuredModel: deepseekSettings.model,
6562
+ availableModels: deepseekModelCatalog,
6563
+ })
6564
+ : null))));
6270
6565
  // The single rejection for "this provider does not offer that model".
6271
6566
  // Per-provider pre-checks used to duplicate it and had drifted apart in
6272
6567
  // wording, casing rules and whether they listed the alternatives.
6273
6568
  if (providerModelSelection && !providerModelSelection.ok) {
6274
6569
  const providerLabel = useClaudeCloudProvider
6275
6570
  ? 'Claude Cloud'
6571
+ : useClaudeApiProvider
6572
+ ? CLAUDE_API_PROVIDER_LABEL
6276
6573
  : (useOpenAIProvider
6277
6574
  ? 'OpenAI'
6278
- : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : 'Grok')));
6575
+ : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : (useGrokProvider ? 'Grok' : 'DeepSeek'))));
6279
6576
  const providerCode = useClaudeCloudProvider
6280
6577
  ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
6578
+ : useClaudeApiProvider
6579
+ ? 'CLAUDE_API_MODEL_UNAVAILABLE'
6281
6580
  : (useOpenAIProvider
6282
6581
  ? 'OPENAI_MODEL_UNAVAILABLE'
6283
6582
  : (useClaudeProvider
6284
6583
  ? 'CLAUDE_MODEL_UNAVAILABLE'
6285
- : (useCursorProvider ? 'CURSOR_MODEL_UNAVAILABLE' : 'GROK_MODEL_UNAVAILABLE')));
6584
+ : (useCursorProvider
6585
+ ? 'CURSOR_MODEL_UNAVAILABLE'
6586
+ : (useGrokProvider ? 'GROK_MODEL_UNAVAILABLE' : 'DEEPSEEK_MODEL_UNAVAILABLE'))));
6286
6587
  return res.status(400).json({
6287
6588
  ok: false,
6288
6589
  error: `${providerLabel} model "${providerModelSelection.requestedModel}" is not available`,
@@ -6302,6 +6603,7 @@ export function registerSessionsRoutes(app, deps) {
6302
6603
  if (
6303
6604
  remoteRequest.remote
6304
6605
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6606
+ && !useDeepSeekProvider && !useClaudeApiProvider
6305
6607
  ) {
6306
6608
  const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
6307
6609
  const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
@@ -6367,6 +6669,15 @@ export function registerSessionsRoutes(app, deps) {
6367
6669
  code: 'GROK_NOT_CONFIGURED',
6368
6670
  });
6369
6671
  }
6672
+ if (useDeepSeekProvider && deepseekSettings?.enabled !== true) {
6673
+ return res.status(400).json({
6674
+ ok: false,
6675
+ error: deepseekSettings?.configured === true
6676
+ ? 'DeepSeek is switched off for new conversations'
6677
+ : 'DeepSeek API key is not configured',
6678
+ code: 'DEEPSEEK_NOT_CONFIGURED',
6679
+ });
6680
+ }
6370
6681
 
6371
6682
  // The session group an agent's create_session names (an id or an exact
6372
6683
  // name): resolved before anything is written, so an unknown one creates
@@ -6421,7 +6732,10 @@ export function registerSessionsRoutes(app, deps) {
6421
6732
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
6422
6733
  });
6423
6734
  }
6424
- if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider) {
6735
+ if (
6736
+ !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6737
+ && !useDeepSeekProvider && !useClaudeApiProvider
6738
+ ) {
6425
6739
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
6426
6740
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
6427
6741
  : [];
@@ -6434,8 +6748,12 @@ export function registerSessionsRoutes(app, deps) {
6434
6748
  code: 'OPENAI_PROVIDER_REQUIRED',
6435
6749
  });
6436
6750
  }
6437
- const claudeOnlySelection = selectedProviders.length > 0
6438
- && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'claude');
6751
+ // Claude API serves the Claude provider's ids: a model both list is
6752
+ // still Claude's, and one only Claude API lists names that provider.
6753
+ const selectedProviderIds = selectedProviders.map((provider) => String(provider || '').trim().toLowerCase());
6754
+ const claudeOnlySelection = selectedProviderIds.length > 0
6755
+ && selectedProviderIds.includes('claude')
6756
+ && selectedProviderIds.every((provider) => provider === 'claude' || provider === CLAUDE_API_PROVIDER_TYPE);
6439
6757
  if (claudeOnlySelection) {
6440
6758
  return res.status(400).json({
6441
6759
  ok: false,
@@ -6443,6 +6761,15 @@ export function registerSessionsRoutes(app, deps) {
6443
6761
  code: 'CLAUDE_PROVIDER_REQUIRED',
6444
6762
  });
6445
6763
  }
6764
+ const claudeApiOnlySelection = selectedProviderIds.length > 0
6765
+ && selectedProviderIds.every((provider) => provider === CLAUDE_API_PROVIDER_TYPE);
6766
+ if (claudeApiOnlySelection) {
6767
+ return res.status(400).json({
6768
+ ok: false,
6769
+ error: `Model "${selectedModel}" requires the Claude API provider`,
6770
+ code: 'CLAUDE_API_PROVIDER_REQUIRED',
6771
+ });
6772
+ }
6446
6773
  const cursorOnlySelection = selectedProviders.length > 0
6447
6774
  && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'cursor');
6448
6775
  if (cursorOnlySelection) {
@@ -6461,17 +6788,28 @@ export function registerSessionsRoutes(app, deps) {
6461
6788
  code: 'GROK_PROVIDER_REQUIRED',
6462
6789
  });
6463
6790
  }
6791
+ const deepseekOnlySelection = selectedProviders.length > 0
6792
+ && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === DEEPSEEK_PROVIDER_TYPE);
6793
+ if (deepseekOnlySelection) {
6794
+ return res.status(400).json({
6795
+ ok: false,
6796
+ error: `Model "${selectedModel}" requires the DeepSeek provider`,
6797
+ code: 'DEEPSEEK_PROVIDER_REQUIRED',
6798
+ });
6799
+ }
6464
6800
  }
6465
6801
 
6466
6802
  const bootstrapProviderType = useClaudeCloudProvider
6467
6803
  ? 'claude-cloud'
6804
+ : useClaudeApiProvider
6805
+ ? CLAUDE_API_PROVIDER_TYPE
6468
6806
  : (useOpenAIProvider
6469
6807
  ? 'openai'
6470
6808
  : (useClaudeProvider
6471
6809
  ? 'claude'
6472
6810
  : (useCursorProvider
6473
6811
  ? 'cursor'
6474
- : (useGrokProvider ? 'grok' : 'github'))));
6812
+ : (useGrokProvider ? 'grok' : (useDeepSeekProvider ? 'deepseek' : 'github')))));
6475
6813
  // Preferences are written in the same transaction as the conversation, so
6476
6814
  // the caller's current mode has to arrive with the request: without it every
6477
6815
  // new chat would be stored as the default one and reset the composer. An
@@ -6487,11 +6825,15 @@ export function registerSessionsRoutes(app, deps) {
6487
6825
  const bootstrapReasoning = resolveProviderReasoningEffort({
6488
6826
  requestedEffort: req.body?.reasoningEffort || copilotVariantEffort,
6489
6827
  strict: bootstrapProviderType === 'openai',
6828
+ ...(bootstrapProviderType === 'deepseek'
6829
+ ? { defaultEffort: DEFAULT_DEEPSEEK_REASONING_EFFORT, aliasFor: deepseekReasoningEffortFor }
6830
+ : {}),
6490
6831
  supportedEfforts: supportedReasoningEffortsForProviderModel({
6491
6832
  providerType: bootstrapProviderType,
6492
6833
  model: selectedModel,
6493
6834
  cursorSettings,
6494
6835
  claudeSettings,
6836
+ claudeApiSettings,
6495
6837
  grokSettings,
6496
6838
  reasoningByModel: modelState?.reasoningByModel || null,
6497
6839
  }),
@@ -6536,7 +6878,10 @@ export function registerSessionsRoutes(app, deps) {
6536
6878
  {
6537
6879
  assignConfiguredProvider: true,
6538
6880
  providerType: bootstrapProviderType,
6539
- providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider) ? selectedModel : null,
6881
+ providerModel: (
6882
+ useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider
6883
+ || useDeepSeekProvider || useClaudeApiProvider
6884
+ ) ? selectedModel : null,
6540
6885
  },
6541
6886
  );
6542
6887
  // What the cloud session will clone, stored with the conversation so
@@ -7511,6 +7856,53 @@ export function registerSessionsRoutes(app, deps) {
7511
7856
  });
7512
7857
  });
7513
7858
 
7859
+ // A rotated API key only reaches a worker's environment at spawn: live
7860
+ // workers of the provider keep the old key and fail each turn after the
7861
+ // rotation. Stops them; the primer or the next delivery respawns them with
7862
+ // the fresh key from settings.
7863
+ //
7864
+ // The workers are picked by what they run: a worker that states its kind
7865
+ // is taken at its word (a Claude chat switched to the API key may still
7866
+ // have its subscription worker finishing a turn, and one switched back
7867
+ // its API worker); one that does not is taken at its chat's provider.
7868
+ async function stopLiveProviderWorkersForKeyRotation(providerType) {
7869
+ const rows = db.prepare(`
7870
+ SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
7871
+ FROM runtime_sessions
7872
+ WHERE LOWER(COALESCE(provider_type, '')) = ?
7873
+ AND NULLIF(sdk_session_id, '') IS NOT NULL
7874
+ `).all(providerType);
7875
+ const sids = new Set();
7876
+ for (const worker of sessionWorkerRegistry?.listWorkers?.() || []) {
7877
+ if (worker?.kind === providerType && worker.sdkSessionId) sids.add(worker.sdkSessionId);
7878
+ }
7879
+ for (const row of rows) {
7880
+ const sid = String(row.sdk_session_id || '').trim();
7881
+ const kind = sid ? (sessionWorkerRegistry?.getWorker?.(sid)?.kind || null) : null;
7882
+ if (sid && (!kind || kind === providerType)) sids.add(sid);
7883
+ }
7884
+ for (const sid of sids) {
7885
+ if (!sid) continue;
7886
+ const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7887
+ if (!workerState) continue;
7888
+ try {
7889
+ const stopped = await stopIdleWorkspaceRootSession({
7890
+ sdkSessionId: sid,
7891
+ worker: sessionWorkerRegistry?.getWorker?.(sid) || null,
7892
+ sessionWorkerSupervisor,
7893
+ sessionWorkerRegistry,
7894
+ sessionWorkerProcessInspector,
7895
+ });
7896
+ if (stopped.ok) {
7897
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
7898
+ console.log(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stopped for API key rotation`);
7899
+ }
7900
+ } catch (error) {
7901
+ console.warn(`[sessions] ${providerType} worker ${sid.slice(0, 8)} stop failed on key rotation: ${error?.message || error}`);
7902
+ }
7903
+ }
7904
+ }
7905
+
7514
7906
  app.get('/api/settings/cursor', auth, (_req, res) => {
7515
7907
  const settings = getCursorProviderSettings();
7516
7908
  return res.json({
@@ -7542,33 +7934,7 @@ export function registerSessionsRoutes(app, deps) {
7542
7934
  // fail each turn after the rotation. Stop them now; the primer or the
7543
7935
  // next delivery respawns them with the fresh key from settings.
7544
7936
  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
- }
7937
+ await stopLiveProviderWorkersForKeyRotation('cursor');
7572
7938
  }
7573
7939
  const shouldDiscover = result.enabled === true && (
7574
7940
  !!parsed.apiKey
@@ -7626,6 +7992,164 @@ export function registerSessionsRoutes(app, deps) {
7626
7992
  });
7627
7993
  });
7628
7994
 
7995
+ // --- DeepSeek ----------------------------------------------------------
7996
+ // The key is written here and never read back: GET and every answer carry
7997
+ // `configured` only. A rotated key reaches a worker only at spawn, so the
7998
+ // live DeepSeek workers are stopped; the next message starts them again.
7999
+ // What the tab shows: the stored settings, and whether the starting
8000
+ // balance still applies (a top-up since it was set replaces it), judged
8001
+ // on the currency the card's bar uses: the last balance read's main one.
8002
+ function describeDeepSeekSettings() {
8003
+ const described = deepseekSettingsService.describe();
8004
+ let references = [];
8005
+ try {
8006
+ references = planUsageService?.readDeepSeekBalanceReferences?.() || [];
8007
+ } catch {}
8008
+ let balances = null;
8009
+ try {
8010
+ balances = deepseekBalanceService?.peekBalance?.()?.balances || null;
8011
+ } catch {}
8012
+ const startingBalanceNote = deepseekStartingBalanceNote(
8013
+ described.startingBalance ? { amount: described.startingBalance, setAt: described.startingBalanceSetAt } : null,
8014
+ references,
8015
+ { balances },
8016
+ );
8017
+ return { ...described, startingBalanceNote };
8018
+ }
8019
+
8020
+ app.get('/api/settings/deepseek', auth, (_req, res) => {
8021
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
8022
+ return res.json(describeDeepSeekSettings());
8023
+ });
8024
+
8025
+ app.post('/api/settings/deepseek', auth, async (req, res) => {
8026
+ if (!deepseekSettingsService) return res.status(501).json({ error: 'DeepSeek settings are unavailable' });
8027
+ const parsed = parseDeepSeekSettingsUpdateRequest(req.body);
8028
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
8029
+ // A starting balance a top-up has replaced is set anew when it is saved
8030
+ // again, the same amount too.
8031
+ const before = describeDeepSeekSettings();
8032
+ const startingBalanceReplaced = !!before.startingBalanceNote;
8033
+ const result = deepseekSettingsService.update(parsed, { startingBalanceReplaced });
8034
+ if (!result?.ok) {
8035
+ return res.status(result?.code === 'deepseek-key-removal-blocked' ? 409 : 400).json({
8036
+ error: result?.error || 'Failed to update DeepSeek settings',
8037
+ code: result?.code || null,
8038
+ activeConversationCount: Number(result?.activeConversationCount || 0),
8039
+ startedConversationCount: Number(result?.startedConversationCount || 0),
8040
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
8041
+ });
8042
+ }
8043
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(DEEPSEEK_PROVIDER_TYPE);
8044
+ const discovery = result.discover
8045
+ ? await deepseekSettingsService.refreshModels()
8046
+ : { ok: true, models: [], error: null };
8047
+ // A starting balance set anew: the balance is read right then and noted
8048
+ // as the latest read, so a later top-up is a rise from a balance read
8049
+ // after the setting, and one made before it is already in this read
8050
+ // (deepseekStartingBalanceState). A failed read changes nothing: the
8051
+ // setting then stays until a rise seen after a read that followed it.
8052
+ const setAt = result.settings?.startingBalanceSetAt || null;
8053
+ if (setAt && setAt !== before.startingBalanceSetAt) {
8054
+ try {
8055
+ const balance = await deepseekBalanceService?.getBalance?.({ force: true });
8056
+ planUsageService?.recordDeepSeekBalance?.(balance);
8057
+ } catch {}
8058
+ }
8059
+ // Removing the key moves unstarted DeepSeek conversations back to the
8060
+ // default provider, as for OpenAI and Cursor.
8061
+ const reconciliationResult = parsed.remove === true
8062
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: DEFAULT_DEEPSEEK_MODEL, provider: DEEPSEEK_PROVIDER_TYPE })
8063
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
8064
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
8065
+ const settingsPayload = {
8066
+ ...describeDeepSeekSettings(),
8067
+ reconciliation: {
8068
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
8069
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
8070
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
8071
+ failedConversations,
8072
+ },
8073
+ };
8074
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8075
+ io.emit('deepseek_settings_updated', settingsPayload);
8076
+ return res.json({
8077
+ ok: true,
8078
+ ...settingsPayload,
8079
+ warning: failedConversations.length
8080
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
8081
+ : (discovery?.ok ? null : (discovery?.error || 'DeepSeek model discovery failed')),
8082
+ });
8083
+ });
8084
+
8085
+ // --- Claude API ----------------------------------------------------------
8086
+ // The key is written here and never read back: GET and every answer carry
8087
+ // `configured` only. A pasted Claude login token or Admin key is refused by
8088
+ // its prefix. A rotated key reaches a worker only at spawn, so the live
8089
+ // Claude API workers are stopped; the next message starts them again.
8090
+ app.get('/api/settings/claude-api', auth, (_req, res) => {
8091
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8092
+ return res.json(claudeApiSettingsService.describe());
8093
+ });
8094
+
8095
+ app.post('/api/settings/claude-api', auth, async (req, res) => {
8096
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8097
+ const parsed = parseClaudeApiSettingsUpdateRequest(req.body);
8098
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error, code: parsed.code || null });
8099
+ const result = claudeApiSettingsService.update(parsed);
8100
+ if (!result?.ok) {
8101
+ return res.status(result?.code === 'claude-api-key-removal-blocked' ? 409 : 400).json({
8102
+ error: result?.error || 'Failed to update Claude API settings',
8103
+ code: result?.code || null,
8104
+ activeConversationCount: Number(result?.activeConversationCount || 0),
8105
+ startedConversationCount: Number(result?.startedConversationCount || 0),
8106
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
8107
+ });
8108
+ }
8109
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(CLAUDE_API_PROVIDER_TYPE);
8110
+ const discovery = result.discover
8111
+ ? await claudeApiSettingsService.refreshModels()
8112
+ : { ok: true, models: [], error: null };
8113
+ // Removing the key or switching the provider off moves unstarted Claude
8114
+ // API conversations back to the default provider, as for Claude itself.
8115
+ const reconciliationResult = result.removed === true || result.disabled === true
8116
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: '', provider: CLAUDE_API_PROVIDER_TYPE })
8117
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
8118
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
8119
+ const settingsPayload = {
8120
+ ...claudeApiSettingsService.describe(),
8121
+ reconciliation: {
8122
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
8123
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
8124
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
8125
+ failedConversations,
8126
+ },
8127
+ };
8128
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8129
+ io.emit('claude_api_settings_updated', settingsPayload);
8130
+ try { claudeApiStopLedger?.clearAll?.(); } catch {}
8131
+ try { checkClaudeApiCap('settings'); } catch {}
8132
+ // The key is saved whatever discovery said; Anthropic's refusal (a 401,
8133
+ // a key that needs a workspace) is the tab's status until a discovery
8134
+ // succeeds. A list dropped because the key changed meanwhile is no verdict.
8135
+ // A default the fallback list could not keep is named with the refusal.
8136
+ const previousModel = String(result.previousModel || '').trim();
8137
+ const defaultModelChanged = previousModel && settingsPayload.model && previousModel !== settingsPayload.model
8138
+ ? { from: previousModel, to: settingsPayload.model }
8139
+ : null;
8140
+ const discoveryError = result.discover && discovery?.ok === false && discovery?.stale !== true
8141
+ ? { error: discovery.error || 'Claude API model discovery failed', code: discovery.code || null, defaultModelChanged }
8142
+ : null;
8143
+ return res.json({
8144
+ ok: true,
8145
+ ...settingsPayload,
8146
+ discoveryError,
8147
+ warning: failedConversations.length
8148
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
8149
+ : (discovery?.ok ? null : (discovery?.error || 'Claude API model discovery failed')),
8150
+ });
8151
+ });
8152
+
7629
8153
  app.get('/api/settings/turn-ceiling', auth, (_req, res) => {
7630
8154
  res.json({
7631
8155
  ceilingMinutes: getTurnCeilingMinutes(),
@@ -8193,6 +8717,8 @@ export function registerSessionsRoutes(app, deps) {
8193
8717
  const claudeSettings = getClaudeProviderSettings();
8194
8718
  const cursorSettings = getCursorProviderSettings();
8195
8719
  const grokSettings = getGrokProviderSettings();
8720
+ const deepseekSettings = getDeepSeekProviderSettings();
8721
+ const claudeApiSettings = getClaudeApiProviderSettings();
8196
8722
  return {
8197
8723
  ...buildModelVariantCatalogPayload({
8198
8724
  rows,
@@ -8222,6 +8748,23 @@ export function registerSessionsRoutes(app, deps) {
8222
8748
  enabledModels: Array.isArray(grokSettings.models) ? grokSettings.models : [],
8223
8749
  }
8224
8750
  : null,
8751
+ deepseekModels: deepseekSettings?.enabled === true
8752
+ ? {
8753
+ defaultModel: deepseekSettings.model,
8754
+ availableModels: Array.isArray(deepseekSettings.availableModels) ? deepseekSettings.availableModels : [],
8755
+ enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
8756
+ }
8757
+ : null,
8758
+ // The Select Models → Claude API tab: its own list, "[1m]" ids included
8759
+ // (the tab shows them as on the Claude tab).
8760
+ claudeApiModels: claudeApiSettings?.enabled === true
8761
+ ? {
8762
+ defaultModel: claudeApiSettings.model,
8763
+ availableModels: Array.isArray(claudeApiSettings.availableModels) ? claudeApiSettings.availableModels : [],
8764
+ enabledModels: Array.isArray(claudeApiSettings.models) ? claudeApiSettings.models : [],
8765
+ discovered: claudeApiSettings.discovered === true,
8766
+ }
8767
+ : null,
8225
8768
  };
8226
8769
  }
8227
8770
 
@@ -8240,6 +8783,8 @@ export function registerSessionsRoutes(app, deps) {
8240
8783
  const claudeSettings = getClaudeProviderSettings();
8241
8784
  const cursorSettings = getCursorProviderSettings();
8242
8785
  const grokSettings = getGrokProviderSettings();
8786
+ const deepseekSettings = getDeepSeekProviderSettings();
8787
+ const claudeApiSettings = getClaudeApiProviderSettings();
8243
8788
  const refreshTasks = [
8244
8789
  refreshModelVariantCatalogFromCli(),
8245
8790
  openAISettings?.enabled === true
@@ -8260,8 +8805,16 @@ export function registerSessionsRoutes(app, deps) {
8260
8805
  typeof copilotModelDiscoveryService?.refresh === 'function'
8261
8806
  ? copilotModelDiscoveryService.refresh('manual-refresh')
8262
8807
  : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8808
+ deepseekSettings?.enabled === true && deepseekSettingsService
8809
+ ? deepseekSettingsService.refreshModels()
8810
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8811
+ claudeApiSettings?.enabled === true && claudeApiSettingsService
8812
+ ? claudeApiSettingsService.refreshModels()
8813
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8263
8814
  ];
8264
- const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh] = await Promise.allSettled(refreshTasks);
8815
+ const [
8816
+ cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh, claudeApiRefresh,
8817
+ ] = await Promise.allSettled(refreshTasks);
8265
8818
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
8266
8819
  const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
8267
8820
  ? openAIRefresh.value
@@ -8291,6 +8844,20 @@ export function registerSessionsRoutes(app, deps) {
8291
8844
  models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
8292
8845
  error: grokRefresh.reason?.message || 'Grok model discovery failed',
8293
8846
  };
8847
+ const deepseekModelDiscovery = deepseekRefresh.status === 'fulfilled'
8848
+ ? deepseekRefresh.value
8849
+ : {
8850
+ ok: false,
8851
+ models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
8852
+ error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
8853
+ };
8854
+ const claudeApiModelDiscovery = claudeApiRefresh.status === 'fulfilled'
8855
+ ? claudeApiRefresh.value
8856
+ : {
8857
+ ok: false,
8858
+ models: Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [],
8859
+ error: claudeApiRefresh.reason?.message || 'Claude API model discovery failed',
8860
+ };
8294
8861
  const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
8295
8862
  ? copilotDiscoveryRefresh.value
8296
8863
  : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
@@ -8304,6 +8871,8 @@ export function registerSessionsRoutes(app, deps) {
8304
8871
  claudeModelDiscovery,
8305
8872
  cursorModelDiscovery,
8306
8873
  grokModelDiscovery,
8874
+ deepseekModelDiscovery,
8875
+ claudeApiModelDiscovery,
8307
8876
  copilotModelDiscovery,
8308
8877
  });
8309
8878
  } catch (error) {
@@ -8351,6 +8920,7 @@ export function registerSessionsRoutes(app, deps) {
8351
8920
  // stay in models[] (a selection must not vanish) but pickers grey them.
8352
8921
  unavailableModels: Array.isArray(modelState.unavailableModels) ? modelState.unavailableModels : [],
8353
8922
  claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
8923
+ claudeApiContextTiersByModel: modelState.claudeApiContextTiersByModel || {},
8354
8924
  providersByModel: modelState.providersByModel || {},
8355
8925
  stale: modelState.stale,
8356
8926
  metadataValid: modelState.metadataValid === true,
@@ -8399,8 +8969,10 @@ export function registerSessionsRoutes(app, deps) {
8399
8969
  copilotBilling: null, // { value, at }
8400
8970
  grokBilling: null,
8401
8971
  cursorBilling: null,
8972
+ deepseekBalance: null,
8402
8973
  };
8403
- const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok'];
8974
+ // `claude-api`'s card is read from the relay's own books on every answer.
8975
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek', 'claude-api'];
8404
8976
 
8405
8977
  function parseUsageProviders(raw) {
8406
8978
  const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
@@ -8455,6 +9027,16 @@ export function registerSessionsRoutes(app, deps) {
8455
9027
  const claudeSettings = getClaudeProviderSettings();
8456
9028
  const cursorSettings = getCursorProviderSettings();
8457
9029
  const grokSettings = getGrokProviderSettings();
9030
+ const deepseekSettings = getDeepSeekProviderSettings();
9031
+ const deepseekEnabled = deepseekSettings?.enabled === true;
9032
+ // An unreadable export is no reason to lose the card: the estimate stays.
9033
+ const readDeepSeekActuals = () => {
9034
+ try {
9035
+ return deepseekUsageImportService?.readActuals?.() ?? null;
9036
+ } catch {
9037
+ return null;
9038
+ }
9039
+ };
8458
9040
  let claudeCloudEnabled = false;
8459
9041
  try {
8460
9042
  claudeCloudEnabled = claudeCloudSettingsService?.getSettings?.()?.enabled === true;
@@ -8476,7 +9058,7 @@ export function registerSessionsRoutes(app, deps) {
8476
9058
  : Promise.resolve(null);
8477
9059
 
8478
9060
  // All reads side by side: the modal waits for the slowest, not the sum.
8479
- const [copilot, billingEntry, grokEntry, cursorEntry, claudeAccount] = await Promise.all([
9061
+ const [copilot, billingEntry, grokEntry, cursorEntry, deepseekEntry, claudeAccount] = await Promise.all([
8480
9062
  readCopilotSummary(),
8481
9063
  cachedOrLive('copilotBilling', live('github'), () => (
8482
9064
  typeof fetchCopilotBillingUsage === 'function'
@@ -8489,6 +9071,12 @@ export function registerSessionsRoutes(app, deps) {
8489
9071
  cachedOrLive('cursorBilling', live('cursor'), () => (
8490
9072
  cursorSettings?.enabled === true && typeof fetchCursorDashboardUsage === 'function' ? fetchCursorDashboardUsage() : null
8491
9073
  )),
9074
+ // The service keeps its own minute of cache and answers a failure as
9075
+ // `{ ok: false, … }`, so a failed read replaces the earlier answer here
9076
+ // (which it carries as `previous`).
9077
+ cachedOrLive('deepseekBalance', live('deepseek'), () => (
9078
+ deepseekEnabled && typeof deepseekBalanceService?.getBalance === 'function' ? deepseekBalanceService.getBalance() : null
9079
+ )),
8492
9080
  claudeAccountPromise,
8493
9081
  ]);
8494
9082
 
@@ -8503,6 +9091,14 @@ export function registerSessionsRoutes(app, deps) {
8503
9091
  }
8504
9092
  }
8505
9093
 
9094
+ // Built from the database on every answer, so it is always live.
9095
+ let claudeApiCard = null;
9096
+ try {
9097
+ claudeApiCard = claudeApiPlanUsage?.buildCard?.() || null;
9098
+ } catch {
9099
+ claudeApiCard = null;
9100
+ }
9101
+
8506
9102
  const report = planUsageService.buildReport({
8507
9103
  copilotSummary: copilot.summary,
8508
9104
  copilotError: copilot.error,
@@ -8521,6 +9117,15 @@ export function registerSessionsRoutes(app, deps) {
8521
9117
  grokConfigured: grokSettings?.enabled === true,
8522
9118
  grokAllowances: getGrokPlanAllowanceSettings(),
8523
9119
  grokBilling: grokEntry.value,
9120
+ deepseekConfigured: deepseekEnabled,
9121
+ deepseekBalance: deepseekEnabled ? deepseekEntry.value : null,
9122
+ deepseekMonthlyBudget: deepseekEnabled ? (deepseekSettings?.monthlyBudget ?? null) : null,
9123
+ deepseekMonthlyBudgetCurrency: deepseekEnabled ? (deepseekSettings?.monthlyBudgetCurrency ?? null) : null,
9124
+ deepseekStartingBalance: deepseekEnabled && deepseekSettings?.startingBalance
9125
+ ? { amount: deepseekSettings.startingBalance, setAt: deepseekSettings.startingBalanceSetAt }
9126
+ : null,
9127
+ deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9128
+ claudeApiCard,
8524
9129
  });
8525
9130
 
8526
9131
  // Legacy top-level fields stay alongside `providers` so an older cached
@@ -8535,6 +9140,8 @@ export function registerSessionsRoutes(app, deps) {
8535
9140
  'claude-cloud': claudeAccount?.fetchedAt || null,
8536
9141
  cursor: cursorEntry.at,
8537
9142
  grok: grokEntry.at,
9143
+ deepseek: deepseekEntry.value?.fetchedAt || deepseekEntry.at,
9144
+ 'claude-api': nowIso(),
8538
9145
  },
8539
9146
  });
8540
9147
  });