@oar-sh/oar 0.9.17 → 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/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +192 -0
- package/README.md +13 -12
- package/package.json +1 -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-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 +35 -9
- package/server/claude-worker/claude-session-process.mjs +166 -21
- package/server/claude-worker/claude-session-worker-main.mjs +3 -0
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-publisher.mjs +117 -5
- package/server/claude-worker/claude-usage-totals.mjs +53 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
- 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 +33 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
- 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/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 +52 -0
- package/server/public/app/bootstrap.js +153 -27
- 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 +6 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +11 -0
- package/server/public/app/conversation-view.js +29 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +127 -10
- package/server/public/app/model-selector-options.mjs +17 -1
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/provider-readiness.mjs +5 -2
- package/server/public/app/relay-error-ctas.mjs +50 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -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 +189 -3
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +1 -1
- 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 +1 -1
- package/server/routes/messages-routes.mjs +645 -27
- 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 +327 -29
- package/server/server-runtime.mjs +363 -26
- package/server/services/about-service.mjs +1 -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/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-service.mjs +5 -0
- package/server/services/provider-model-selection.mjs +3 -2
- package/server/services/provider-reasoning-effort.mjs +9 -0
- 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 +83 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +61 -2
- 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 +2 -0
- package/shared/anthropic-api.mjs +202 -0
- 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 +1 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/heartbeat.mjs +5 -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 {
|
|
@@ -81,6 +81,9 @@ import {
|
|
|
81
81
|
} from '../../shared/deepseek-models.mjs';
|
|
82
82
|
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
83
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';
|
|
84
87
|
import {
|
|
85
88
|
admitRemoteRelayRequest,
|
|
86
89
|
readRemoteRelayRequest,
|
|
@@ -161,6 +164,8 @@ function normalizeRequestedProviderType(value = '') {
|
|
|
161
164
|
// Its own provider, never read as `claude`.
|
|
162
165
|
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
163
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';
|
|
164
169
|
return '';
|
|
165
170
|
}
|
|
166
171
|
|
|
@@ -170,6 +175,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
|
|
|
170
175
|
claude: 'Claude',
|
|
171
176
|
'claude-cloud': 'Claude Cloud',
|
|
172
177
|
deepseek: 'DeepSeek',
|
|
178
|
+
'claude-api': 'Claude API',
|
|
173
179
|
});
|
|
174
180
|
|
|
175
181
|
function deriveModelOrigin(model) {
|
|
@@ -867,9 +873,16 @@ export function resolveExecutedProviderForResponse({
|
|
|
867
873
|
responseBridgeIdentity,
|
|
868
874
|
conversationProvider = null,
|
|
869
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,
|
|
870
881
|
} = {}) {
|
|
871
882
|
const boundProvider = String(conversationProvider || '').trim().toLowerCase() || 'github';
|
|
872
883
|
const responderSessionId = normalizeSessionWorkerId(responseBridgeIdentity?.sessionId);
|
|
884
|
+
const responderBilling = normalizeClaudeBilling(responderKind);
|
|
885
|
+
if (responderSessionId && responderBilling) return responderBilling;
|
|
873
886
|
if (responderSessionId) {
|
|
874
887
|
const runtimeSession = stmts?.getRuntimeSessionBySdkSessionId?.get?.(responderSessionId) || null;
|
|
875
888
|
const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase();
|
|
@@ -912,10 +925,21 @@ export function dequeuePendingMessage({
|
|
|
912
925
|
requesterSessionId = null,
|
|
913
926
|
ownerLeaseMs = SESSION_WORKER_OWNER_LEASE_MS,
|
|
914
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,
|
|
915
938
|
} = {}) {
|
|
916
939
|
const currentIso = String(nowIso || '').trim() || new Date().toISOString();
|
|
917
940
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
918
941
|
const leaseExpiresAt = requesterSid ? addMsToIso(currentIso, ownerLeaseMs) : null;
|
|
942
|
+
let fenced = null;
|
|
919
943
|
const dequeue = db.transaction(() => {
|
|
920
944
|
let next = null;
|
|
921
945
|
if (routingEnabled && requesterSid && stmts.findPendingForWorker) {
|
|
@@ -927,6 +951,10 @@ export function dequeuePendingMessage({
|
|
|
927
951
|
} else if (!routingEnabled && requesterSid && stmts.findPendingForSessionAffinity) {
|
|
928
952
|
next = stmts.findPendingForSessionAffinity.get(currentIso, requesterSid);
|
|
929
953
|
}
|
|
954
|
+
if (next && requesterSid) {
|
|
955
|
+
fenced = claudeBillingFence({ stmts, row: next, workerKind, claudeApiClaimCheck });
|
|
956
|
+
if (fenced) return null;
|
|
957
|
+
}
|
|
930
958
|
if (!next && affinityOnly) return null;
|
|
931
959
|
if (!next) {
|
|
932
960
|
// Requesters that reach the global fallback carry no worker identity
|
|
@@ -963,7 +991,119 @@ export function dequeuePendingMessage({
|
|
|
963
991
|
attempt_id: attemptId,
|
|
964
992
|
};
|
|
965
993
|
});
|
|
966
|
-
|
|
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;
|
|
967
1107
|
}
|
|
968
1108
|
|
|
969
1109
|
// A requeue for a turn that produced nothing and was only briefly in flight is
|
|
@@ -1029,9 +1169,13 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1029
1169
|
relayRestartOrchestrator = null,
|
|
1030
1170
|
inFlightProcessingCount = 0,
|
|
1031
1171
|
telemetry = null,
|
|
1172
|
+
workerKind = null,
|
|
1173
|
+
onBillingFence = null,
|
|
1174
|
+
claudeApiClaimCheck = null,
|
|
1032
1175
|
} = {}) {
|
|
1033
1176
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
1034
1177
|
const routingWithWorker = routingEnabled && requesterSid;
|
|
1178
|
+
let billingFenced = null;
|
|
1035
1179
|
const retryLimit = Math.max(0, Number(transientRetryLimit) || 0);
|
|
1036
1180
|
const backoffBaseMs = Math.max(1, Number(transientRetryBackoffMs) || 1);
|
|
1037
1181
|
const supervisor = sessionWorkerSupervisor || null;
|
|
@@ -1112,6 +1256,12 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1112
1256
|
requesterSessionId: requesterSid,
|
|
1113
1257
|
ownerLeaseMs,
|
|
1114
1258
|
affinityOnly,
|
|
1259
|
+
workerKind,
|
|
1260
|
+
claudeApiClaimCheck,
|
|
1261
|
+
onBillingFence: (fence) => {
|
|
1262
|
+
billingFenced = fence;
|
|
1263
|
+
if (typeof onBillingFence === 'function') onBillingFence(fence);
|
|
1264
|
+
},
|
|
1115
1265
|
});
|
|
1116
1266
|
emitWorkerLoopTelemetry(telemetry, {
|
|
1117
1267
|
event: message ? 'queue.dequeue.success' : 'queue.dequeue.empty',
|
|
@@ -1134,7 +1284,9 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1134
1284
|
}
|
|
1135
1285
|
return {
|
|
1136
1286
|
message,
|
|
1137
|
-
|
|
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,
|
|
1138
1290
|
worker: routingWithWorker ? (supervisor?.getWorkerState?.(requesterSid) || null) : null,
|
|
1139
1291
|
lifecycle: routingWithWorker ? (supervisor?.getLifecycleState?.(requesterSid) || null) : null,
|
|
1140
1292
|
attempts: attempt + 1,
|
|
@@ -1204,6 +1356,9 @@ export function buildDequeuedRelayMessage({
|
|
|
1204
1356
|
}
|
|
1205
1357
|
}
|
|
1206
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;
|
|
1207
1362
|
// The Cursor worker declares one subagent per enabled model, because the
|
|
1208
1363
|
// SDK's built-in subagent model menu is stuck at `inherit` +
|
|
1209
1364
|
// `composer-2.5-fast` (see cursor-subagent-roster.mjs). Delivered per turn
|
|
@@ -1241,6 +1396,7 @@ export function buildDequeuedRelayMessage({
|
|
|
1241
1396
|
model: String(msg.model || '').trim() || defaultModel,
|
|
1242
1397
|
modelVariantId: String(msg.model_variant_id || '').trim() || String(msg.model || '').trim() || null,
|
|
1243
1398
|
providerType,
|
|
1399
|
+
...(billing ? { billing } : {}),
|
|
1244
1400
|
providerModel: String(runtimeSession?.provider_model || '').trim() || null,
|
|
1245
1401
|
claudeNativeSessionId: String(runtimeSession?.claude_native_session_id || '').trim() || null,
|
|
1246
1402
|
cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
|
|
@@ -1844,6 +2000,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1844
2000
|
getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
|
|
1845
2001
|
getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
|
|
1846
2002
|
getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
|
|
2003
|
+
getClaudeApiProviderSettings = () => ({ enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} }),
|
|
1847
2004
|
rebindUnstartedOpenAIConversationModel = null,
|
|
1848
2005
|
normalizeRelayMode,
|
|
1849
2006
|
DEFAULT_RELAY_MODE,
|
|
@@ -1883,6 +2040,27 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1883
2040
|
fetchUsageSummary = null,
|
|
1884
2041
|
planUsageService = null,
|
|
1885
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(),
|
|
1886
2064
|
getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
|
|
1887
2065
|
getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
|
|
1888
2066
|
opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
|
|
@@ -2722,6 +2900,92 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2722
2900
|
return buildRelayStopFailure();
|
|
2723
2901
|
}
|
|
2724
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
|
+
|
|
2725
2989
|
function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
|
|
2726
2990
|
const id = String(queueMessageId || '').trim();
|
|
2727
2991
|
if (!id) return 0;
|
|
@@ -3509,6 +3773,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3509
3773
|
return res.json({ ok: true, control: formatRelayControlResponse(control) });
|
|
3510
3774
|
});
|
|
3511
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
|
+
|
|
3512
3787
|
app.post('/api/control/:controlId/result', auth, (req, res) => {
|
|
3513
3788
|
const controlId = String(req.params.controlId || '').trim();
|
|
3514
3789
|
if (!controlId) {
|
|
@@ -3571,7 +3846,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3571
3846
|
if (String(control.type || '').trim() === 'abort_turn') {
|
|
3572
3847
|
const queueMessageId = String(control.queue_message_id || '').trim();
|
|
3573
3848
|
const queueRow = queueMessageId ? (stmts.findQById.get(queueMessageId) || null) : null;
|
|
3574
|
-
|
|
3849
|
+
const capStop = queueRow ? claudeApiCapStopOf(control) : null;
|
|
3850
|
+
if (capStop) {
|
|
3851
|
+
settleClaudeApiContinuationStop({ queueRow, queueMessageId });
|
|
3852
|
+
} else if (queueRow) {
|
|
3575
3853
|
const failureRecord = buildRelayStopFailurePayload();
|
|
3576
3854
|
failQueueMessage({
|
|
3577
3855
|
queueRow,
|
|
@@ -4479,7 +4757,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4479
4757
|
}
|
|
4480
4758
|
|
|
4481
4759
|
// POST /api/message — browser sends a message
|
|
4482
|
-
|
|
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) => {
|
|
4483
4765
|
const {
|
|
4484
4766
|
messageId: clientMessageId,
|
|
4485
4767
|
clientId,
|
|
@@ -4566,7 +4848,90 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4566
4848
|
: (stmts.getRuntimeSessionByConversation.get(conversationId) || null);
|
|
4567
4849
|
const configuredOpenAI = getOpenAIProviderSettings();
|
|
4568
4850
|
const requestedProviderType = normalizeRequestedProviderType(providerType || provider);
|
|
4569
|
-
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
|
+
}
|
|
4570
4935
|
const runtimeUsesOpenAI = runtimeProviderType === 'openai';
|
|
4571
4936
|
// Providers that resell each other's model ids ("claude-opus-5" is served by
|
|
4572
4937
|
// Claude, Copilot and Cursor alike) make a model id useless for deciding
|
|
@@ -4582,7 +4947,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4582
4947
|
|| runtimeProviderType === 'cursor'
|
|
4583
4948
|
|| runtimeProviderType === 'grok'
|
|
4584
4949
|
|| runtimeProviderType === 'claude-cloud'
|
|
4585
|
-
|| runtimeProviderType === 'deepseek'
|
|
4950
|
+
|| runtimeProviderType === 'deepseek'
|
|
4951
|
+
|| runtimeProviderType === 'claude-api';
|
|
4586
4952
|
const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
|
|
4587
4953
|
const availableOpenAIModels = new Set(
|
|
4588
4954
|
(Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
|
|
@@ -4743,6 +5109,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4743
5109
|
code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
|
|
4744
5110
|
});
|
|
4745
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
|
+
}
|
|
4746
5122
|
if (
|
|
4747
5123
|
useOpenAIProvider
|
|
4748
5124
|
&& requestedOpenAIModel
|
|
@@ -4790,8 +5166,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4790
5166
|
// Claude conversations resolve models against the Claude provider
|
|
4791
5167
|
// catalog (the Copilot catalog does not know these ids). Per-turn model
|
|
4792
5168
|
// switching is allowed: each Claude turn is a fresh query() with resume.
|
|
4793
|
-
|
|
4794
|
-
|
|
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;
|
|
4795
5175
|
const requestedClaudeModel = runtimeUsesClaude ? String(model || '').trim() : '';
|
|
4796
5176
|
const availableClaudeModels = new Set([
|
|
4797
5177
|
String(configuredClaude?.model || '').trim(),
|
|
@@ -5331,7 +5711,22 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5331
5711
|
const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
|
|
5332
5712
|
let conversationPreferences;
|
|
5333
5713
|
let imageOperation = null;
|
|
5714
|
+
// The switch's announcement, made once the send has committed.
|
|
5715
|
+
let announceBillingSwitch = null;
|
|
5334
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
|
+
}
|
|
5335
5730
|
stmts.insertMsg.run(
|
|
5336
5731
|
msgId,
|
|
5337
5732
|
convId,
|
|
@@ -5394,7 +5789,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5394
5789
|
&& claudeModel !== String(existingRuntimeSession?.provider_model || '').trim()
|
|
5395
5790
|
&& typeof stmts.updateRuntimeSessionProvider?.run === 'function'
|
|
5396
5791
|
) {
|
|
5397
|
-
stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
|
|
5792
|
+
stmts.updateRuntimeSessionProvider.run(runtimeUsesClaudeApi ? 'claude-api' : 'claude', claudeModel, claudeModel, now, runtimeSession.id);
|
|
5398
5793
|
}
|
|
5399
5794
|
// DeepSeek follows the composer the same way: a relaunched worker reads
|
|
5400
5795
|
// provider_model, so it has to be the model last used.
|
|
@@ -5482,7 +5877,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5482
5877
|
});
|
|
5483
5878
|
try {
|
|
5484
5879
|
persistMessageAndQueue();
|
|
5880
|
+
try { announceBillingSwitch?.(); } catch {}
|
|
5485
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
|
+
}
|
|
5486
5891
|
// A replayed send (Background Sync outbox) with a caller-supplied id hits
|
|
5487
5892
|
// the messages.id primary key. Surface it as a clean 409 so the replay
|
|
5488
5893
|
// can treat "already accepted" as success instead of retrying forever.
|
|
@@ -5613,7 +6018,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5613
6018
|
messageId: msgId,
|
|
5614
6019
|
conversationId: convId,
|
|
5615
6020
|
runtimeSessionId: runtimeSession?.id || null,
|
|
5616
|
-
runtimeProviderType:
|
|
6021
|
+
runtimeProviderType: billingSwitch?.to
|
|
6022
|
+
|| String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
|
|
6023
|
+
...(billingSwitch ? { billingSwitched: true } : {}),
|
|
5617
6024
|
runtimeProviderModel: runtimeSession?.provider_model || (useOpenAIProvider ? openAIModel : null),
|
|
5618
6025
|
ownerSessionId: ownerSessionId || null,
|
|
5619
6026
|
preferredRelayMode: conversationPreferences.preferredRelayMode,
|
|
@@ -5639,6 +6046,73 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5639
6046
|
}
|
|
5640
6047
|
: null,
|
|
5641
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
|
+
});
|
|
5642
6116
|
});
|
|
5643
6117
|
|
|
5644
6118
|
app.post('/api/heartbeat', auth, (req, res) => {
|
|
@@ -5704,7 +6178,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5704
6178
|
|| (stored.supported === true) !== (steering.supported === true)
|
|
5705
6179
|
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5706
6180
|
// The running CLI's compaction window (context-usage modal).
|
|
5707
|
-
|| 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);
|
|
5708
6186
|
if (workerForSteering && changed) {
|
|
5709
6187
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
5710
6188
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -5713,6 +6191,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5713
6191
|
io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
|
|
5714
6192
|
}
|
|
5715
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
|
+
}
|
|
5716
6203
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5717
6204
|
// The worker's digest of its live background-task ids against the set
|
|
5718
6205
|
// held for it (keyed as the worker posts, by its session id). They
|
|
@@ -5930,9 +6417,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5930
6417
|
return res.status(404).json({ error: 'Runtime session not found for conversation' });
|
|
5931
6418
|
}
|
|
5932
6419
|
// The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
|
|
5933
|
-
// its sessions the same way
|
|
6420
|
+
// its sessions the same way; the Claude API worker is the Claude one on
|
|
6421
|
+
// an API key.
|
|
5934
6422
|
const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
|
|
5935
|
-
if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek') {
|
|
6423
|
+
if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek' && nativeSessionProvider !== 'claude-api') {
|
|
5936
6424
|
return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
|
|
5937
6425
|
}
|
|
5938
6426
|
if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
|
|
@@ -5974,9 +6462,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5974
6462
|
return res.status(404).json({ error: 'Runtime session not found for conversation' });
|
|
5975
6463
|
}
|
|
5976
6464
|
// The Claude Cloud worker reports the same shape (the cloud session's
|
|
5977
|
-
// context and token usage), and so
|
|
6465
|
+
// context and token usage), and so do the DeepSeek and Claude API ones
|
|
6466
|
+
// (the same CLI).
|
|
5978
6467
|
const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
|
|
5979
|
-
if (
|
|
6468
|
+
if (!['claude', 'claude-cloud', 'deepseek', 'claude-api'].includes(contextUsageProvider)) {
|
|
5980
6469
|
return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
|
|
5981
6470
|
}
|
|
5982
6471
|
if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
|
|
@@ -6057,6 +6546,42 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6057
6546
|
res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
|
|
6058
6547
|
});
|
|
6059
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
|
+
|
|
6060
6585
|
// POST /api/claude-plan-usage — Claude worker reports the session's
|
|
6061
6586
|
// structured /usage data (plan rate-limit windows + session cost totals).
|
|
6062
6587
|
//
|
|
@@ -6320,6 +6845,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6320
6845
|
});
|
|
6321
6846
|
|
|
6322
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
|
+
|
|
6323
6857
|
app.get('/api/pending', auth, async (req, res) => {
|
|
6324
6858
|
touchCli();
|
|
6325
6859
|
const requester = readBridgeIdentity(req);
|
|
@@ -6447,6 +6981,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6447
6981
|
sessionWorkerSupervisor,
|
|
6448
6982
|
relayRestartOrchestrator,
|
|
6449
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,
|
|
6450
6990
|
telemetry: (payload = {}) => {
|
|
6451
6991
|
if (!sessionWorkerRoutingEnabled) return;
|
|
6452
6992
|
emitSessionWorkerTelemetry(payload.event, {
|
|
@@ -6664,6 +7204,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6664
7204
|
sessionWorkerSupervisor,
|
|
6665
7205
|
relayRestartOrchestrator,
|
|
6666
7206
|
inFlightProcessingCount: Number(queueCounts()?.processingCount || 0),
|
|
7207
|
+
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7208
|
+
claudeApiClaimCheck,
|
|
7209
|
+
onBillingFence: onClaudeBillingFence,
|
|
6667
7210
|
});
|
|
6668
7211
|
if (recoveryDequeue?.message) {
|
|
6669
7212
|
msg = recoveryDequeue.message;
|
|
@@ -7384,7 +7927,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7384
7927
|
? (usageLimitPauseService?.planPause?.({
|
|
7385
7928
|
terminalError: req.body.terminalError,
|
|
7386
7929
|
conversationId: targetConversationId,
|
|
7387
|
-
|
|
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,
|
|
7388
7936
|
queueRow: q,
|
|
7389
7937
|
partialText: req.body?.partialText,
|
|
7390
7938
|
}) || null)
|
|
@@ -7504,6 +8052,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7504
8052
|
const conversationProvider = String(
|
|
7505
8053
|
stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type || '',
|
|
7506
8054
|
).trim().toLowerCase() || 'github';
|
|
8055
|
+
const responderKind = sessionWorkerRegistry?.getWorker?.(normalizeSessionWorkerId(responseBridgeIdentity?.sessionId))?.kind || null;
|
|
7507
8056
|
const executedProvider = resolveExecutedProviderForResponse({
|
|
7508
8057
|
stmts,
|
|
7509
8058
|
responseBridgeIdentity,
|
|
@@ -7511,13 +8060,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7511
8060
|
// Image operations are executed by this server against the conversation's
|
|
7512
8061
|
// own provider API, then finalized through an identity-less self-post.
|
|
7513
8062
|
serverExecutedOperation: !!q?.image_operation_id,
|
|
8063
|
+
responderKind,
|
|
7514
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);
|
|
7515
8079
|
if (executedProvider === 'unknown') {
|
|
7516
8080
|
// An identity that names no runtime session is itself an anomaly (a stale
|
|
7517
8081
|
// or forged header), just not evidence of cross-provider execution. Say so
|
|
7518
8082
|
// rather than staying silent.
|
|
7519
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`);
|
|
7520
|
-
} else if (executedProvider !== conversationProvider) {
|
|
8084
|
+
} else if (executedProvider !== conversationProvider && !switchedClaudeTurn) {
|
|
7521
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`);
|
|
7522
8086
|
}
|
|
7523
8087
|
const responderConversationCheck = resolveResponderConversationMismatch({
|
|
@@ -7874,8 +8438,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7874
8438
|
// worker (detached background shells) serving both Copilot-CLI providers.
|
|
7875
8439
|
// A Claude Cloud session can take a turn of its own too: one the agent
|
|
7876
8440
|
// starts when its background work reports back, or one started on
|
|
7877
|
-
// claude.ai; the cloud worker follows it and registers it here.
|
|
7878
|
-
|
|
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']);
|
|
7879
8444
|
|
|
7880
8445
|
// POST /api/continuation-turn — a session worker's CLI started a turn on its
|
|
7881
8446
|
// own (a background task's notification). The synthetic queue row gives that
|
|
@@ -7928,7 +8493,35 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7928
8493
|
const attemptId = uuidv4();
|
|
7929
8494
|
const now = new Date().toISOString();
|
|
7930
8495
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
7931
|
-
|
|
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 = () => {
|
|
7932
8525
|
stmts.insertContinuationQ.run(
|
|
7933
8526
|
messageId,
|
|
7934
8527
|
conversationId,
|
|
@@ -7945,6 +8538,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7945
8538
|
attemptId,
|
|
7946
8539
|
operationId,
|
|
7947
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();
|
|
7948
8549
|
} catch (error) {
|
|
7949
8550
|
// Lost the race against a concurrent duplicate registration: the unique
|
|
7950
8551
|
// index on continuation_op_id guarantees the winner's row exists now.
|
|
@@ -7954,7 +8555,20 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7954
8555
|
}
|
|
7955
8556
|
throw error;
|
|
7956
8557
|
}
|
|
7957
|
-
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
|
+
}
|
|
7958
8572
|
io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
|
|
7959
8573
|
io.emit('queue_updated', { continuation: 1 });
|
|
7960
8574
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
@@ -8208,7 +8822,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8208
8822
|
// The providers whose workers name a shell task's output (a file, a spool
|
|
8209
8823
|
// or a snapshot). A task of theirs without one comes from a worker that
|
|
8210
8824
|
// started before the console existed.
|
|
8211
|
-
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek']);
|
|
8825
|
+
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek', 'claude-api']);
|
|
8212
8826
|
const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
|
|
8213
8827
|
|
|
8214
8828
|
// Why a task has no output to show, for the console's `none` answer. The
|
|
@@ -8256,7 +8870,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8256
8870
|
// 256 KB; with `before` (a startOffset of an earlier answer): the lines
|
|
8257
8871
|
// ending there. Everything is cleaned (controls out, colours kept,
|
|
8258
8872
|
// credentials scrubbed) for every reader. Another relay's agent reads it
|
|
8259
|
-
// too (
|
|
8873
|
+
// too (relay_tool read_task_output, read permission): no inbound switch,
|
|
8260
8874
|
// nothing it reads changes anything.
|
|
8261
8875
|
app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
|
|
8262
8876
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
@@ -8947,12 +9561,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8947
9561
|
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
|
|
8948
9562
|
return res.json({ ok: true, mismatch: true });
|
|
8949
9563
|
}
|
|
8950
|
-
|
|
9564
|
+
const handBackClass = String(req.body?.class || '').trim();
|
|
9565
|
+
if (q && q.status === 'processing' && (handBackClass === 'steering-held' || handBackClass === 'kind-mismatch')) {
|
|
8951
9566
|
// The worker refused to push the message because its live turn is
|
|
8952
9567
|
// holding steering (an open question card, a compaction, an adoption).
|
|
8953
9568
|
// Not a failure: no retry increment, no backoff, and the owner is kept —
|
|
8954
9569
|
// the same worker takes it again the moment it signals ready, which is
|
|
8955
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.
|
|
8956
9574
|
const result = db.prepare(`
|
|
8957
9575
|
UPDATE queue
|
|
8958
9576
|
SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
|
|
@@ -8962,7 +9580,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8962
9580
|
settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
8963
9581
|
settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
8964
9582
|
io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
|
|
8965
|
-
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)}`);
|
|
8966
9584
|
}
|
|
8967
9585
|
return res.json({ ok: true, held: true });
|
|
8968
9586
|
}
|