@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
@@ -36,7 +36,7 @@ import { createMessageRepository } from './repositories/message-repository.mjs';
36
36
  import { createQuestionRepository } from './repositories/question-repository.mjs';
37
37
  import { createImageConversationRepository } from './repositories/image-conversation-repository.mjs';
38
38
  import { registerSessionsRoutes } from './routes/sessions-routes.mjs';
39
- import { buildDequeuedRelayMessage, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
39
+ import { buildDequeuedRelayMessage, claudeApiRemedyText, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
40
40
  import { registerAskUserRoutes } from './routes/ask-user-routes.mjs';
41
41
  import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
42
42
  import { registerCacheRoutes } from './routes/cache-routes.mjs';
@@ -46,6 +46,7 @@ import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/prev
46
46
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
47
47
  import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
48
48
  import { registerRelayScheduleRoutes } from './routes/relay-schedule-routes.mjs';
49
+ import { registerDeepSeekUsageImportRoutes } from './routes/deepseek-usage-import-routes.mjs';
49
50
  import { registerRelayAskRoutes } from './routes/relay-ask-routes.mjs';
50
51
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
51
52
  import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
@@ -64,6 +65,17 @@ import { createClaudeCloudCredentials } from '../shared/claude-cloud/credentials
64
65
  import { createClaudeCloudClient } from '../shared/claude-cloud/api-client.mjs';
65
66
  import { resolveClaudeCloudBaseUrl } from '../shared/claude-cloud/base-url.mjs';
66
67
  import { createClaudeCloudSettingsService } from './services/claude-cloud-settings-service.mjs';
68
+ import { createDeepSeekSettingsService } from './services/deepseek-settings-service.mjs';
69
+ import { createWorkerPostRedaction } from './services/worker-secret-redaction.mjs';
70
+ import { createDeepSeekBalanceService } from './services/deepseek-balance-service.mjs';
71
+ import { deepseekCardCurrency } from './services/plan-usage-deepseek.mjs';
72
+ import { createDeepSeekUsageImportService } from './services/deepseek-usage-import.mjs';
73
+ import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
74
+ import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
75
+ import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
76
+ import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
77
+ import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
78
+ import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
67
79
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
68
80
  import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } from './services/claude-cloud-repo-service.mjs';
69
81
  import { createClaudeAccountUsageService } from './services/claude-account-usage-service.mjs';
@@ -135,10 +147,10 @@ import { mergeStrayMigratedUploads } from './services/oar-state-migration-servic
135
147
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
136
148
  import { createRelayBridgeOwnerService } from './services/relay-bridge-owner-service.mjs';
137
149
  import { createRelayCliLauncherService } from './services/relay-cli-launcher-service.mjs';
138
- import { createSessionWorkerRegistry } from './services/session-worker-registry-service.mjs';
150
+ import { createSessionWorkerRegistry, normalizeWorkerKind } from './services/session-worker-registry-service.mjs';
139
151
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
140
152
  import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
141
- import { applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
153
+ import { applyClaudeApiProviderEnvironment, applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
142
154
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
143
155
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
144
156
  import {
@@ -1936,6 +1948,7 @@ const runtimeTimers = {
1936
1948
  pendingWorkerPrime: null,
1937
1949
  sharedViewerPrune: null,
1938
1950
  restoredTaskSettle: null,
1951
+ claudeApiCapClock: null,
1939
1952
  };
1940
1953
 
1941
1954
  function normalizeRemotePath(value) {
@@ -2830,6 +2843,76 @@ const claudeCloudRepoService = createClaudeCloudRepoService({
2830
2843
  isEnabled: () => claudeCloudSettingsService.getSettings().enabled === true,
2831
2844
  listRecentCloudSources: () => listRecentCloudSourcesFromStatements(stmts),
2832
2845
  });
2846
+ // What a session worker posts loses every key a provider saved in Settings
2847
+ // (worker-secret-redaction.mjs). Built here, at the start, so it reads the
2848
+ // saved keys now, and the DeepSeek and Claude API saves below note each key
2849
+ // they write: one
2850
+ // changed before any worker has posted is still redacted.
2851
+ const workerPostRedaction = createWorkerPostRedaction({ readSetting: readAppSettingValue });
2852
+ // DeepSeek: one API key per relay, its models and its endpoint. The key stays
2853
+ // in the settings table; GET routes answer `configured`, never the key.
2854
+ const deepseekSettingsService = createDeepSeekSettingsService({
2855
+ readSetting: readAppSettingValue,
2856
+ writeSetting: (key, value) => {
2857
+ stmts.upsertAppSetting.run(key, String(value), new Date().toISOString());
2858
+ workerPostRedaction.noteSavedSetting(key, value);
2859
+ },
2860
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2861
+ listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
2862
+ });
2863
+ function getDeepSeekProviderSettings() {
2864
+ return deepseekSettingsService.getSettings();
2865
+ }
2866
+ // Claude API: Claude chats on a Console API key, one key per relay. The key
2867
+ // stays in the settings table; GET routes answer `configured`, never the key.
2868
+ // Before its own discovery the Claude provider's models stand in.
2869
+ const claudeApiSettingsService = createClaudeApiSettingsService({
2870
+ readSetting: readAppSettingValue,
2871
+ writeSetting: (key, value) => {
2872
+ stmts.upsertAppSetting.run(key, String(value), new Date().toISOString());
2873
+ workerPostRedaction.noteSavedSetting(key, value);
2874
+ },
2875
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2876
+ listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
2877
+ countLiveKeyWorkers: () => (sessionWorkerRegistry?.listWorkers?.() || []).filter((worker) => worker?.kind === 'claude-api').length,
2878
+ getClaudeProviderSettings: () => getClaudeProviderSettings(),
2879
+ });
2880
+ function getClaudeApiProviderSettings() {
2881
+ return claudeApiSettingsService.getSettings();
2882
+ }
2883
+ // The DeepSeek balance for Check Usage: read with the saved key only while
2884
+ // DeepSeek is on, kept for a minute.
2885
+ const deepseekBalanceService = createDeepSeekBalanceService({ getSettings: getDeepSeekProviderSettings });
2886
+ // DeepSeek's own usage export, imported in Settings (or with
2887
+ // `npm run deepseek:import`): the usage card shows its figures for the days
2888
+ // it covers, for the saved key only, and the estimate after.
2889
+ const deepseekUsageImportService = createDeepSeekUsageImportService({
2890
+ db,
2891
+ getApiKey: () => getDeepSeekProviderSettings()?.apiKey || '',
2892
+ });
2893
+ // Where DeepSeek workers keep their Claude Code state (sessions, transcripts,
2894
+ // settings): a directory of the relay's own, so no Claude login is in reach
2895
+ // of a CLI that talks to DeepSeek and their sessions stay out of Claude's.
2896
+ const DEEPSEEK_CLAUDE_CONFIG_DIR = path.join(DATA_DIR, 'deepseek-claude');
2897
+
2898
+ /**
2899
+ * What a DeepSeek session's commits and PRs say: the DeepSeek tab's mode
2900
+ * (OAR or off), the folder override on top, the DeepSeek model's name in the
2901
+ * trailer. A folder set to vanilla gets OAR's: Claude Code's own lines would
2902
+ * credit Claude for DeepSeek's work.
2903
+ */
2904
+ function deepseekAttributionForConversation(conv, modelId) {
2905
+ const root = String(conv?.runtime_workspace_root_path || conv?.configured_workspace_root_path || '').trim()
2906
+ || currentWorkspaceRootPath();
2907
+ const resolved = resolveClaudeAttributionMode({
2908
+ providerMode: getDeepSeekProviderSettings().attribution,
2909
+ folderMode: workspaceRootAttributionMode(root),
2910
+ });
2911
+ return buildClaudeAttributionSettings({
2912
+ mode: resolved === 'vanilla' ? 'oar' : resolved,
2913
+ modelLabel: deepseekModelLabel(modelId),
2914
+ });
2915
+ }
2833
2916
  // The account's live usage for the Check Usage modal. The Claude Cloud switch
2834
2917
  // is the consent to use the Claude CLI's login, so nothing is read while it
2835
2918
  // is off.
@@ -3058,7 +3141,7 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3058
3141
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
3059
3142
  // Clear every provider's variables first (the appliers' kind-guarded deletes
3060
3143
  // keep the chained clears order-independent), then apply only the bound one.
3061
- const cleared = applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv))))));
3144
+ const cleared = applyClaudeApiProviderEnvironment(applyDeepSeekProviderEnvironment(applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv))))))));
3062
3145
  // A Claude Cloud conversation gets its own worker and nothing else: it must
