@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4735
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
5005
|
-
|
|
5006
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
}
|