@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
@@ -18,14 +18,14 @@ import {
18
18
  } from '../services/conversation-preferences-service.mjs';
19
19
  import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
20
20
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
21
- import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
21
+ import { normalizeWorkerKind, normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
22
22
  import {
23
23
  buildSteerSettleFailure,
24
24
  steerAgentLabelForProvider,
25
25
  STEER_SETTLE_FAILED_CODE,
26
26
  STEER_SETTLE_FAILED_STABLE_CODE,
27
27
  } from '../../shared/steer-settle-failure.mjs';
28
- import { matchFailureNote } from '../../shared/failure-note-text.mjs';
28
+ import { FAILURE_NOTE_LEAD_MAX, matchFailureNote } from '../../shared/failure-note-text.mjs';
29
29
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
30
30
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
31
31
  import {
@@ -38,6 +38,7 @@ import { claudePlanUsageFromResult, normalizeClaudePlanUsage } from '../services
38
38
  import { isUsageLimitTerminalError } from '../services/usage-limit-pause-service.mjs';
39
39
  import { normalizeCopilotWorkerUsage } from '../services/plan-usage-copilot.mjs';
40
40
  import { normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
41
+ import { normalizeDeepSeekUsageReport } from '../services/plan-usage-deepseek.mjs';
41
42
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
42
43
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
43
44
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
@@ -71,7 +72,18 @@ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limi
71
72
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
72
73
  import { createLocalRequestCheck } from '../services/request-boundary.mjs';
73
74
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
75
+ import {
76
+ DEEPSEEK_REASONING_EFFORTS,
77
+ DEFAULT_DEEPSEEK_REASONING_EFFORT,
78
+ deepseekModelAcceptsImages,
79
+ deepseekModelLabel,
80
+ deepseekReasoningEffortFor,
81
+ } from '../../shared/deepseek-models.mjs';
74
82
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
83
+ import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
84
+ import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
85
+ import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
86
+ import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
75
87
  import {
76
88
  admitRemoteRelayRequest,
77
89
  readRemoteRelayRequest,
@@ -151,6 +163,9 @@ function normalizeRequestedProviderType(value = '') {
151
163
  if (normalized === 'claude' || normalized === 'anthropic') return 'claude';
152
164
  // Its own provider, never read as `claude`.
153
165
  if (normalized === 'claude-cloud') return 'claude-cloud';
166
+ if (normalized === 'deepseek') return 'deepseek';
167
+ // Its own provider too (Claude on an API key), never read as `claude`.
168
+ if (normalized === 'claude-api') return 'claude-api';
154
169
  return '';
155
170
  }
156
171
 
@@ -159,6 +174,8 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
159
174
  grok: 'Grok',
160
175
  claude: 'Claude',
161
176
  'claude-cloud': 'Claude Cloud',
177
+ deepseek: 'DeepSeek',
178
+ 'claude-api': 'Claude API',
162
179
  });
163
180
 
164
181
  function deriveModelOrigin(model) {
@@ -856,9 +873,16 @@ export function resolveExecutedProviderForResponse({
856
873
  responseBridgeIdentity,
857
874
  conversationProvider = null,
858
875
  serverExecutedOperation = false,
876
+ // The kind the responding worker said it is. A Claude chat moves between
877
+ // the subscription and the API key, and its runtime row names the billing
878
+ // of now, not the one a turn that ends after a switch ran on: the worker's
879
+ // own kind is what ran.
880
+ responderKind = null,
859
881
  } = {}) {
860
882
  const boundProvider = String(conversationProvider || '').trim().toLowerCase() || 'github';
861
883
  const responderSessionId = normalizeSessionWorkerId(responseBridgeIdentity?.sessionId);
884
+ const responderBilling = normalizeClaudeBilling(responderKind);
885
+ if (responderSessionId && responderBilling) return responderBilling;
862
886
  if (responderSessionId) {
863
887
  const runtimeSession = stmts?.getRuntimeSessionBySdkSessionId?.get?.(responderSessionId) || null;
864
888
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase();
@@ -901,10 +925,21 @@ export function dequeuePendingMessage({
901
925
  requesterSessionId = null,
902
926
  ownerLeaseMs = SESSION_WORKER_OWNER_LEASE_MS,
903
927
  affinityOnly = false,
928
+ // The kind the requesting worker says it is ("claude", "claude-api", ...),
929
+ // null when unknown. A Claude chat's row is claimed only by a worker of
930
+ // the row's billing (claudeBillingFence); a refused claim calls
931
+ // `onBillingFence` and claims nothing.
932
+ workerKind = null,
933
+ onBillingFence = null,
934
+ // () → null, or `{ code, error }` when a Claude API row must not run now
935
+ // (the provider off, no key, the monthly cap): such a row is fenced too,
936
+ // whatever the worker, and the caller fails it closed.
937
+ claudeApiClaimCheck = null,
904
938
  } = {}) {
905
939
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
906
940
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
907
941
  const leaseExpiresAt = requesterSid ? addMsToIso(currentIso, ownerLeaseMs) : null;
942
+ let fenced = null;
908
943
  const dequeue = db.transaction(() => {
909
944
  let next = null;
910
945
  if (routingEnabled && requesterSid && stmts.findPendingForWorker) {
@@ -916,6 +951,10 @@ export function dequeuePendingMessage({
916
951
  } else if (!routingEnabled && requesterSid && stmts.findPendingForSessionAffinity) {
917
952
  next = stmts.findPendingForSessionAffinity.get(currentIso, requesterSid);
918
953
  }
954
+ if (next && requesterSid) {
955
+ fenced = claudeBillingFence({ stmts, row: next, workerKind, claudeApiClaimCheck });
956
+ if (fenced) return null;
957
+ }
919
958
  if (!next && affinityOnly) return null;
920
959
  if (!next) {
921
960
  // Requesters that reach the global fallback carry no worker identity
@@ -952,7 +991,119 @@ export function dequeuePendingMessage({
952
991
  attempt_id: attemptId,
953
992
  };
954
993
  });
955
- return dequeue();
994
+ const claimed = dequeue();
995
+ if (fenced && typeof onBillingFence === 'function') {
996
+ try { onBillingFence({ ...fenced, requesterSessionId: requesterSid }); } catch {}
997
+ }
998
+ return claimed;
999
+ }
1000
+
1001
+ // The `source` of an abort_turn control queued for a continuation on the
1002
+ // key that the Claude API claim check refuses (POST /api/continuation-turn):
1003
+ // the cap, no key, or the provider switched off.
1004
+ const CLAUDE_API_CAP_STOP_SOURCE = 'claude-api-cap';
1005
+
1006
+ /**
1007
+ * What a note on a Claude API turn that did not run tells the user to do,
1008
+ * without the full stop: the cap is raised, a switched-off Claude API is
1009
+ * switched on, a missing key is set up; or the chat moves to the
1010
+ * subscription.
1011
+ */
1012
+ export function claudeApiRemedyText({ code = '', switchedOff = false } = {}) {
1013
+ const place = 'Settings → Providers → Claude API';
1014
+ if (String(code) === 'CLAUDE_API_CAP_REACHED') return `Switch this chat to the subscription, or raise the monthly cap in ${place}`;
1015
+ if (switchedOff) return `Switch this chat to the subscription, or switch Claude API on in ${place}`;
1016
+ return `Switch this chat to the subscription, or set Claude API up in ${place}`;
1017
+ }
1018
+
1019
+ const CLAUDE_API_STOP_NOTE_LABELS = 2;
1020
+ const CLAUDE_API_STOP_NOTE_LABEL_CHARS = 40;
1021
+
1022
+ /**
1023
+ * The note for a self-started turn on the key the Claude API claim check
1024
+ * refused (`refusal`: `{ code, error, switchedOff? }`), with the background tasks the
1025
+ * relay stopped and those no stop reaches (`tasks`: `{ stopped, unreached }`
1026
+ * of `{ label }`). One paragraph whose lead stays within
1027
+ * FAILURE_NOTE_LEAD_MAX, so it still reads as a failure note and the
1028
+ * transcript's buttons find its code: at most two labels per list, cut to
1029
+ * 40 characters, then only the counts if the lead is still too long. The
1030
+ * fixed sentences and the code are never cut.
1031
+ */
1032
+ export function buildClaudeApiStopNoteText({ refusal = {}, tasks = {} } = {}) {
1033
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
1034
+ const stableCode = `relay.${code.toLowerCase().replace(/_/g, '-')}`;
1035
+ const reason = String(refusal.error || '').trim()
1036
+ .replace(/,? so this turn did not run\.$/, '.')
1037
+ .replace(/\s*This turn did not run\.$/, '');
1038
+ const stopped = Array.isArray(tasks.stopped) ? tasks.stopped : [];
1039
+ const unreached = Array.isArray(tasks.unreached) ? tasks.unreached : [];
1040
+ const cut = (label) => {
1041
+ const text = String(label || '').trim().replace(/\s+/g, ' ') || 'a task';
1042
+ return text.length > CLAUDE_API_STOP_NOTE_LABEL_CHARS ? `${text.slice(0, CLAUDE_API_STOP_NOTE_LABEL_CHARS - 1).trimEnd()}…` : text;
1043
+ };
1044
+ const named = (list, shown) => {
1045
+ const labels = list.slice(0, shown).map((task) => cut(task.label));
1046
+ const more = list.length - labels.length;
1047
+ return `${labels.join(', ')}${more ? ` and ${more} more` : ''}`;
1048
+ };
1049
+ const tasksText = (shown) => [
1050
+ stopped.length
1051
+ ? (shown ? `Background tasks stopped: ${named(stopped, shown)}. ` : `${stopped.length} background task${stopped.length === 1 ? '' : 's'} stopped. `)
1052
+ : '',
1053
+ unreached.length
1054
+ ? (shown
1055
+ ? `Still running (stop them in the task panel): ${named(unreached, shown)}. `
1056
+ : `${unreached.length}${stopped.length ? ' more' : ` background task${unreached.length === 1 ? '' : 's'}`} still running (stop them in the task panel). `)
1057
+ : '',
1058
+ ].join('');
1059
+ const lead = (shown) => `${reason} Claude Code started this turn by itself when a background task reported back. `
1060
+ + 'It is being stopped, but its first request may already be billed, and a short turn can end first. '
1061
+ + tasksText(shown)
1062
+ + `${claudeApiRemedyText({ code, switchedOff: refusal.switchedOff === true })}. `;
1063
+ let shown = CLAUDE_API_STOP_NOTE_LABELS;
1064
+ while (shown > 0 && lead(shown).length > FAILURE_NOTE_LEAD_MAX) shown -= 1;
1065
+ return `${lead(shown)}Error code: ${stableCode}.`;
1066
+ }
1067
+
1068
+ /**
1069
+ * Whether a worker of `workerKind` must not claim `row`, a Claude chat's
1070
+ * turn: null when it may, else `{ conversationId, messageId, rowBilling,
1071
+ * workerKind, reason }`. The row's billing is its stamp (queue.provider),
1072
+ * else its chat's. A worker that states no kind is taken at its chat's
1073
+ * word, except on a chat that has switched since (a worker from before the
1074
+ * switch existed: it is replaced once, when idle).
1075
+ */
1076
+ export function claudeBillingFence({ stmts, row, workerKind = null, claudeApiClaimCheck = null } = {}) {
1077
+ if (!row) return null;
1078
+ const runtime = stmts?.getRuntimeSessionByConversation?.get?.(row.conversation_id) || null;
1079
+ const chatBilling = normalizeClaudeBilling(runtime?.provider_type);
1080
+ const rowBilling = normalizeClaudeBilling(row.provider) || chatBilling;
1081
+ if (!rowBilling) return null;
1082
+ const kind = normalizeWorkerKind(workerKind);
1083
+ const base = {
1084
+ conversationId: String(row.conversation_id || ''),
1085
+ messageId: String(row.id || ''),
1086
+ rowBilling,
1087
+ workerKind: kind,
1088
+ };
1089
+ if (rowBilling === 'claude-api' && typeof claudeApiClaimCheck === 'function') {
1090
+ let refusal = null;
1091
+ try {
1092
+ refusal = claudeApiClaimCheck() || null;
1093
+ } catch (error) {
1094
+ // A check that cannot answer does not let a turn run on the key: the
1095
+ // row fails closed as unconfigured. Subscription rows never get here.
1096
+ console.warn(`[claude-billing] Claude API claim check failed for ${String(row.id || '').slice(0, 8)}: ${error?.message || error} — the row fails closed`);
1097
+ refusal = {
1098
+ code: 'CLAUDE_API_NOT_CONFIGURED',
1099
+ error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
1100
+ };
1101
+ }
1102
+ if (refusal) return { ...base, reason: 'claude-api-unavailable', refusal };
1103
+ }
1104
+ if (kind) return kind === rowBilling ? null : { ...base, reason: 'kind-mismatch' };
1105
+ if (String(runtime?.billing_switched_at || '').trim()) return { ...base, reason: 'kind-unknown' };
1106
+ return null;
956
1107
  }
957
1108
 
958
1109
  // A requeue for a turn that produced nothing and was only briefly in flight is
@@ -1018,9 +1169,13 @@ export async function dequeuePendingMessageForWorkerLoop({
1018
1169
  relayRestartOrchestrator = null,
1019
1170
  inFlightProcessingCount = 0,
1020
1171
  telemetry = null,
1172
+ workerKind = null,
1173
+ onBillingFence = null,
1174
+ claudeApiClaimCheck = null,
1021
1175
  } = {}) {
1022
1176
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1023
1177
  const routingWithWorker = routingEnabled && requesterSid;
1178
+ let billingFenced = null;
1024
1179
  const retryLimit = Math.max(0, Number(transientRetryLimit) || 0);
1025
1180
  const backoffBaseMs = Math.max(1, Number(transientRetryBackoffMs) || 1);
1026
1181
  const supervisor = sessionWorkerSupervisor || null;
@@ -1101,6 +1256,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1101
1256
  requesterSessionId: requesterSid,
1102
1257
  ownerLeaseMs,
1103
1258
  affinityOnly,
1259
+ workerKind,
1260
+ claudeApiClaimCheck,
1261
+ onBillingFence: (fence) => {
1262
+ billingFenced = fence;
1263
+ if (typeof onBillingFence === 'function') onBillingFence(fence);
1264
+ },
1104
1265
  });
1105
1266
  emitWorkerLoopTelemetry(telemetry, {
1106
1267
  event: message ? 'queue.dequeue.success' : 'queue.dequeue.empty',
@@ -1123,7 +1284,9 @@ export async function dequeuePendingMessageForWorkerLoop({
1123
1284
  }
1124
1285
  return {
1125
1286
  message,
1126
- blockedReason: null,
1287
+ // The next row is of another billing than this worker: it waits for
1288
+ // the swap (claude-billing-swap-service.mjs).
1289
+ blockedReason: !message && billingFenced ? 'billing-swap' : null,
1127
1290
  worker: routingWithWorker ? (supervisor?.getWorkerState?.(requesterSid) || null) : null,
1128
1291
  lifecycle: routingWithWorker ? (supervisor?.getLifecycleState?.(requesterSid) || null) : null,
1129
1292
  attempts: attempt + 1,
@@ -1193,6 +1356,9 @@ export function buildDequeuedRelayMessage({
1193
1356
  }
1194
1357
  }
1195
1358
  const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase() || null;
1359
+ // A Claude chat's billing for this row: its stamp, else the chat's. The
1360
+ // worker runs it only when that is its own kind.
1361
+ const billing = normalizeClaudeBilling(msg.provider) || normalizeClaudeBilling(providerType) || null;
1196
1362
  // The Cursor worker declares one subagent per enabled model, because the
1197
1363
  // SDK's built-in subagent model menu is stuck at `inherit` +
1198
1364
  // `composer-2.5-fast` (see cursor-subagent-roster.mjs). Delivered per turn
@@ -1230,6 +1396,7 @@ export function buildDequeuedRelayMessage({
1230
1396
  model: String(msg.model || '').trim() || defaultModel,
1231
1397
  modelVariantId: String(msg.model_variant_id || '').trim() || String(msg.model || '').trim() || null,
1232
1398
  providerType,
1399
+ ...(billing ? { billing } : {}),
1233
1400
  providerModel: String(runtimeSession?.provider_model || '').trim() || null,
1234
1401
  claudeNativeSessionId: String(runtimeSession?.claude_native_session_id || '').trim() || null,
1235
1402
  cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
@@ -1832,6 +1999,8 @@ export function registerMessagesRoutes(app, deps) {
1832
1999
  getCursorProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1833
2000
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1834
2001
  getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
2002
+ getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
2003
+ getClaudeApiProviderSettings = () => ({ enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} }),
1835
2004
  rebindUnstartedOpenAIConversationModel = null,
1836
2005
  normalizeRelayMode,
1837
2006
  DEFAULT_RELAY_MODE,
@@ -1871,6 +2040,27 @@ export function registerMessagesRoutes(app, deps) {
1871
2040
  fetchUsageSummary = null,
1872
2041
  planUsageService = null,
1873
2042
  usageLimitPauseService = null,
2043
+ // The Claude billing switch (claude-billing-switch-service.mjs): the
2044
+ // billing route, and the send-path guards that refuse a switch by send.
2045
+ claudeBillingSwitchService = null,
2046
+ // The Claude API booking (claude-api-usage-service.mjs).
2047
+ claudeApiUsageService = null,
2048
+ // The delivery fence's runtime half for GET /api/pending, as on the
2049
+ // socket: the Claude API claim check (null: no refusal) and what a fenced
2050
+ // row does (the swap, or fail closed).
2051
+ claudeApiClaimCheck = null,
2052
+ onClaudeBillingFence = null,
2053
+ // The Claude API monthly cap's one seam (`{ reached, error? }`; a throw
2054
+ // counts as reached): an agent's send to a chat on the key is refused
2055
+ // while it is reached. Absent in older wiring and most tests: no cap.
2056
+ getClaudeApiCapState = () => ({ reached: false }),
2057
+ // Asks whether the cap flipped after a booked report (the runtime then
2058
+ // re-announces the Claude chats' billing states). A no-op without it.
2059
+ checkClaudeApiCap = () => {},
2060
+ // The notes for self-started turns stopped on the key, once per chat per
2061
+ // episode (plan-usage-claude-api.mjs); the runtime shares its own, which
2062
+ // the cap watch and the settings route clear.
2063
+ claudeApiStopLedger = createClaudeApiStopNoteLedger(),
1874
2064
  getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
1875
2065
  getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
1876
2066
  opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
@@ -1891,6 +2081,10 @@ export function registerMessagesRoutes(app, deps) {
1891
2081
  sessionActivityTracker = null,
1892
2082
  // A worker's SDK session id or a conversation id → the conversation row.
1893
2083
  resolveConversationRecord = null,
2084
+ // DeepSeek's Claude Code config directory, which the relay owns: each CLI
2085
+ // start a DeepSeek worker reports is recorded there for the delete
2086
+ // (claude-session-leftovers.mjs).
2087
+ deepseekClaudeConfigDir = '',
1894
2088
  } = deps;
1895
2089
 
1896
2090
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
@@ -1983,6 +2177,22 @@ export function registerMessagesRoutes(app, deps) {
1983
2177
  if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
1984
2178
  return { kind: 'note', text: value };
1985
2179
  }
2180
+ // Whether a conversation's history holds an image the user attached. Read
2181
+ // only for a DeepSeek model switch; prepared on first use, so a database
2182
+ // without the messages table (tests) is answered with "no".
2183
+ let conversationHasImageStatement = null;
2184
+ function conversationHasImageAttachment(conversationId) {
2185
+ try {
2186
+ conversationHasImageStatement ||= db.prepare(`
2187
+ SELECT 1 FROM messages
2188
+ WHERE conversation_id = ? AND role = 'user' AND attachments LIKE '%"image/%'
2189
+ LIMIT 1
2190
+ `);
2191
+ return !!conversationHasImageStatement.get(conversationId);
2192
+ } catch {
2193
+ return false;
2194
+ }
2195
+ }
1986
2196
  const getMessageAttachmentsByConversation = db.prepare(`
1987
2197
  SELECT attachments
1988
2198
  FROM messages
@@ -2690,6 +2900,92 @@ export function registerMessagesRoutes(app, deps) {
2690
2900
  return buildRelayStopFailure();
2691
2901
  }
2692
2902
 
2903
+ // The key stop's request (`{ source, code, error, episode }`), or null for any other control.
2904
+ function claudeApiCapStopOf(control) {
2905
+ let request = null;
2906
+ try { request = JSON.parse(String(control?.request || 'null')); } catch { request = null; }
2907
+ return request?.source === CLAUDE_API_CAP_STOP_SOURCE ? request : null;
2908
+ }
2909
+
2910
+ // The Claude API claim check for a continuation on the key, failing closed
2911
+ // as the delivery fence does: `{ code, error }` or null. Without the
2912
+ // runtime's check (older wiring, tests) the cap seam alone is asked.
2913
+ function claudeApiContinuationRefusal() {
2914
+ try {
2915
+ if (typeof claudeApiClaimCheck === 'function') return claudeApiClaimCheck() || null;
2916
+ let cap = null;
2917
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
2918
+ return cap?.reached === true
2919
+ ? { code: 'CLAUDE_API_CAP_REACHED', error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} This turn did not run.` }
2920
+ : null;
2921
+ } catch (error) {
2922
+ console.warn(`[claude-billing] Claude API claim check failed for a continuation: ${error?.message || error} — it is stopped`);
2923
+ return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.' };
2924
+ }
2925
+ }
2926
+
2927
+ // The episode of a refusal, for its note (claudeApiStopLedger): the cap's
2928
+ // month and value, or the refusal's code.
2929
+ function claudeApiStopEpisode(code) {
2930
+ if (code !== 'CLAUDE_API_CAP_REACHED') return String(code || 'refused');
2931
+ try {
2932
+ return `cap:${String(getClaudeApiCapState()?.episode || '')}`;
2933
+ } catch {
2934
+ return 'cap:unreadable';
2935
+ }
2936
+ }
2937
+
2938
+ // Stops the live background tasks of a key worker whose self-started turn
2939
+ // is refused: each report would start another such turn, and a billing
2940
+ // switch waits for them. `{ stopped, unreached }`, each `[{ taskId,
2941
+ // label, reason? }]`: the stops sent, and the tasks no stop reaches (not
2942
+ // stoppable, or the worker has no socket to take it).
2943
+ function stopClaudeApiBackgroundTasks(conversationId, sessionId) {
2944
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
2945
+ const workerSessionId = String(sessionId || conversation?.sdk_session_id || conversationId);
2946
+ const keys = [...new Set([conversationId, workerSessionId, String(conversation?.sdk_session_id || '')].filter(Boolean))];
2947
+ const stopped = [];
2948
+ const unreached = [];
2949
+ const seen = new Set();
2950
+ for (const task of keys.flatMap((key) => backgroundTaskStore?.get?.(key) || [])) {
2951
+ const taskId = String(task?.taskId || '').trim();
2952
+ if (!taskId || seen.has(taskId)) continue;
2953
+ seen.add(taskId);
2954
+ const label = String(task?.description || task?.command || task?.taskType || '').trim().replace(/\s+/g, ' ').slice(0, 80) || taskId;
2955
+ if (task?.stoppable === false) {
2956
+ unreached.push({ taskId, label, reason: 'no-stop' });
2957
+ } else if (sendWorkerControl?.(workerSessionId, { type: 'stop_background_task', taskId })) {
2958
+ stopped.push({ taskId, label });
2959
+ } else {
2960
+ unreached.push({ taskId, label, reason: 'worker-not-connected' });
2961
+ }
2962
+ }
2963
+ if (stopped.length || unreached.length) {
2964
+ console.log(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)} background tasks stopped=${stopped.length} unreached=${unreached.length}`);
2965
+ }
2966
+ return { stopped, unreached };
2967
+ }
2968
+
2969
+ /**
2970
+ * The note for a self-started turn on the key the claim check refused,
2971
+ * written when the turn is registered (a short turn can end before the
2972
+ * worker's control poller takes the stop): the refusal, what the stop can
2973
+ * and can't do, the background tasks stopped and those no stop reached,
2974
+ * and the way out, its code last where the transcript's buttons look.
2975
+ */
2976
+ function writeClaudeApiStopNote({ conversationId, relayMode, refusal, tasks, now }) {
2977
+ const text = buildClaudeApiStopNoteText({ refusal, tasks });
2978
+ const noteId = uuidv4();
2979
+ stmts.insertMsg.run(noteId, conversationId, 'assistant', text, null, relayMode, null, now, null, null, null);
2980
+ stmts.updateConvTime?.run?.(now, conversationId);
2981
+ io.emit('assistant_message', {
2982
+ conversationId,
2983
+ messageId: noteId,
2984
+ message: { id: noteId, role: 'assistant', text, model: null, mode: relayMode, attachments: [], timestamp: now },
2985
+ });
2986
+ return noteId;
2987
+ }
2988
+
2693
2989
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
2694
2990
  const id = String(queueMessageId || '').trim();
2695
2991
  if (!id) return 0;
@@ -3477,6 +3773,17 @@ export function registerMessagesRoutes(app, deps) {
3477
3773
  return res.json({ ok: true, control: formatRelayControlResponse(control) });
3478
3774
  });
3479
3775
 
3776
+ /**
3777
+ * A continuation on the key, stopped because the Claude API claim check
3778
+ * refused it: its note was written, and its tasks stopped, when it was
3779
+ * registered, so the row fails quietly.
3780
+ */
3781
+ function settleClaudeApiContinuationStop({ queueRow, queueMessageId }) {
3782
+ if (Number(stmts.dropStaleContinuation?.run?.(queueMessageId)?.changes || 0) > 0) {
3783
+ io.emit('message_status', { messageId: queueMessageId, conversationId: queueRow.conversation_id, status: 'failed' });
3784
+ }
3785
+ }
3786
+
3480
3787
  app.post('/api/control/:controlId/result', auth, (req, res) => {
3481
3788
  const controlId = String(req.params.controlId || '').trim();
3482
3789
  if (!controlId) {
@@ -3539,7 +3846,10 @@ export function registerMessagesRoutes(app, deps) {
3539
3846
  if (String(control.type || '').trim() === 'abort_turn') {
3540
3847
  const queueMessageId = String(control.queue_message_id || '').trim();
3541
3848
  const queueRow = queueMessageId ? (stmts.findQById.get(queueMessageId) || null) : null;
3542
- if (queueRow) {
3849
+ const capStop = queueRow ? claudeApiCapStopOf(control) : null;
3850
+ if (capStop) {
3851
+ settleClaudeApiContinuationStop({ queueRow, queueMessageId });
3852
+ } else if (queueRow) {
3543
3853
  const failureRecord = buildRelayStopFailurePayload();
3544
3854
  failQueueMessage({
3545
3855
  queueRow,
@@ -4447,7 +4757,11 @@ export function registerMessagesRoutes(app, deps) {
4447
4757
  }
4448
4758
 
4449
4759
  // POST /api/message — browser sends a message
4450
- app.post('/api/message', auth, async (req, res) => {
4760
+ // The send route's handler. The billing route runs it too, for an agent's
4761
+ // send that switches the chat's billing with its message: that request
4762
+ // carries `oarBillingSwitch` (set by the route, never read from a body),
4763
+ // and the switch happens in the same transaction as the enqueue.
4764
+ const handleSendMessage = async (req, res) => {
4451
4765
  const {
4452
4766
  messageId: clientMessageId,
4453
4767
  clientId,
@@ -4534,7 +4848,90 @@ export function registerMessagesRoutes(app, deps) {
4534
4848
  : (stmts.getRuntimeSessionByConversation.get(conversationId) || null);
4535
4849
  const configuredOpenAI = getOpenAIProviderSettings();
4536
4850
  const requestedProviderType = normalizeRequestedProviderType(providerType || provider);
4537
- const runtimeProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4851
+ const boundProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
4852
+ // A Claude chat runs on the subscription (`claude`) or the saved API key
4853
+ // (`claude-api`). A send never moves it: the billing route does
4854
+ // (claude-billing-switch-service.mjs). A send that names the other
4855
+ // billing is refused, and so is one from a page that still showed the
4856
+ // other one (`billing`), so nobody's turn runs on a bill they did not
4857
+ // see. A send without `billing` (an older page, an agent) is not checked.
4858
+ const boundClaudeBilling = shouldCreateConversation ? '' : normalizeClaudeBilling(boundProviderType);
4859
+ const billingSwitch = boundClaudeBilling && req.oarBillingSwitch
4860
+ ? { to: normalizeClaudeBilling(req.oarBillingSwitch.to), by: String(req.oarBillingSwitch.by || 'agent') }
4861
+ : null;
4862
+ if (boundClaudeBilling && !billingSwitch) {
4863
+ const namedBilling = normalizeClaudeBilling(requestedProviderType);
4864
+ if (namedBilling && namedBilling !== boundClaudeBilling) {
4865
+ return res.status(409).json({
4866
+ error: `This chat runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Switch its billing first; a send does not.`,
4867
+ code: 'CLAUDE_BILLING_SWITCH_REQUIRED',
4868
+ conversationId,
4869
+ billing: boundClaudeBilling,
4870
+ });
4871
+ }
4872
+ const shownBilling = normalizeClaudeBilling(req.body?.billing);
4873
+ if (shownBilling && shownBilling !== boundClaudeBilling) {
4874
+ // The text is not lost: it goes into the chat's draft (after what is
4875
+ // there), where every page shows it, so a page's own retry and a
4876
+ // replay from the outbox both leave it for the user to send again.
4877
+ const keptText = String(trimmedText || '').trim();
4878
+ const conversationRow = keptText ? (stmts.getConvAnyStatus?.get?.(conversationId) || null) : null;
4879
+ if (conversationRow) {
4880
+ const draft = String(conversationRow.draft_text || '');
4881
+ if (!draft.includes(keptText)) {
4882
+ const keptDraft = draft.trim() ? `${draft.replace(/\s+$/, '')}\n\n${keptText}` : keptText;
4883
+ const keptAt = new Date().toISOString();
4884
+ db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = NULL WHERE id = ?`)
4885
+ .run(keptDraft, keptAt, conversationId);
4886
+ // The draft's attachments stay what they are: the update must not
4887
+ // clear them on the other pages.
4888
+ let keptAttachments = [];
4889
+ try {
4890
+ const parsed = JSON.parse(String(conversationRow.draft_attachments || '[]'));
4891
+ keptAttachments = Array.isArray(parsed) ? parsed : [];
4892
+ } catch {
4893
+ keptAttachments = [];
4894
+ }
4895
+ io.emit('conversation_draft_updated', {
4896
+ conversationId,
4897
+ draftText: keptDraft,
4898
+ draftAttachments: keptAttachments,
4899
+ draftUpdatedAt: keptAt,
4900
+ draftUpdatedByClientId: null,
4901
+ senderClientId: null,
4902
+ });
4903
+ }
4904
+ }
4905
+ return res.status(409).json({
4906
+ error: `This chat now runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Send again to confirm.`,
4907
+ code: 'CLAUDE_BILLING_CHANGED',
4908
+ conversationId,
4909
+ billing: boundClaudeBilling,
4910
+ keptInDraft: true,
4911
+ });
4912
+ }
4913
+ }
4914
+ // A switch with the message: the rest of the send resolves the model,
4915
+ // effort and limits against the billing it switches to.
4916
+ const runtimeProviderType = billingSwitch?.to || boundProviderType;
4917
+ // An agent's send to a chat on the key while this month's cap is reached
4918
+ // is refused before anything is stored, so the agent hears it at once (a
4919
+ // page's send is queued and refused at the claim, with its note there).
4920
+ // A send that switches to the key was refused by the switch's own check;
4921
+ // one that names the key for a chat already on it (a switch that changes
4922
+ // nothing) is refused here like any other.
4923
+ if (remoteRequest.remote && (!billingSwitch || billingSwitch.to === 'claude-api') && runtimeProviderType === 'claude-api') {
4924
+ let cap = null;
4925
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
4926
+ if (cap?.reached === true) {
4927
+ return res.status(409).json({
4928
+ error: `${String(cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} Nothing was queued. Send with provider "claude" to move the session to the Claude subscription, or ask the user to raise the cap.`,
4929
+ code: 'CLAUDE_API_CAP_REACHED',
4930
+ conversationId,
4931
+ billing: 'claude-api',
4932
+ });
4933
+ }
4934
+ }
4538
4935
  const runtimeUsesOpenAI = runtimeProviderType === 'openai';
4539
4936
  // Providers that resell each other's model ids ("claude-opus-5" is served by
4540
4937
  // Claude, Copilot and Cursor alike) make a model id useless for deciding
@@ -4549,7 +4946,9 @@ export function registerMessagesRoutes(app, deps) {
4549
4946
  || runtimeProviderType === 'claude'
4550
4947
  || runtimeProviderType === 'cursor'
4551
4948
  || runtimeProviderType === 'grok'
4552
- || runtimeProviderType === 'claude-cloud';
4949
+ || runtimeProviderType === 'claude-cloud'
4950
+ || runtimeProviderType === 'deepseek'
4951
+ || runtimeProviderType === 'claude-api';
4553
4952
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4554
4953
  const availableOpenAIModels = new Set(
4555
4954
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4673,6 +5072,32 @@ export function registerMessagesRoutes(app, deps) {
4673
5072
  });
4674
5073
  }
4675
5074
  }
5075
+ // DeepSeek conversations are bootstrap-created only too. Its ids are its
5076
+ // own ("deepseek-*"), so an id alone may name it for an unbound session.
5077
+ const runtimeUsesDeepSeek = runtimeProviderType === 'deepseek';
5078
+ if (!shouldCreateConversation && !runtimeUsesDeepSeek) {
5079
+ let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
5080
+ if (
5081
+ !requestTargetsDeepSeek
5082
+ && !runtimeBoundToExplicitProvider
5083
+ && requestedOpenAIModel
5084
+ && !requestedModelAvailableInGitHub
5085
+ ) {
5086
+ const deepseekSettings = getDeepSeekProviderSettings();
5087
+ const deepseekOnlyModels = new Set([
5088
+ String(deepseekSettings?.model || '').trim(),
5089
+ ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [])
5090
+ .map((value) => String(value || '').trim()),
5091
+ ].filter(Boolean));
5092
+ requestTargetsDeepSeek = deepseekSettings?.enabled === true && deepseekOnlyModels.has(requestedOpenAIModel);
5093
+ }
5094
+ if (requestTargetsDeepSeek) {
5095
+ return res.status(409).json({
5096
+ error: 'DeepSeek model selection requires creating a new DeepSeek conversation',
5097
+ code: 'DEEPSEEK_MODEL_REQUIRES_NEW_CONVERSATION',
5098
+ });
5099
+ }
5100
+ }
4676
5101
  // Claude Cloud conversations are bootstrap-created only as well: each
4677
5102
  // carries the repository its cloud session clones. Only an explicit
4678
5103
  // provider counts here — the cloud's model ids are the Claude provider's
@@ -4684,6 +5109,16 @@ export function registerMessagesRoutes(app, deps) {
4684
5109
  code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
4685
5110
  });
4686
5111
  }
5112
+ // Claude API conversations are bootstrap-created only as well, and asked
5113
+ // for by name only: their model ids are the Claude provider's. Moving a
5114
+ // chat between the subscription and the key is not a send's to make here.
5115
+ const runtimeUsesClaudeApi = runtimeProviderType === 'claude-api';
5116
+ if (!shouldCreateConversation && !runtimeUsesClaudeApi && requestedProviderType === 'claude-api') {
5117
+ return res.status(409).json({
5118
+ error: 'Claude API requires creating a new Claude API conversation',
5119
+ code: 'CLAUDE_API_REQUIRES_NEW_CONVERSATION',
5120
+ });
5121
+ }
4687
5122
  if (
4688
5123
  useOpenAIProvider
4689
5124
  && requestedOpenAIModel
@@ -4731,8 +5166,12 @@ export function registerMessagesRoutes(app, deps) {
4731
5166
  // Claude conversations resolve models against the Claude provider
4732
5167
  // catalog (the Copilot catalog does not know these ids). Per-turn model
4733
5168
  // switching is allowed: each Claude turn is a fresh query() with resume.
4734
- const runtimeUsesClaude = runtimeProviderType === 'claude';
4735
- const configuredClaude = runtimeUsesClaude ? getClaudeProviderSettings() : null;
5169
+ // A Claude API conversation runs the same CLI on an API key: the same
5170
+ // rules, against its own model list and efforts.
5171
+ const runtimeUsesClaude = runtimeProviderType === 'claude' || runtimeUsesClaudeApi;
5172
+ const configuredClaude = runtimeUsesClaude
5173
+ ? (runtimeUsesClaudeApi ? getClaudeApiProviderSettings() : getClaudeProviderSettings())
5174
+ : null;
4736
5175
  const requestedClaudeModel = runtimeUsesClaude ? String(model || '').trim() : '';
4737
5176
  const availableClaudeModels = new Set([
4738
5177
  String(configuredClaude?.model || '').trim(),
@@ -4842,6 +5281,45 @@ export function registerMessagesRoutes(app, deps) {
4842
5281
  // sends set_model before the message), so a changed model is taken as
4843
5282
  // for a local Claude chat.
4844
5283
  const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
5284
+ // DeepSeek conversations resolve models against the DeepSeek catalog and
5285
+ // may switch between turns, like Claude ones (the worker's CLI switches
5286
+ // before the next message). A model DeepSeek does not offer is refused.
5287
+ const configuredDeepSeek = runtimeUsesDeepSeek ? getDeepSeekProviderSettings() : null;
5288
+ const pinnedDeepSeekModel = runtimeUsesDeepSeek ? String(existingRuntimeSession?.provider_model || '').trim() : '';
5289
+ const deepseekModelSelection = runtimeUsesDeepSeek
5290
+ ? resolveProviderModelSelection({
5291
+ requestedModel: String(model || '').trim(),
5292
+ configuredModel: pinnedDeepSeekModel || String(configuredDeepSeek?.model || '').trim(),
5293
+ availableModels: [
5294
+ String(configuredDeepSeek?.model || '').trim(),
5295
+ pinnedDeepSeekModel,
5296
+ ...(Array.isArray(configuredDeepSeek?.models) ? configuredDeepSeek.models : []),
5297
+ ].map((value) => String(value || '').trim()).filter(Boolean),
5298
+ })
5299
+ : null;
5300
+ if (deepseekModelSelection && !deepseekModelSelection.ok) {
5301
+ return res.status(400).json({
5302
+ error: `DeepSeek model "${deepseekModelSelection.requestedModel}" is not available`,
5303
+ code: 'DEEPSEEK_MODEL_UNAVAILABLE',
5304
+ supportedModels: deepseekModelSelection.availableModels || [],
5305
+ });
5306
+ }
5307
+ const deepseekModel = deepseekModelSelection ? deepseekModelSelection.model : '';
5308
+ // A model without image input cannot take over a session whose transcript
5309
+ // carries images: the resumed history would fail the whole request.
5310
+ if (
5311
+ runtimeUsesDeepSeek
5312
+ && deepseekModel
5313
+ && pinnedDeepSeekModel
5314
+ && deepseekModel.toLowerCase() !== pinnedDeepSeekModel.toLowerCase()
5315
+ && !deepseekModelAcceptsImages(deepseekModel)
5316
+ && conversationHasImageAttachment(conversationId)
5317
+ ) {
5318
+ return res.status(409).json({
5319
+ error: `${deepseekModelLabel(deepseekModel)} takes no images, and this conversation already has some. Stay on ${deepseekModelLabel(pinnedDeepSeekModel)}, or start a new DeepSeek conversation for ${deepseekModelLabel(deepseekModel)}.`,
5320
+ code: 'DEEPSEEK_MODEL_TEXT_ONLY',
5321
+ });
5322
+ }
4845
5323
  // What the request itself chose. Only these are saved as the
4846
5324
  // conversation's preference; a value filled in below never is.
4847
5325
  const explicitModelGiven = !!requestedOpenAIModel;
@@ -4910,7 +5388,15 @@ export function registerMessagesRoutes(app, deps) {
4910
5388
  reasoningEffort: null,
4911
5389
  error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4912
5390
  }
4913
- : githubModelResolution))));
5391
+ : (runtimeUsesDeepSeek
5392
+ ? {
5393
+ ok: !!deepseekModel,
5394
+ model: deepseekModel,
5395
+ modelVariantId: deepseekModel,
5396
+ reasoningEffort: null,
5397
+ error: deepseekModel ? null : 'DeepSeek model is not configured',
5398
+ }
5399
+ : githubModelResolution)))));
4914
5400
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4915
5401
  const requestedModel = String(modelResolution.model || '').trim();
4916
5402
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4987,6 +5473,16 @@ export function registerMessagesRoutes(app, deps) {
4987
5473
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4988
5474
  return { ok: true, effort, supported };
4989
5475
  };
5476
+ // DeepSeek takes none/low/high/max for every model; other names map to
5477
+ // what DeepSeek would run ("medium" is high), and no stated effort is the
5478
+ // conversation's own, else high, DeepSeek's default.
5479
+ const resolveDeepSeekReasoningEffort = () => {
5480
+ const supported = [...DEEPSEEK_REASONING_EFFORTS];
5481
+ const requestedEffort = deepseekReasoningEffortFor(explicitReasoningEffort);
5482
+ if (requestedEffort) return { ok: true, effort: requestedEffort, supported };
5483
+ const kept = deepseekReasoningEffortFor(conversationReasoningEffort);
5484
+ return { ok: true, effort: kept || DEFAULT_DEEPSEEK_REASONING_EFFORT, supported };
5485
+ };
4990
5486
  let reasoningResolution = useOpenAIProvider
4991
5487
  ? resolveOpenAIReasoningEffort(
4992
5488
  explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
@@ -5001,10 +5497,12 @@ export function registerMessagesRoutes(app, deps) {
5001
5497
  // A cloud session takes no effort: whatever was asked, it is none.
5002
5498
  : (runtimeUsesClaudeCloud
5003
5499
  ? { ok: true, effort: 'none', supported: ['none'] }
5004
- : resolveRequestedReasoningEffort(
5005
- requestedModel,
5006
- explicitReasoningEffort || modelResolution.reasoningEffort || null,
5007
- )))));
5500
+ : (runtimeUsesDeepSeek
5501
+ ? resolveDeepSeekReasoningEffort()
5502
+ : resolveRequestedReasoningEffort(
5503
+ requestedModel,
5504
+ explicitReasoningEffort || modelResolution.reasoningEffort || null,
5505
+ ))))));
5008
5506
  // No effort sent (or the model's variant carried one it lacks): the