3063
3146
  // never reach the Copilot CLI fall-through at the end of this function. The
3064
3147
  // environment names the worker and the model, no credential.
@@ -3088,6 +3171,32 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3088
3171
  dataDir: DATA_DIR,
3089
3172
  });
3090
3173
  }
3174
+ // A DeepSeek conversation gets its own worker and must never reach the
3175
+ // Copilot fall-through below. Without a key the applier throws, and the
3176
+ // conversation gets the launch note.
3177
+ if (providerType === 'deepseek') {
3178
+ const deepseekSettings = getDeepSeekProviderSettings();
3179
+ return applyDeepSeekProviderEnvironment(cleared, {
3180
+ enabled: true,
3181
+ model: String(runtimeSession?.provider_model || runtimeSession?.model || deepseekSettings.model).trim(),
3182
+ apiKey: deepseekSettings.apiKey,
3183
+ baseUrl: deepseekSettings.baseUrl,
3184
+ configDir: DEEPSEEK_CLAUDE_CONFIG_DIR,
3185
+ });
3186
+ }
3187
+ // A Claude API conversation gets its own worker, never the Copilot
3188
+ // fall-through below nor a Claude one on the host's subscription login.
3189
+ // Without a key the applier throws, and the conversation gets the launch
3190
+ // note. No config dir of its own: the chat is a Claude chat in the host's
3191
+ // Claude Code state, billed to the key.
3192
+ if (providerType === 'claude-api') {
3193
+ const claudeApiSettings = getClaudeApiProviderSettings();
3194
+ return applyClaudeApiProviderEnvironment(cleared, {
3195
+ enabled: true,
3196
+ model: String(runtimeSession?.provider_model || runtimeSession?.model || claudeApiSettings.model).trim(),
3197
+ apiKey: claudeApiSettings.apiKey,
3198
+ });
3199
+ }
3091
3200
  if (providerType === 'grok') {
3092
3201
  const grokSettings = getGrokProviderSettings();
3093
3202
  const grokModel = String(runtimeSession?.provider_model || runtimeSession?.model || grokSettings.model).trim();
@@ -3316,13 +3425,16 @@ async function stopSessionWorkerForProviderRebind(sdkSessionId) {
3316
3425
 
3317
3426
  async function reconcileUnstartedConversationProviders({ enabled, model, provider = 'openai' } = {}) {
3318
3427
  const normalizedProvider = String(provider || 'openai').trim().toLowerCase();
3319
- const managedProvider = ['claude', 'cursor', 'grok'].includes(normalizedProvider) ? normalizedProvider : 'openai';
3320
- const managedDefaultModel = {
3321
- openai: DEFAULT_OPENAI_MODEL,
3322
- claude: DEFAULT_CLAUDE_MODEL,
3323
- cursor: DEFAULT_CURSOR_MODEL,
3324
- grok: DEFAULT_GROK_MODEL,
3325
- }[managedProvider];
3428
+ const managedProvider = ['claude', 'cursor', 'grok', 'deepseek', 'claude-api'].includes(normalizedProvider) ? normalizedProvider : 'openai';
3429
+ const managedDefaultModel = managedProvider === 'claude-api'
3430
+ ? getClaudeApiProviderSettings().model
3431
+ : {
3432
+ openai: DEFAULT_OPENAI_MODEL,
3433
+ claude: DEFAULT_CLAUDE_MODEL,
3434
+ cursor: DEFAULT_CURSOR_MODEL,
3435
+ grok: DEFAULT_GROK_MODEL,
3436
+ deepseek: DEFAULT_DEEPSEEK_MODEL,
3437
+ }[managedProvider];
3326
3438
  const providerType = enabled === true ? managedProvider : 'github';
3327
3439
  const providerModel = enabled === true
3328
3440
  ? (String(model || '').trim() || managedDefaultModel)
@@ -3395,7 +3507,11 @@ async function reconcileUnstartedConversationProviders({ enabled, model, provide
3395
3507
  ? getCursorProviderSettings().configured === true
3396
3508
  : (currentProvider === 'grok'
3397
3509
  ? getGrokProviderSettings().enabled === true
3398
- : (currentProvider !== 'openai' || getOpenAIProviderSettings().configured === true)));
3510
+ : (currentProvider === 'deepseek'
3511
+ ? getDeepSeekProviderSettings().configured === true
3512
+ : (currentProvider === 'claude-api'
3513
+ ? getClaudeApiProviderSettings().enabled === true
3514
+ : (currentProvider !== 'openai' || getOpenAIProviderSettings().configured === true)))));
3399
3515
  if (providerWasUpdated && canRestorePreviousProvider) {
3400
3516
  stmts.updateRuntimeSessionProvider.run(
3401
3517
  currentProvider,
@@ -5130,7 +5246,18 @@ function createCompactedConversation(sourceConversationId) {
5130
5246
  // The new conversation stays in the old one's session group, and heads
5131
5247
  // it if the old one did; the archived source stays a member.
5132
5248
  conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
5133
- targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
5249
+ // A Claude chat keeps its billing (the subscription or the API key, a
5250
+ // pending swap's included): the new chat's first worker launches on it,
5251
+ // and it is never guessed again from a Claude model id. Other providers
5252
+ // bind as before.
5253
+ const sourceRuntime = stmts.getRuntimeSessionByConversation.get(sourceConversationId) || null;
5254
+ const sourceBilling = normalizeClaudeBilling(sourceRuntime?.provider_type);
5255
+ targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now, null, sourceBilling
5256
+ ? {
5257
+ providerType: sourceBilling,
5258
+ providerModel: String(sourceRuntime?.provider_model || sourceRuntime?.model || '').trim() || null,
5259
+ }
5260
+ : {});
5134
5261
  });
5135
5262
  tx();
5136
5263
  // Its schedules go on in the new conversation, both the ones that fire
@@ -5416,6 +5543,67 @@ const usageLimitPauseService = createUsageLimitPauseService({
5416
5543
  logger: console,
5417
5544
  });
5418
5545
 
5546
+ // What turns on the saved Claude API key cost, booked from both Claude
5547
+ // workers' reports (services/claude-api-usage-service.mjs); the Claude API
5548
+ // card and its cap read it.
5549
+ const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
5550
+ // This month's spend on the key against the cap, and the Check Usage card
5551
+ // (services/plan-usage-claude-api.mjs), over the same local days.
5552
+ const claudeApiPlanUsage = createClaudeApiPlanUsage({
5553
+ usageService: claudeApiUsageService,
5554
+ getSettings: () => getClaudeApiProviderSettings(),
5555
+ });
5556
+
5557
+ // A Claude chat's billing: the subscription or the saved API key, switched
5558
+ // between turns (services/claude-billing-switch-service.mjs). A switch to the
5559
+ // key releases a turn held at the subscription's usage limit.
5560
+ const claudeBillingSwitchService = createClaudeBillingSwitchService({
5561
+ db,
5562
+ stmts,
5563
+ emit: (event, payload) => io.emit(event, payload),
5564
+ getClaudeProviderSettings: () => getClaudeProviderSettings(),
5565
+ getClaudeApiProviderSettings: () => getClaudeApiProviderSettings(),
5566
+ getClaudeApiCapState: () => getClaudeApiCapState(),
5567
+ releaseUsageLimitPause: (args) => usageLimitPauseService.releaseForBillingSwitch(args),
5568
+ // What the swap waits for: a turn, a question, a compaction, background tasks.
5569
+ getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
5570
+ onQueueChanged: (reason) => {
5571
+ void primePendingSessionWorkers(reason);
5572
+ sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
5573
+ },
5574
+ logger: console,
5575
+ });
5576
+
5577
+ // When the Claude API cap flips (a booked report, the cap setting, a new
5578
+ // month), every open Claude chat's billing state is announced again: the
5579
+ // composer's toggle and the usage-limit banner's switch button follow it.
5580
+ const listOpenClaudeChats = db.prepare(`
5581
+ SELECT rs.conversation_id AS id FROM runtime_sessions rs
5582
+ JOIN conversations c ON c.id = rs.conversation_id
5583
+ WHERE rs.provider_type IN ('claude', 'claude-api') AND COALESCE(c.archived, 0) = 0
5584
+ AND COALESCE(c.status, '') != 'deleted'
5585
+ `);
5586
+ // The notes for self-started turns stopped on the key, once per chat per
5587
+ // episode: a cap that clears ends every chat's episode (the settings route
5588
+ // clears it on a save, a claimed turn on the key for its chat).
5589
+ const claudeApiStopLedger = createClaudeApiStopNoteLedger();
5590
+ const claudeApiCapWatch = createClaudeApiCapWatch({
5591
+ getCapState: () => getClaudeApiCapState(),
5592
+ onChange: ({ reached, reason }) => {
5593
+ if (!reached) claudeApiStopLedger.clearAll();
5594
+ const chats = listOpenClaudeChats.all();
5595
+ console.log(`[claude-api-cap] ${reached ? 'reached' : 'cleared'} (${reason}): announcing ${chats.length} Claude chat(s)`);
5596
+ for (const { id } of chats) {
5597
+ const billing = claudeBillingSwitchService.billingState(id);
5598
+ if (billing) io.emit('conversation_billing', { conversationId: id, billing });
5599
+ }
5600
+ },
5601
+ logger: console,
5602
+ });
5603
+ claudeApiCapWatch.check('boot');
5604
+ runtimeTimers.claudeApiCapClock = setInterval(() => claudeApiCapWatch.check('clock'), 5 * 60 * 1000);
5605
+ runtimeTimers.claudeApiCapClock.unref?.();
5606
+
5419
5607
  // Turns a restart cut off for good (their worker went with the relay, and a
5420
5608
  // second delivery would run the prompt twice): with the setting on, their
5421
5609
  // session gets one note to carry on. The rows processing right now are the
@@ -5469,7 +5657,7 @@ relayEventService = createRelayEventService({
5469
5657
  });
5470
5658
 
5471
5659
  // ─── Relay schedules ──────────────────────────────────────────────────────────
5472
- // Prompts agents scheduled with remote_relay's `schedule`: kept in the
5660
+ // Prompts agents scheduled with relay_tool's `schedule`: kept in the
5473
5661
  // database, their timers rebuilt at boot (services/relay-schedule-service.mjs).
5474
5662
  const relayScheduleService = createRelayScheduleService({
5475
5663
  db,
@@ -5487,7 +5675,7 @@ const relayScheduleService = createRelayScheduleService({
5487
5675
  });
5488
5676
 
5489
5677
  // ─── Questions that don't block ───────────────────────────────────────────────
5490
- // remote_relay's `ask`: a card in the session and the Inbox; the answer
5678
+ // relay_tool's `ask`: a card in the session and the Inbox; the answer
5491
5679
  // reaches the askers as a relay event (services/relay-ask-service.mjs).
5492
5680
  const relayAskService = createRelayAskService({
5493
5681
  db,
@@ -5984,6 +6172,14 @@ const readContextFromSessionEvents = contextSnapshotService.readContextFromSessi
5984
6172
  // Claude-provider sessions live under the Agent SDK's project layout rather than
5985
6173
  // ~/.copilot/session-state, so their browsable session folder resolves separately.
5986
6174
  const claudeSessionRootResolver = createClaudeSessionRootResolver({ fs, path });
6175
+ // DeepSeek chats run Claude Code with a config dir of their own: only that
6176
+ // root, never the host's ~/.claude.
6177
+ const deepseekSessionRootResolver = createClaudeSessionRootResolver({
6178
+ fs,
6179
+ path,
6180
+ env: { CLAUDE_CONFIG_DIR: DEEPSEEK_CLAUDE_CONFIG_DIR },
6181
+ homedir: () => '',
6182
+ });
5987
6183
  const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname, dataDir: DATA_DIR });
5988
6184
 
5989
6185
  // ─── Express + Socket.io Setup ────────────────────────────────────────────────
@@ -6226,7 +6422,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
6226
6422
  }
6227
6423
  return { ok: true, watcherCount, capped: false };
6228
6424
  }
6229
- async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'worker-ready' } = {}) {
6425
+ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'worker-ready', workerKind = null } = {}) {
6230
6426
  const requesterSessionId = String(sessionId || '').trim();
6231
6427
  if (!requesterSessionId) return { message: null, reason: 'missing-session-id' };
6232
6428
  touchCli();
@@ -6284,6 +6480,10 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6284
6480
 
6285
6481
  const counts = queueCounts();
6286
6482
  const existingWorker = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6483
+ // The kind the socket's worker stated is registered with it, so what a
6484
+ // response ran on is known from the first turn on (not from its first
6485
+ // heartbeat).
6486
+ const statedKind = normalizeWorkerKind(workerKind);
6287
6487
  if (!existingWorker) {
6288
6488
  sessionWorkerRegistry?.upsertWorker?.({
6289
6489
  sdkSessionId: requesterSessionId,
@@ -6291,6 +6491,7 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6291
6491
  status: 'ready',
6292
6492
  pid: pid || null,
6293
6493
  queueDepth: Math.max(0, Number(counts.pendingCount || 0)),
6494
+ ...(statedKind ? { kind: statedKind } : {}),
6294
6495
  });
6295
6496
  } else if (pid && existingWorker.pid !== pid) {
6296
6497
  sessionWorkerRegistry?.upsertWorker?.({
@@ -6299,7 +6500,10 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6299
6500
  pid,
6300
6501
  status: existingWorker.status || 'ready',
6301
6502
  queueDepth: Math.max(0, Number(counts.pendingCount || 0)),
6503
+ kind: statedKind,
6302
6504
  });
6505
+ } else if (statedKind && existingWorker.kind !== statedKind) {
6506
+ sessionWorkerRegistry?.upsertWorker?.({ ...existingWorker, kind: statedKind });
6303
6507
  }
6304
6508
 
6305
6509
  const dequeueResult = await dequeuePendingMessageForWorkerLoop({
@@ -6313,6 +6517,11 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6313
6517
  sessionWorkerSupervisor,
6314
6518
  relayRestartOrchestrator,
6315
6519
  inFlightProcessingCount: Number(counts.processingCount || 0),
6520
+ // A Claude chat's row goes only to a worker of its billing: the socket's
6521
+ // own word, else what the worker last said in a heartbeat.
6522
+ workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
6523
+ claudeApiClaimCheck,
6524
+ onBillingFence: handleClaudeBillingFence,
6316
6525
  });
6317
6526
  if (!dequeueResult?.message) {
6318
6527
  return {
@@ -6356,11 +6565,14 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6356
6565
  }
6357
6566
  console.log(`${runtimeLogPrefix()}WS DEQUEUE ${out.id.slice(0, 8)} session=${requesterSessionId.slice(0, 8)} reason=${String(reason || 'worker-ready')}`);
6358
6567
  io.emit('message_status', { messageId: out.id, conversationId: out.conversationId, status: 'processing' });
6568
+ // The first message after a billing swap tells the new worker so.
6569
+ const billingSwap = out?.ownerSessionId ? claudeBillingSwapService.takeSwapNote(out.ownerSessionId) : null;
6359
6570
  return {
6360
6571
  message: out,
6361
6572
  // Piggybacked settings so workers pick up slider changes on the next
6362
6573
  // delivery without a restart.
6363
6574
  settings: {
6575
+ ...(billingSwap ? { billingSwap } : {}),
6364
6576
  backgroundTaskTimeoutMs: backgroundTaskTimeoutMinutesToMs(getBackgroundTaskTimeoutMinutes()),
6365
6577
  // The user's max-turn-duration ceiling (0 = no limit): worker-local
6366
6578
  // watchdogs (Grok's prompt ceiling) must honor it rather than imposing
@@ -6371,8 +6583,12 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6371
6583
  // without a new queue column or push channel. null = Auto.
6372
6584
  ...((() => {
6373
6585
  const conv = out.conversationId ? stmts.getConvAnyStatus.get(out.conversationId) : null;
6586
+ const deliveredProvider = String(out.providerType || '').trim().toLowerCase();
6374
6587
  return {
6375
- autoCompactWindow: parseAutoCompactWindow(conv?.auto_compact_window),
6588
+ // DeepSeek's window is 1M; its sessions compact at 400K unless the
6589
+ // conversation pins another window.
6590
+ autoCompactWindow: parseAutoCompactWindow(conv?.auto_compact_window)
6591
+ ?? (deliveredProvider === 'deepseek' ? DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW : null),
6376
6592
  // Per-conversation thinking control, same delivery scoping. An
6377
6593
  // explicit null on thinkingEnabled means Host default — the worker
6378
6594
  // distinguishes it from an absent key (an older relay), so both
@@ -6384,16 +6600,26 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6384
6600
  // attribution. Always present on such a delivery, so the worker can
6385
6601
  // tell "left as it was" (absent) from "vanilla" (null).
6386
6602
  ...((() => {
6387
- const deliveredProvider = String(out.providerType || '').trim().toLowerCase();
6388
- if (deliveredProvider !== 'claude' && deliveredProvider !== 'claude-cloud') return {};
6603
+ // A DeepSeek session runs Claude Code on the host like a Claude
6604
+ // one: the same two settings, with DeepSeek's attribution.
6605
+ if (deliveredProvider === 'deepseek') {
6606
+ return {
6607
+ attribution: deepseekAttributionForConversation(conv, out.providerModel || out.model),
6608
+ claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()),
6609
+ };
6610
+ }
6611
+ // A Claude API session is a Claude session on another bill:
6612
+ // Claude's attribution and background command limit.
6613
+ const localClaude = deliveredProvider === 'claude' || deliveredProvider === 'claude-api';
6614
+ if (!localClaude && deliveredProvider !== 'claude-cloud') return {};
6389
6615
  return {
6390
6616
  attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
6391
- relayFolderFallback: deliveredProvider === 'claude',
6617
+ relayFolderFallback: localClaude,
6392
6618
  }),
6393
6619
  // The Claude tab's background command limit; the worker
6394
6620
  // hands it to the CLI it spawns next. Claude Cloud runs no
6395
6621
  // local CLI, so it does not get one.
6396
- ...(deliveredProvider === 'claude'
6622
+ ...(localClaude
6397
6623
  ? { claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()) }
6398
6624
  : {}),
6399
6625
  };
@@ -6605,6 +6831,177 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6605
6831
  return recovered;
6606
6832
  }
6607
6833
 
6834
+ // The Claude billing switch's worker swap (services/
6835
+ // claude-billing-swap-service.mjs): a Claude chat's next row of the other
6836
+ // billing is refused to the worker it has, which is replaced, once idle, by
6837
+ // one of the row's kind. The old one is stopped like the kill route does, and
6838
+ // the new one launched only once every process of the session is gone.
6839
+ const claudeBillingSwapService = createClaudeBillingSwapService({
6840
+ getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
6841
+ // A row the session claimed and has not finished: never stopped under it.
6842
+ hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
6843
+ stopWorker: async (sid) => {
6844
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
6845
+ // Nothing may start the session while it is being stopped: a launch then
6846
+ // would adopt the old process, kind and all.
6847
+ sessionWorkerSupervisor?.markKilled?.(sid);
6848
+ // A stop that fails backs off (the swap service retries it): the killed
6849
+ // marker goes with it, so the session's worker, alive or relaunched,
6850
+ // keeps asking for work meanwhile. The fence still keeps the other
6851
+ // billing's rows from it.
6852
+ const backOff = (error, remainingPids = []) => {
6853
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6854
+ console.warn(`${runtimeLogPrefix()}BILLING SWAP STOP FAILED session=${sid.slice(0, 8)} ${error}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''} — kill marker cleared, retrying with a backoff`);
6855
+ return { ok: false, error, remainingPids };
6856
+ };
6857
+ let stopped = null;
6858
+ try {
6859
+ await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
6860
+ stopped = await stopSessionWorkerProcesses({
6861
+ sdkSessionId: sid,
6862
+ worker,
6863
+ processInspector: sessionWorkerProcessInspector,
6864
+ isPidAliveImpl: isProcessAlive,
6865
+ killTmuxSessionImpl: killTmuxSession,
6866
+ });
6867
+ } catch (error) {
6868
+ return backOff(String(error?.message || error || 'worker-stop-failed'));
6869
+ }
6870
+ if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
6871
+ sessionWorkerRegistry?.removeWorker?.(sid);
6872
+ sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
6873
+ return { ok: true };
6874
+ },
6875
+ launchWorker: async (sid) => {
6876
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6877
+ const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
6878
+ return { ok: launched?.ok === true, error: launched?.error || null };
6879
+ },
6880
+ onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
6881
+ if (!conversationId) return;
6882
+ const at = new Date().toISOString();
6883
+ const messageId = uuidv4();
6884
+ const cause = reason === 'probe'
6885
+ ? `the relay cannot tell whether the old Claude process still runs a turn (${attempts} tries)`
6886
+ : `the old Claude process did not stop (${attempts} tries)`;
6887
+ const text = `⚠️ Could not restart the session on the ${rowBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}: `
6888
+ + `${cause}. The relay keeps trying; switching the billing back runs the queued messages on the old process.`;
6889
+ try {
6890
+ db.transaction(() => {
6891
+ stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
6892
+ stmts.setMessageKind?.run?.('billing-switch-stuck', messageId);
6893
+ stmts.updateConvTime?.run?.(at, conversationId);
6894
+ })();
6895
+ io.emit('assistant_message', {
6896
+ conversationId,
6897
+ messageId,
6898
+ message: { id: messageId, role: 'assistant', text, kind: 'billing-switch-stuck', model: null, mode: null, attachments: [], timestamp: at },
6899
+ });
6900
+ } catch (error) {
6901
+ console.warn(`[claude-billing] stuck note failed: ${error?.message || error}`);
6902
+ }
6903
+ },
6904
+ onPendingChange: ({ conversationId }) => {
6905
+ if (!conversationId) return;
6906
+ io.emit('conversation_billing', { conversationId, billing: claudeBillingSwitchService.billingState(conversationId) });
6907
+ },
6908
+ logger: console,
6909
+ });
6910
+
6911
+ // The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
6912
+ // one seam the claim check below, the billing switch, an agent's send and a
6913
+ // new Claude API chat ask: this month's priced estimate in
6914
+ // claude_api_usage_totals (relay local days, claudeApiUsageService.localDay)
6915
+ // against claude_api_monthly_cap_usd (services/plan-usage-claude-api.mjs).
6916
+ // It throws when the totals cannot be read; every caller counts that as reached.
6917
+ function getClaudeApiCapState() {
6918
+ return claudeApiPlanUsage.getCapState();
6919
+ }
6920
+
6921
+ // A Claude API row must not run while the provider is off, has no key, or
6922
+ // this month's cap is reached (getClaudeApiCapState): it is failed closed at
6923
+ // the claim, with the way out in its note.
6924
+ function claudeApiClaimCheck() {
6925
+ const settings = getClaudeApiProviderSettings();
6926
+ if (settings?.configured !== true) {
6927
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'Claude API has no key, so this turn did not run.' };
6928
+ }
6929
+ if (settings?.enabled !== true) {
6930
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'Claude API is switched off, so this turn did not run.', switchedOff: true };
6931
+ }
6932
+ let cap = null;
6933
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
6934
+ if (cap?.reached === true) {
6935
+ return {
6936
+ code: 'CLAUDE_API_CAP_REACHED',
6937
+ error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} This turn did not run.`,
6938
+ };
6939
+ }
6940
+ return null;
6941
+ }
6942
+
6943
+ // A Claude API chat's worker is not launched while Claude API cannot run a
6944
+ // turn (no key, switched off, the cap): its waiting rows on the key are
6945
+ // failed closed here, as the claim would, instead of a spawn that could only
6946
+ // refuse them. True when it was so.
6947
+ const listPendingClaudeApiRowsForOwner = db.prepare(`
6948
+ SELECT id FROM queue
6949
+ WHERE status = 'pending' AND NULLIF(owner_sdk_session_id, '') = ?
6950
+ AND COALESCE(kind, '') != 'continuation'
6951
+ AND LOWER(COALESCE(${stmts.setQueueProvider ? 'provider' : 'NULL'}, ?)) = 'claude-api'
6952
+ `);
6953
+ function failClaudeApiRowsWithoutLaunch(sdkSessionId) {
6954
+ const runtime = stmts.getRuntimeSessionBySdkSessionId.get(sdkSessionId) || null;
6955
+ if (normalizeClaudeBilling(runtime?.provider_type) !== 'claude-api') return false;
6956
+ // A check that cannot answer fails this session's rows on the key closed,
6957
+ // as the claim does (messages-routes.mjs, claudeBillingFence).
6958
+ let refusal = null;
6959
+ try {
6960
+ refusal = claudeApiClaimCheck();
6961
+ } catch (error) {
6962
+ console.warn(`${runtimeLogPrefix()}Claude API claim check failed for session=${sdkSessionId.slice(0, 8)}: ${error?.message || error} — its rows on the key fail closed`);
6963
+ refusal = {
6964
+ code: 'CLAUDE_API_NOT_CONFIGURED',
6965
+ error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
6966
+ };
6967
+ }
6968
+ if (!refusal) return false;
6969
+ for (const row of listPendingClaudeApiRowsForOwner.all(sdkSessionId, 'claude-api')) {
6970
+ handleClaudeBillingFence({ reason: 'claude-api-unavailable', messageId: row.id, refusal });
6971
+ }
6972
+ console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} reason=${refusal.code} — no Claude API worker while Claude API cannot run a turn`);
6973
+ return true;
6974
+ }
6975
+
6976
+ // The delivery refused a Claude chat's row to the worker that asked
6977
+ // (messages-routes.mjs, claudeBillingFence).
6978
+ function handleClaudeBillingFence(fence) {
6979
+ if (fence?.reason === 'claude-api-unavailable') {
6980
+ const row = stmts.findQById.get(fence.messageId);
6981
+ if (!row || String(row.status || '') !== 'pending') return;
6982
+ const code = String(fence.refusal?.code || 'CLAUDE_API_NOT_CONFIGURED');
6983
+ failRecoveredRowTerminally(row, 'claude-api-unavailable', {
6984
+ failureRecord: {
6985
+ kind: 'claude-api-unavailable',
6986
+ error: 'claude-api-unavailable',
6987
+ code,
6988
+ stableCode: `relay.${code.toLowerCase().replace(/_/g, '-')}`,
6989
+ failedAt: new Date().toISOString(),
6990
+ },
6991
+ // The code ends the note's paragraph, where the transcript's buttons
6992
+ // look for it (relay-error-ctas.mjs).
6993
+ failureText: `${fence.refusal?.error || 'Claude API is unavailable, so this turn did not run.'} `
6994
+ + `${claudeApiRemedyText({ code, switchedOff: fence.refusal?.switchedOff === true })}, then send the message again. `
6995
+ + `Error code: relay.${code.toLowerCase().replace(/_/g, '-')}.`,
6996
+ });
6997
+ return;
6998
+ }
6999
+ const result = claudeBillingSwapService.request(fence);
7000
+ if (result.state === 'swapping') {
7001
+ console.log(`${runtimeLogPrefix()}BILLING SWAP session=${String(fence.requesterSessionId || '').slice(0, 8)} conv=${String(fence.conversationId || '').slice(0, 8)} ${fence.workerKind || 'unknown'} → ${fence.rowBilling}`);
7002
+ }
7003
+ }
7004
+
6608
7005
  // A worker alive without its socket while a message waits: replaced after a
6609
7006
  // grace time (see the service). Stops the way the kill route does, but only
6610
7007
  // names the registry's pid when the process list confirms it is the
@@ -6675,6 +7072,14 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
6675
7072
  onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
6676
7073
  onWorkerSocketClosed: handleWorkerSocketClosed,
6677
7074
  requestWork: requestSessionWorkerSocketDelivery,
7075
+ // What a worker says it is ("claude", "claude-api", ...): the registry
7076
+ // keeps it for the billing fence and for what a response ran on.
7077
+ onWorkerKind: (sessionId, kind) => {
7078
+ const sid = String(sessionId || '').trim();
7079
+ if (!sid) return;
7080
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7081
+ if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
7082
+ },
6678
7083
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
6679
7084
  pathPrefix: remotePath,
6680
7085
  pollIntervalMs: 500,
@@ -6717,6 +7122,14 @@ async function primePendingSessionWorkers(reason = 'pending-worker-prime') {
6717
7122
  const sdkSessionId = String(row?.sdk_session_id || '').trim();
6718
7123
  if (!sdkSessionId) continue;
6719
7124
  if (sessionWorkerSupervisor?.isKillBlocked?.(sdkSessionId) === true) continue;
7125
+ try {
7126
+ // Per session: a throw here costs this session its launch, never the
7127
+ // rest of the pass.
7128
+ if (failClaudeApiRowsWithoutLaunch(sdkSessionId)) continue;
7129
+ } catch (error) {
7130
+ console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
7131
+ continue;
7132
+ }
6720
7133
  requested += 1;
6721
7134
  try {
6722
7135
  const result = await sessionWorkerSupervisor.ensureWorker(sdkSessionId);
@@ -6740,6 +7153,9 @@ app.use((req, _res, next) => {
6740
7153
  stripRequestPathPrefix(req, remotePath);
6741
7154
  next();
6742
7155
  });
7156
+ // What a session worker posts loses every key a provider saved in Settings
7157
+ // before any route stores, broadcasts or logs it (worker-secret-redaction.mjs).
7158
+ app.use(workerPostRedaction);
6743
7159
  function isApiRequestPath(requestPath) {
6744
7160
  return /^\/api(?:\/|$)/.test(String(requestPath || ''));
6745
7161
  }
@@ -6960,7 +7376,7 @@ const remoteRelayInbound = {
6960
7376
  },
6961
7377
  repository: remoteRelayRepository,
6962
7378
  };
6963
- // The remote_relay tool: who is calling (live turn, provider, model, mode,
7379
+ // The relay_tool: who is calling (live turn, provider, model, mode,
6964
7380
  // hops) and the Allow / Deny card write actions need in ask/plan mode.
6965
7381
  const remoteRelayCallerContext = createRemoteRelayCallerContext({
6966
7382
  db,
@@ -7074,6 +7490,12 @@ const sharedRouteDeps = {
7074
7490
  getClaudeCloudProviderSettings,
7075
7491
  claudeCloudSettingsService,
7076
7492
  claudeCloudSessionService,
7493
+ getDeepSeekProviderSettings,
7494
+ deepseekSettingsService,
7495
+ deepseekBalanceService,
7496
+ deepseekUsageImportService,
7497
+ getClaudeApiProviderSettings,
7498
+ claudeApiSettingsService,
7077
7499
  claudeCloudRepoService,
7078
7500
  // The login's state for the workers' nudge route (never the token).
7079
7501
  claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
@@ -7198,6 +7620,8 @@ const sharedRouteDeps = {
7198
7620
  relayRestartOrchestrator,
7199
7621
  resolveSessionStateRoot,
7200
7622
  resolveClaudeSessionRoot: claudeSessionRootResolver.resolveClaudeSessionRoot,
7623
+ resolveDeepSeekSessionRoot: deepseekSessionRootResolver.resolveClaudeSessionRoot,
7624
+ deepseekClaudeConfigDir: DEEPSEEK_CLAUDE_CONFIG_DIR,
7201
7625
  resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
7202
7626
  getTurnCeilingMinutes,
7203
7627
  setTurnCeilingMinutes,
@@ -7218,6 +7642,14 @@ const sharedRouteDeps = {
7218
7642
  hostSuspendService,
7219
7643
  collectHostActivity,
7220
7644
  usageLimitPauseService,
7645
+ claudeBillingSwitchService,
7646
+ claudeApiUsageService,
7647
+ claudeApiClaimCheck,
7648
+ onClaudeBillingFence: handleClaudeBillingFence,
7649
+ claudeApiPlanUsage,
7650
+ getClaudeApiCapState,
7651
+ checkClaudeApiCap: (reason) => claudeApiCapWatch.check(reason),
7652
+ claudeApiStopLedger,
7221
7653
  relayEventService,
7222
7654
  relayScheduleService,
7223
7655
  relayAskService,
@@ -7271,6 +7703,14 @@ registerRelayScheduleRoutes(app, {
7271
7703
  relayScheduleService,
7272
7704
  remoteRelayInbound,
7273
7705
  });
7706
+ registerDeepSeekUsageImportRoutes(app, {
7707
+ auth,
7708
+ deepseekUsageImportService,
7709
+ remoteRelayInbound,
7710
+ // The usage card's currency (the balance's), so the import's answer can
7711
+ // say whether the card uses the export's rows.
7712
+ getCardCurrency: async () => deepseekCardCurrency(await deepseekBalanceService.getBalance()),
7713
+ });
7274
7714
  registerRelayAskRoutes(app, {
7275
7715
  auth,
7276
7716
  db,
@@ -7309,6 +7749,7 @@ registerAboutRoutes(app, {
7309
7749
  'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
7310
7750
  cursor: { enabled: getCursorProviderSettings()?.enabled === true },
7311
7751
  grok: { enabled: getGrokProviderSettings()?.enabled === true },
7752
+ 'claude-api': { enabled: getClaudeApiProviderSettings()?.enabled === true },
7312
7753
  }),
7313
7754
  getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
7314
7755
  isRelayCliOnline: () => runtimeState.cliOnline === true,
@@ -7552,10 +7993,19 @@ function ensureRuntimeSessionBinding(
7552
7993
  // default below and bind a GitHub (Copilot CLI) session.
7553
7994
  : (normalizedRequestedProviderType === 'claude-cloud'
7554
7995
  ? 'claude-cloud'
7555
- : (normalizedRequestedProviderType === 'github'
7556
- ? 'github'
7557
- : (openAISettings?.enabled ? 'openai' : 'github'))))));
7558
- const resolvedProviderModel = (resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor' || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud')
7996
+ : (normalizedRequestedProviderType === 'deepseek'
7997
+ ? 'deepseek'
7998
+ // Never folded into `claude`: the two bill different accounts.
7999
+ : (normalizedRequestedProviderType === 'claude-api'
8000
+ ? 'claude-api'
8001
+ : (normalizedRequestedProviderType === 'github'
8002
+ ? 'github'
8003
+ : (openAISettings?.enabled ? 'openai' : 'github'))))))));
8004
+ const resolvedProviderModel = (
8005
+ resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor'
8006
+ || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud' || resolvedProviderType === 'deepseek'
8007
+ || resolvedProviderType === 'claude-api'
8008
+ )
7559
8009
  ? (String(providerModel || normalizedModel || '').trim() || null)
7560
8010
  : null;
7561
8011
  try {