5009
5507
  // conversation's own effort, clamped to the model's levels, else its
5010
5508
  // lowest level that is not 'none'. Some models list 'none' and the API
@@ -5040,8 +5538,9 @@ export function registerMessagesRoutes(app, deps) {
5040
5538
  }
5041
5539
  } else if (!['default', 'long_context'].includes(requestedContextTier)) {
5042
5540
  return res.status(400).json({ error: 'Unsupported context tier' });
5043
- } else if (runtimeUsesClaudeCloud) {
5044
- // The cloud has no long-context tier to pick.
5541
+ } else if (runtimeUsesClaudeCloud || runtimeUsesDeepSeek) {
5542
+ // The cloud has no long-context tier to pick, and DeepSeek's one window
5543
+ // is already 1M.
5045
5544
  normalizedContextTier = 'default';
5046
5545
  }
5047
5546
  if (!requestedRelayMode) return res.status(400).json({ error: 'Unsupported relay mode' });
@@ -5212,7 +5711,22 @@ export function registerMessagesRoutes(app, deps) {
5212
5711
  const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
5213
5712
  let conversationPreferences;
5214
5713
  let imageOperation = null;
5714
+ // The switch's announcement, made once the send has committed.
5715
+ let announceBillingSwitch = null;
5215
5716
  const persistMessageAndQueue = db.transaction(() => {
5717
+ // First, so the row below is stamped with the new billing; a refused
5718
+ // switch undoes the whole send.
5719
+ if (billingSwitch) {
5720
+ const switched = claudeBillingSwitchService?.switchBilling?.({
5721
+ conversationId: convId,
5722
+ to: billingSwitch.to,
5723
+ by: billingSwitch.by,
5724
+ model: claudeModel,
5725
+ deferAnnounce: true,
5726
+ }) || { ok: false, status: 501, code: 'CLAUDE_BILLING_UNAVAILABLE', error: 'The billing switch is unavailable' };
5727
+ if (!switched.ok) throw Object.assign(new Error(switched.error), { billingRefusal: switched });
5728
+ announceBillingSwitch = switched.announce || null;
5729
+ }
5216
5730
  stmts.insertMsg.run(
5217
5731
  msgId,
5218
5732
  convId,
@@ -5275,7 +5789,18 @@ export function registerMessagesRoutes(app, deps) {
5275
5789
  && claudeModel !== String(existingRuntimeSession?.provider_model || '').trim()
5276
5790
  && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
5277
5791
  ) {
5278
- stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
5792
+ stmts.updateRuntimeSessionProvider.run(runtimeUsesClaudeApi ? 'claude-api' : 'claude', claudeModel, claudeModel, now, runtimeSession.id);
5793
+ }
5794
+ // DeepSeek follows the composer the same way: a relaunched worker reads
5795
+ // provider_model, so it has to be the model last used.
5796
+ if (
5797
+ runtimeUsesDeepSeek
5798
+ && deepseekModel
5799
+ && runtimeSession?.id
5800
+ && deepseekModel !== String(existingRuntimeSession?.provider_model || '').trim()
5801
+ && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
5802
+ ) {
5803
+ stmts.updateRuntimeSessionProvider.run('deepseek', deepseekModel, deepseekModel, now, runtimeSession.id);
5279
5804
  }
5280
5805
  // A cloud conversation follows the composer like a Claude one: the
5281
5806
  // worker switches the sandbox's model before the message.
@@ -5352,7 +5877,17 @@ export function registerMessagesRoutes(app, deps) {
5352
5877
  });
5353
5878
  try {
5354
5879
  persistMessageAndQueue();
5880
+ try { announceBillingSwitch?.(); } catch {}
5355
5881
  } catch (error) {
5882
+ if (error?.billingRefusal) {
5883
+ const refused = error.billingRefusal;
5884
+ return res.status(refused.status || 409).json({
5885
+ error: refused.error,
5886
+ code: refused.code,
5887
+ conversationId: convId,
5888
+ ...(refused.model ? { model: refused.model } : {}),
5889
+ });
5890
+ }
5356
5891
  // A replayed send (Background Sync outbox) with a caller-supplied id hits
5357
5892
  // the messages.id primary key. Surface it as a clean 409 so the replay
5358
5893
  // can treat "already accepted" as success instead of retrying forever.
@@ -5483,7 +6018,9 @@ export function registerMessagesRoutes(app, deps) {
5483
6018
  messageId: msgId,
5484
6019
  conversationId: convId,
5485
6020
  runtimeSessionId: runtimeSession?.id || null,
5486
- runtimeProviderType: String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6021
+ runtimeProviderType: billingSwitch?.to
6022
+ || String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
6023
+ ...(billingSwitch ? { billingSwitched: true } : {}),
5487
6024
  runtimeProviderModel: runtimeSession?.provider_model || (useOpenAIProvider ? openAIModel : null),
5488
6025
  ownerSessionId: ownerSessionId || null,
5489
6026
  preferredRelayMode: conversationPreferences.preferredRelayMode,
@@ -5509,6 +6046,73 @@ export function registerMessagesRoutes(app, deps) {
5509
6046
  }
5510
6047
  : null,
5511
6048
  });
6049
+ };
6050
+ app.post('/api/message', auth, handleSendMessage);
6051
+
6052
+ // POST /api/conversation/:id/billing — move a Claude chat between the
6053
+ // subscription and the saved API key (`to`: "claude" | "claude-api"): the
6054
+ // composer's toggle, the usage-limit note's button (`by: "usage-limit"`)
6055
+ // and an agent's `send` with a provider (`by: "agent"`). With `message`
6056
+ // (the body /api/message takes), the switch and the enqueue are one
6057
+ // transaction: a send that is refused leaves the billing as it was, and a
6058
+ // refused switch sends nothing. Applies from the next turn; a running one
6059
+ // finishes on its billing.
6060
+ app.post('/api/conversation/:id/billing', auth, async (req, res) => {
6061
+ if (!claudeBillingSwitchService) {
6062
+ return res.status(501).json({ ok: false, error: 'The billing switch is unavailable', code: 'CLAUDE_BILLING_UNAVAILABLE' });
6063
+ }
6064
+ const conversationId = String(req.params?.id || '').trim();
6065
+ const to = normalizeClaudeBilling(req.body?.to ?? req.body?.provider);
6066
+ const byValue = String(req.body?.by || '').trim().toLowerCase();
6067
+ const by = ['user', 'agent', 'usage-limit'].includes(byValue) ? byValue : 'user';
6068
+ const message = req.body?.message && typeof req.body.message === 'object' && !Array.isArray(req.body.message)
6069
+ ? req.body.message
6070
+ : null;
6071
+ // Checked first, so a refusal names the switch's reason before the send's.
6072
+ const check = claudeBillingSwitchService.checkSwitch({ conversationId, to, model: message?.model || '' });
6073
+ if (!check.ok) {
6074
+ return res.status(check.status || 409).json({
6075
+ ok: false,
6076
+ error: check.error,
6077
+ code: check.code,
6078
+ conversationId,
6079
+ ...(check.model ? { model: check.model } : {}),
6080
+ });
6081
+ }
6082
+ if (message) {
6083
+ const sendRequest = Object.create(req);
6084
+ sendRequest.body = {
6085
+ ...message,
6086
+ conversationId,
6087
+ newConversation: false,
6088
+ provider: undefined,
6089
+ providerType: undefined,
6090
+ billing: undefined,
6091
+ };
6092
+ sendRequest.oarBillingSwitch = { to: check.to, by };
6093
+ return handleSendMessage(sendRequest, res);
6094
+ }
6095
+ if (by === 'usage-limit' && check.to !== 'claude-api') {
6096
+ return res.status(400).json({ ok: false, error: 'The usage-limit note switches to the API key only', code: 'CLAUDE_BILLING_INVALID' });
6097
+ }
6098
+ const result = claudeBillingSwitchService.switchBilling({ conversationId, to: check.to, by });
6099
+ if (!result?.ok) {
6100
+ return res.status(result?.status || 409).json({
6101
+ ok: false,
6102
+ error: result?.error || 'The billing switch failed',
6103
+ code: result?.code || null,
6104
+ conversationId,
6105
+ ...(result?.model ? { model: result.model } : {}),
6106
+ });
6107
+ }
6108
+ return res.json({
6109
+ ok: true,
6110
+ conversationId,
6111
+ changed: result.changed,
6112
+ provider: result.provider,
6113
+ released: !!result.released,
6114
+ billing: claudeBillingSwitchService.billingState(conversationId),
6115
+ });
5512
6116
  });
5513
6117
 
5514
6118
  app.post('/api/heartbeat', auth, (req, res) => {
@@ -5574,7 +6178,11 @@ export function registerMessagesRoutes(app, deps) {
5574
6178
  || (stored.supported === true) !== (steering.supported === true)
5575
6179
  || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
5576
6180
  // The running CLI's compaction window (context-usage modal).
5577
- || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
6181
+ || JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null)
6182
+ // Whether a billing swap may replace the worker now, and why not.
6183
+ || (stored.releasable ?? null) !== (steering.releasable ?? null)
6184
+ || (stored.releaseHold || null) !== (steering.releaseHold || null)
6185
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
5578
6186
  if (workerForSteering && changed) {
5579
6187
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
5580
6188
  // Pushed rather than left for the clients' status poll: the
@@ -5583,6 +6191,15 @@ export function registerMessagesRoutes(app, deps) {
5583
6191
  io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
5584
6192
  }
5585
6193
  }
6194
+ // The worker's kind (a Claude chat's billing runs only on its own kind):
6195
+ // recorded when it says one and the registry has another.
6196
+ const heartbeatKind = normalizeWorkerKind(req.body?.workerKind);
6197
+ if (heartbeatKind) {
6198
+ const workerForKind = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6199
+ if (workerForKind && workerForKind.kind !== heartbeatKind) {
6200
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6201
+ }
6202
+ }
5586
6203
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5587
6204
  // The worker's digest of its live background-task ids against the set
5588
6205
  // held for it (keyed as the worker posts, by its session id). They
@@ -5784,7 +6401,10 @@ export function registerMessagesRoutes(app, deps) {
5784
6401
  });
5785
6402
 
5786
6403
  // POST /api/claude-native-session — Claude worker persists the native Agent
5787
- // SDK session id so later turns can resume it across worker restarts.
6404
+ // SDK session id so later turns can resume it across worker restarts. A
6405
+ // DeepSeek worker also reports each CLI start (`cliStartedAt`, ms), which
6406
+ // the relay records so deleting the conversation finds every CLI's shell
6407
+ // snapshot.
5788
6408
  app.post('/api/claude-native-session', auth, (req, res) => {
5789
6409
  touchCli();
5790
6410
  const conversationId = String(req.body?.conversationId || '').trim();
@@ -5796,7 +6416,11 @@ export function registerMessagesRoutes(app, deps) {
5796
6416
  if (!runtimeSession) {
5797
6417
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5798
6418
  }
5799
- if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'claude') {
6419
+ // The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
6420
+ // its sessions the same way; the Claude API worker is the Claude one on
6421
+ // an API key.
6422
+ const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
6423
+ if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek' && nativeSessionProvider !== 'claude-api') {
5800
6424
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5801
6425
  }
5802
6426
  if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
@@ -5807,6 +6431,10 @@ export function registerMessagesRoutes(app, deps) {
5807
6431
  new Date().toISOString(),
5808
6432
  conversationId,
5809
6433
  );
6434
+ const cliStartedAt = Number(req.body?.cliStartedAt);
6435
+ if (nativeSessionProvider === 'deepseek' && Number.isFinite(cliStartedAt) && cliStartedAt > 0 && cliStartedAt <= Date.now() + 60_000) {
6436
+ recordClaudeCliStart({ configRoot: deepseekClaudeConfigDir, nativeSessionId: claudeNativeSessionId, startedAt: cliStartedAt });
6437
+ }
5810
6438
  res.json({ ok: true });
5811
6439
  });
5812
6440
 
@@ -5834,9 +6462,10 @@ export function registerMessagesRoutes(app, deps) {
5834
6462
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5835
6463
  }
5836
6464
  // The Claude Cloud worker reports the same shape (the cloud session's
5837
- // context and token usage), so both providers are taken here.
6465
+ // context and token usage), and so do the DeepSeek and Claude API ones
6466
+ // (the same CLI).
5838
6467
  const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5839
- if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud') {
6468
+ if (!['claude', 'claude-cloud', 'deepseek', 'claude-api'].includes(contextUsageProvider)) {
5840
6469
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5841
6470
  }
5842
6471
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -5883,6 +6512,76 @@ export function registerMessagesRoutes(app, deps) {
5883
6512
  res.json({ ok: true, cycle: applied.cycle.key });
5884
6513
  });
5885
6514
 
6515
+ // POST /api/deepseek-plan-usage — DeepSeek worker reports, after a turn,
6516
+ // the CLI's running token totals per model (cache hit, cache miss, output)
6517
+ // for its Claude Code session, with the time and the id of the CLI
6518
+ // process (`runId`). The relay books what they add to the totals last
6519
+ // reported for that session (a resumed CLI starts from its session's
6520
+ // earlier totals), with the estimated cost at the price of that time
6521
+ // (shared/deepseek-pricing.mjs). Totals below the checkpoint under the
6522
+ // checkpoint's run id are a stale report (retried or out of order) and
6523
+ // book nothing; under another run id, or without one, they are a CLI
6524
+ // counting from zero again, booked in full. The web searches and fetches
6525
+ // come as `requests`: with a run id the process's running counts, booked
6526
+ // like the tokens; without one the turn's counts, booked as they are.
6527
+ // Priced from 2026-10-31 16:00 UTC.
6528
+ app.post('/api/deepseek-plan-usage', auth, (req, res) => {
6529
+ touchCli();
6530
+ if (!planUsageService?.recordDeepSeekUsageReport) {
6531
+ return res.status(500).json({ error: 'Plan usage storage is unavailable' });
6532
+ }
6533
+ const conversationId = String(req.body?.conversationId || '').trim();
6534
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
6535
+ const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
6536
+ if (!runtimeSession) {
6537
+ return res.status(404).json({ error: 'Runtime session not found for conversation' });
6538
+ }
6539
+ if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'deepseek') {
6540
+ return res.status(409).json({ error: 'Conversation is not bound to the DeepSeek provider' });
6541
+ }
6542
+ const parsed = normalizeDeepSeekUsageReport(req.body);
6543
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6544
+ const booked = planUsageService.recordDeepSeekUsageReport({ ...parsed.report, conversationId });
6545
+ if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
6546
+ res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
6547
+ });
6548
+
6549
+ // POST /api/claude-api-usage — both Claude workers (the subscription's and
6550
+ // the Claude API one) report, after every turn, the CLI process's own
6551
+ // totals per model with Claude Code's cost estimate (`runCounts`) and the
6552
+ // CLI's session totals (`sessionTotals`). The relay books what turns on
6553
+ // the key cost (claude-api-usage-service.mjs); a subscription report only
6554
+ // keeps the session's mark, against which a resumed run's first result is
6555
+ // measured. What a report ran on is the posting worker's registered kind;
6556
+ // a body that says otherwise is logged as a fence failure.
6557
+ app.post('/api/claude-api-usage', auth, (req, res) => {
6558
+ touchCli();
6559
+ if (!claudeApiUsageService) return res.status(501).json({ error: 'Claude API usage storage is unavailable' });
6560
+ const conversationId = String(req.body?.conversationId || '').trim();
6561
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
6562
+ const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
6563
+ if (!runtimeSession) return res.status(404).json({ error: 'Runtime session not found for conversation' });
6564
+ if (!normalizeClaudeBilling(runtimeSession.provider_type)) {
6565
+ return res.status(409).json({ error: 'Conversation is not a Claude conversation' });
6566
+ }
6567
+ const parsed = normalizeClaudeApiUsageReport(req.body);
6568
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6569
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6570
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6571
+ const statedKind = normalizeClaudeBilling(parsed.report.billing);
6572
+ if (registeredKind && statedKind && registeredKind !== statedKind) {
6573
+ console.warn(`[${ts()}] BILLING FENCE FAILURE usage conv=${conversationId.slice(0, 8)} stated=${statedKind} registered=${registeredKind} — booked as the registered kind`);
6574
+ }
6575
+ const billing = registeredKind || statedKind;
6576
+ if (!billing) return res.status(400).json({ error: 'Missing billing' });
6577
+ const booked = claudeApiUsageService.recordReport({ conversationId, billing, report: parsed.report });
6578
+ if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
6579
+ if (booked.booked.length) {
6580
+ try { checkClaudeApiCap('usage'); } catch {}
6581
+ }
6582
+ res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6583
+ });
6584
+
5886
6585
  // POST /api/claude-plan-usage — Claude worker reports the session's
5887
6586
  // structured /usage data (plan rate-limit windows + session cost totals).
5888
6587
  //
@@ -6146,6 +6845,15 @@ export function registerMessagesRoutes(app, deps) {
6146
6845
  });
6147
6846
 
6148
6847
  // GET /api/pending — CLI fetches next pending message
6848
+ // The kind a poller of GET /api/pending is fenced as: the one its worker
6849
+ // stated, else none, as on the socket. A worker of unknown kind gets the
6850
+ // rows of a chat that never switched, and none of a chat that has
6851
+ // (kind-unknown): such a chat waits for a worker that says its kind.
6852
+ function httpPendingWorkerKind(requesterSessionId) {
6853
+ const stated = requesterSessionId ? sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind : null;
6854
+ return normalizeWorkerKind(stated) || null;
6855
+ }
6856
+
6149
6857
  app.get('/api/pending', auth, async (req, res) => {
6150
6858
  touchCli();
6151
6859
  const requester = readBridgeIdentity(req);
@@ -6273,6 +6981,12 @@ export function registerMessagesRoutes(app, deps) {
6273
6981
  sessionWorkerSupervisor,
6274
6982
  relayRestartOrchestrator,
6275
6983
  inFlightProcessingCount: Number(counts.processingCount || 0),
6984
+ // No Claude worker polls here; one that does is fenced as on the
6985
+ // socket: by the kind it stated, the key's claim check, and a fenced
6986
+ // row asks for the swap or fails closed as there.
6987
+ workerKind: httpPendingWorkerKind(requesterSessionId),
6988
+ claudeApiClaimCheck,
6989
+ onBillingFence: onClaudeBillingFence,
6276
6990
  telemetry: (payload = {}) => {
6277
6991
  if (!sessionWorkerRoutingEnabled) return;
6278
6992
  emitSessionWorkerTelemetry(payload.event, {
@@ -6490,6 +7204,9 @@ export function registerMessagesRoutes(app, deps) {
6490
7204
  sessionWorkerSupervisor,
6491
7205
  relayRestartOrchestrator,
6492
7206
  inFlightProcessingCount: Number(queueCounts()?.processingCount || 0),
7207
+ workerKind: httpPendingWorkerKind(requesterSessionId),
7208
+ claudeApiClaimCheck,
7209
+ onBillingFence: onClaudeBillingFence,
6493
7210
  });
6494
7211
  if (recoveryDequeue?.message) {
6495
7212
  msg = recoveryDequeue.message;
@@ -6795,7 +7512,10 @@ export function registerMessagesRoutes(app, deps) {
6795
7512
  body: JSON.stringify({
6796
7513
  messageId: operation.queue_message_id,
6797
7514
  conversationId: operation.conversation_id,
6798
- terminalFailure: {
7515
+ // `terminalError` is the field /api/response reads; under any other
7516
+ // name the failure was refused as an empty reply and the row stayed
7517
+ // processing until the relay recovered and re-ran it.
7518
+ terminalError: {
6799
7519
  code: 'IMAGE_ATTEMPT_INTERRUPTED',
6800
7520
  message: 'Image generation may have completed before the relay was interrupted. It was not retried to avoid duplicate images.',
6801
7521
  retryable: false,
@@ -6956,7 +7676,7 @@ export function registerMessagesRoutes(app, deps) {
6956
7676
  body: JSON.stringify({
6957
7677
  messageId: operation.queue_message_id,
6958
7678
  conversationId: operation.conversation_id,
6959
- terminalFailure: failure,
7679
+ terminalError: failure,
6960
7680
  }),
6961
7681
  }).catch(() => null);
6962
7682
  if (!failResponse?.ok) {
@@ -7207,7 +7927,12 @@ export function registerMessagesRoutes(app, deps) {
7207
7927
  ? (usageLimitPauseService?.planPause?.({
7208
7928
  terminalError: req.body.terminalError,
7209
7929
  conversationId: targetConversationId,
7210
- providerType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7930
+ // What the refused turn ran on (its row's billing), and the chat's
7931
+ // billing of now: a chat switched to the API key meanwhile carries on
7932
+ // there instead of waiting for the subscription's reset.
7933
+ providerType: normalizeClaudeBilling(q?.provider)
7934
+ || stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7935
+ chatProviderType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
7211
7936
  queueRow: q,
7212
7937
  partialText: req.body?.partialText,
7213
7938
  }) || null)
@@ -7327,6 +8052,7 @@ export function registerMessagesRoutes(app, deps) {
7327
8052
  const conversationProvider = String(
7328
8053
  stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type || '',
7329
8054
  ).trim().toLowerCase() || 'github';
8055
+ const responderKind = sessionWorkerRegistry?.getWorker?.(normalizeSessionWorkerId(responseBridgeIdentity?.sessionId))?.kind || null;
7330
8056
  const executedProvider = resolveExecutedProviderForResponse({
7331
8057
  stmts,
7332
8058
  responseBridgeIdentity,
@@ -7334,13 +8060,28 @@ export function registerMessagesRoutes(app, deps) {
7334
8060
  // Image operations are executed by this server against the conversation's
7335
8061
  // own provider API, then finalized through an identity-less self-post.
7336
8062
  serverExecutedOperation: !!q?.image_operation_id,
8063
+ responderKind,
7337
8064
  });
8065
+ // A Claude chat's row ran on a worker of another billing than it was
8066
+ // stamped with: the delivery fence failed. What ran is recorded.
8067
+ const stampedBilling = normalizeClaudeBilling(q?.provider);
8068
+ const ranBilling = normalizeClaudeBilling(executedProvider);
8069
+ // A claimed turn on the key answered (it passed the claim check): the
8070
+ // next refusal of a self-started turn there is a new episode, with its
8071
+ // own note. A continuation that slipped through before its stop is not.
8072
+ if (ranBilling === 'claude-api' && String(q?.kind || '') !== 'continuation') claudeApiStopLedger.clear(targetConversationId);
8073
+ if (stampedBilling && ranBilling && stampedBilling !== ranBilling) {
8074
+ console.warn(`[${ts()}] BILLING FENCE FAILURE ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} stamped=${stampedBilling} ran=${ranBilling} — a Claude turn ran on the other billing`);
8075
+ }
8076
+ // A Claude chat that switched billing mid-turn finished that turn on the
8077
+ // billing it started with: not a mismatch.
8078
+ const switchedClaudeTurn = ranBilling && normalizeClaudeBilling(conversationProvider) && (!stampedBilling || stampedBilling === ranBilling);
7338
8079
  if (executedProvider === 'unknown') {
7339
8080
  // An identity that names no runtime session is itself an anomaly (a stale
7340
8081
  // or forged header), just not evidence of cross-provider execution. Say so
7341
8082
  // rather than staying silent.
7342
8083
  console.warn(`[${ts()}] PROVIDER UNRESOLVED ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} bound=${conversationProvider} responder=${normalizeSessionWorkerId(responseBridgeIdentity?.sessionId)?.slice(0, 8) || 'none'} — responder identity matches no runtime session`);
7343
- } else if (executedProvider !== conversationProvider) {
8084
+ } else if (executedProvider !== conversationProvider && !switchedClaudeTurn) {
7344
8085
  console.warn(`[${ts()}] PROVIDER MISMATCH ${messageId?.slice(0, 8)} conv=${targetConversationId?.slice(0, 8)} bound=${conversationProvider} executed=${executedProvider} — turn answered by the wrong provider`);
7345
8086
  }
7346
8087
  const responderConversationCheck = resolveResponderConversationMismatch({
@@ -7697,8 +8438,9 @@ export function registerMessagesRoutes(app, deps) {
7697
8438
  // worker (detached background shells) serving both Copilot-CLI providers.
7698
8439
  // A Claude Cloud session can take a turn of its own too: one the agent
7699
8440
  // starts when its background work reports back, or one started on
7700
- // claude.ai; the cloud worker follows it and registers it here.
7701
- const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud']);
8441
+ // claude.ai; the cloud worker follows it and registers it here. The Claude
8442
+ // API worker is the Claude one on an API key.
8443
+ const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud', 'claude-api']);
7702
8444
 
7703
8445
  // POST /api/continuation-turn — a session worker's CLI started a turn on its
7704
8446
  // own (a background task's notification). The synthetic queue row gives that
@@ -7751,7 +8493,35 @@ export function registerMessagesRoutes(app, deps) {
7751
8493
  const attemptId = uuidv4();
7752
8494
  const now = new Date().toISOString();
7753
8495
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
7754
- try {
8496
+ // A Claude chat's continuation runs on the worker that started it,
8497
+ // whose billing may no longer be the chat's (a switch waiting for this
8498
+ // very background work): stamped with that worker's kind.
8499
+ const continuationBilling = normalizeClaudeBilling(sessionWorkerRegistry?.getWorker?.(ownerSessionId)?.kind);
8500
+ // A turn the CLI starts by itself on the key passes no claim, so the
8501
+ // claim check (the cap, the key, the switch) is asked here: when it
8502
+ // refuses, the row is still minted (the worker needs its id), with a
8503
+ // stop for it that the worker's control poller picks up; the stop's
8504
+ // result stops the worker's background tasks and fails the row.
8505
+ let capStop = null;
8506
+ if ((continuationBilling || normalizeClaudeBilling(providerType)) === 'claude-api') {
8507
+ const refusal = claudeApiContinuationRefusal();
8508
+ if (refusal) {
8509
+ const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
8510
+ capStop = {
8511
+ source: CLAUDE_API_CAP_STOP_SOURCE,
8512
+ code,
8513
+ error: String(refusal.error || '').trim() || 'Claude API is unavailable, so this turn did not run.',
8514
+ // Its note says to switch Claude API on rather than to set it up.
8515
+ ...(refusal.switchedOff === true ? { switchedOff: true } : {}),
8516
+ episode: claudeApiStopEpisode(code),
8517
+ };
8518
+ } else {
8519
+ claudeApiStopLedger.clear(conversationId);
8520
+ }
8521
+ }
8522
+ // The row, its stamp and a cap stop commit together (a database without
8523
+ // transactions, a test's stand-in, runs them in order).
8524
+ const insertContinuationRow = () => {
7755
8525
  stmts.insertContinuationQ.run(
7756
8526
  messageId,
7757
8527
  conversationId,
@@ -7768,6 +8538,14 @@ export function registerMessagesRoutes(app, deps) {
7768
8538
  attemptId,
7769
8539
  operationId,
7770
8540
  );
8541
+ if (continuationBilling) stmts.setQueueProvider?.run?.(continuationBilling, messageId);
8542
+ if (capStop) {
8543
+ insertRelayControlRequest.run(uuidv4(), 'abort_turn', conversationId, messageId, ownerSessionId, JSON.stringify(capStop), now, now);
8544
+ }
8545
+ };
8546
+ const insertContinuation = typeof db.transaction === 'function' ? db.transaction(insertContinuationRow) : insertContinuationRow;
8547
+ try {
8548
+ insertContinuation();
7771
8549
  } catch (error) {
7772
8550
  // Lost the race against a concurrent duplicate registration: the unique
7773
8551
  // index on continuation_op_id guarantees the winner's row exists now.
@@ -7777,7 +8555,20 @@ export function registerMessagesRoutes(app, deps) {
7777
8555
  }
7778
8556
  throw error;
7779
8557
  }
7780
- console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}${operationId ? ` op=${operationId.slice(0, 8)}` : ''}`);
8558
+ console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}${operationId ? ` op=${operationId.slice(0, 8)}` : ''}${capStop ? ' stopped=claude-api-cap' : ''}`);
8559
+ if (capStop) {
8560
+ // Not left to the stop: a turn that ends before the worker's control
8561
+ // poller takes it still gets its tasks stopped and the note.
8562
+ try {
8563
+ const tasks = stopClaudeApiBackgroundTasks(conversationId, ownerSessionId);
8564
+ if (!claudeApiStopLedger.has(conversationId, capStop.episode)) {
8565
+ writeClaudeApiStopNote({ conversationId, relayMode, refusal: capStop, tasks, now });
8566
+ claudeApiStopLedger.note(conversationId, capStop.episode);
8567
+ }
8568
+ } catch (error) {
8569
+ console.warn(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)}: ${error?.message || error}`);
8570
+ }
8571
+ }
7781
8572
  io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
7782
8573
  io.emit('queue_updated', { continuation: 1 });
7783
8574
  res.json({ ok: true, messageId, conversationId, attemptId });
@@ -8031,7 +8822,7 @@ export function registerMessagesRoutes(app, deps) {
8031
8822
  // The providers whose workers name a shell task's output (a file, a spool
8032
8823
  // or a snapshot). A task of theirs without one comes from a worker that
8033
8824
  // started before the console existed.
8034
- const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
8825
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek', 'claude-api']);
8035
8826
  const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
8036
8827
 
8037
8828
  // Why a task has no output to show, for the console's `none` answer. The
@@ -8079,7 +8870,7 @@ export function registerMessagesRoutes(app, deps) {
8079
8870
  // 256 KB; with `before` (a startOffset of an earlier answer): the lines
8080
8871
  // ending there. Everything is cleaned (controls out, colours kept,
8081
8872
  // credentials scrubbed) for every reader. Another relay's agent reads it
8082
- // too (remote_relay read_task_output, read permission): no inbound switch,
8873
+ // too (relay_tool read_task_output, read permission): no inbound switch,
8083
8874
  // nothing it reads changes anything.
8084
8875
  app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
8085
8876
  const conversationId = String(req.params.conversationId || '').trim();
@@ -8770,12 +9561,16 @@ export function registerMessagesRoutes(app, deps) {
8770
9561
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
8771
9562
  return res.json({ ok: true, mismatch: true });
8772
9563
  }
8773
- if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
9564
+ const handBackClass = String(req.body?.class || '').trim();
9565
+ if (q && q.status === 'processing' && (handBackClass === 'steering-held' || handBackClass === 'kind-mismatch')) {
8774
9566
  // The worker refused to push the message because its live turn is
8775
9567
  // holding steering (an open question card, a compaction, an adoption).
8776
9568
  // Not a failure: no retry increment, no backoff, and the owner is kept —
8777
9569
  // the same worker takes it again the moment it signals ready, which is
8778
9570
  // when the hold ends, so the message steers into the resumed turn.
9571
+ // `kind-mismatch`: a Claude chat's row reached a worker of the other
9572
+ // billing; back to pending (re-stamped to the chat's billing by the
9573
+ // queue trigger) for the worker the billing swap brings up.
8779
9574
  const result = db.prepare(`
8780
9575
  UPDATE queue
8781
9576
  SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
@@ -8785,7 +9580,7 @@ export function registerMessagesRoutes(app, deps) {
8785
9580
  settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8786
9581
  settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
8787
9582
  io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
8788
- console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
9583
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=${handBackClass} retry=${Number(q.retry_count || 0)}`);
8789
9584
  }
8790
9585
  return res.json({ ok: true, held: true });
8791
9586
  }