@oar-sh/oar 0.9.17 → 0.9.19
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/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +234 -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 +5 -5
- 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-heap-guard.mjs +338 -0
- package/server/relay-self-restart.mjs +33 -7
- package/server/relay-tools.md +6 -6
- 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 +678 -31
- 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 +365 -53
- package/server/server-runtime.mjs +373 -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 +111 -17
- 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 +54 -12
- 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 {
|
|
@@ -56,7 +56,11 @@ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../servic
|
|
|
56
56
|
import { taskKindOf } from '../services/session-activity-tracker.mjs';
|
|
57
57
|
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
58
58
|
import { isLiveResend } from '../services/steer-resend.mjs';
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
checkRequestedProviderModel,
|
|
61
|
+
resolveProviderModelSelection,
|
|
62
|
+
unknownProviderModelText,
|
|
63
|
+
} from '../services/provider-model-selection.mjs';
|
|
60
64
|
import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
|
|
61
65
|
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
62
66
|
import {
|
|
@@ -81,6 +85,9 @@ import {
|
|
|
81
85
|
} from '../../shared/deepseek-models.mjs';
|
|
82
86
|
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
83
87
|
import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
|
|
88
|
+
import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
|
|
89
|
+
import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
|
|
90
|
+
import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
|
|
84
91
|
import {
|
|
85
92
|
admitRemoteRelayRequest,
|
|
86
93
|
readRemoteRelayRequest,
|
|
@@ -161,6 +168,8 @@ function normalizeRequestedProviderType(value = '') {
|
|
|
161
168
|
// Its own provider, never read as `claude`.
|
|
162
169
|
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
163
170
|
if (normalized === 'deepseek') return 'deepseek';
|
|
171
|
+
// Its own provider too (Claude on an API key), never read as `claude`.
|
|
172
|
+
if (normalized === 'claude-api') return 'claude-api';
|
|
164
173
|
return '';
|
|
165
174
|
}
|
|
166
175
|
|
|
@@ -170,6 +179,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
|
|
|
170
179
|
claude: 'Claude',
|
|
171
180
|
'claude-cloud': 'Claude Cloud',
|
|
172
181
|
deepseek: 'DeepSeek',
|
|
182
|
+
'claude-api': 'Claude API',
|
|
173
183
|
});
|
|
174
184
|
|
|
175
185
|
function deriveModelOrigin(model) {
|
|
@@ -867,9 +877,16 @@ export function resolveExecutedProviderForResponse({
|
|
|
867
877
|
responseBridgeIdentity,
|
|
868
878
|
conversationProvider = null,
|
|
869
879
|
serverExecutedOperation = false,
|
|
880
|
+
// The kind the responding worker said it is. A Claude chat moves between
|
|
881
|
+
// the subscription and the API key, and its runtime row names the billing
|
|
882
|
+
// of now, not the one a turn that ends after a switch ran on: the worker's
|
|
883
|
+
// own kind is what ran.
|
|
884
|
+
responderKind = null,
|
|
870
885
|
} = {}) {
|
|
871
886
|
const boundProvider = String(conversationProvider || '').trim().toLowerCase() || 'github';
|
|
872
887
|
const responderSessionId = normalizeSessionWorkerId(responseBridgeIdentity?.sessionId);
|
|
888
|
+
const responderBilling = normalizeClaudeBilling(responderKind);
|
|
889
|
+
if (responderSessionId && responderBilling) return responderBilling;
|
|
873
890
|
if (responderSessionId) {
|
|
874
891
|
const runtimeSession = stmts?.getRuntimeSessionBySdkSessionId?.get?.(responderSessionId) || null;
|
|
875
892
|
const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase();
|
|
@@ -912,10 +929,21 @@ export function dequeuePendingMessage({
|
|
|
912
929
|
requesterSessionId = null,
|
|
913
930
|
ownerLeaseMs = SESSION_WORKER_OWNER_LEASE_MS,
|
|
914
931
|
affinityOnly = false,
|
|
932
|
+
// The kind the requesting worker says it is ("claude", "claude-api", ...),
|
|
933
|
+
// null when unknown. A Claude chat's row is claimed only by a worker of
|
|
934
|
+
// the row's billing (claudeBillingFence); a refused claim calls
|
|
935
|
+
// `onBillingFence` and claims nothing.
|
|
936
|
+
workerKind = null,
|
|
937
|
+
onBillingFence = null,
|
|
938
|
+
// () → null, or `{ code, error }` when a Claude API row must not run now
|
|
939
|
+
// (the provider off, no key, the monthly cap): such a row is fenced too,
|
|
940
|
+
// whatever the worker, and the caller fails it closed.
|
|
941
|
+
claudeApiClaimCheck = null,
|
|
915
942
|
} = {}) {
|
|
916
943
|
const currentIso = String(nowIso || '').trim() || new Date().toISOString();
|
|
917
944
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
918
945
|
const leaseExpiresAt = requesterSid ? addMsToIso(currentIso, ownerLeaseMs) : null;
|
|
946
|
+
let fenced = null;
|
|
919
947
|
const dequeue = db.transaction(() => {
|
|
920
948
|
let next = null;
|
|
921
949
|
if (routingEnabled && requesterSid && stmts.findPendingForWorker) {
|
|
@@ -927,6 +955,10 @@ export function dequeuePendingMessage({
|
|
|
927
955
|
} else if (!routingEnabled && requesterSid && stmts.findPendingForSessionAffinity) {
|
|
928
956
|
next = stmts.findPendingForSessionAffinity.get(currentIso, requesterSid);
|
|
929
957
|
}
|
|
958
|
+
if (next && requesterSid) {
|
|
959
|
+
fenced = claudeBillingFence({ stmts, row: next, workerKind, claudeApiClaimCheck });
|
|
960
|
+
if (fenced) return null;
|
|
961
|
+
}
|
|
930
962
|
if (!next && affinityOnly) return null;
|
|
931
963
|
if (!next) {
|
|
932
964
|
// Requesters that reach the global fallback carry no worker identity
|
|
@@ -963,7 +995,119 @@ export function dequeuePendingMessage({
|
|
|
963
995
|
attempt_id: attemptId,
|
|
964
996
|
};
|
|
965
997
|
});
|
|
966
|
-
|
|
998
|
+
const claimed = dequeue();
|
|
999
|
+
if (fenced && typeof onBillingFence === 'function') {
|
|
1000
|
+
try { onBillingFence({ ...fenced, requesterSessionId: requesterSid }); } catch {}
|
|
1001
|
+
}
|
|
1002
|
+
return claimed;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// The `source` of an abort_turn control queued for a continuation on the
|
|
1006
|
+
// key that the Claude API claim check refuses (POST /api/continuation-turn):
|
|
1007
|
+
// the cap, no key, or the provider switched off.
|
|
1008
|
+
const CLAUDE_API_CAP_STOP_SOURCE = 'claude-api-cap';
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* What a note on a Claude API turn that did not run tells the user to do,
|
|
1012
|
+
* without the full stop: the cap is raised, a switched-off Claude API is
|
|
1013
|
+
* switched on, a missing key is set up; or the chat moves to the
|
|
1014
|
+
* subscription.
|
|
1015
|
+
*/
|
|
1016
|
+
export function claudeApiRemedyText({ code = '', switchedOff = false } = {}) {
|
|
1017
|
+
const place = 'Settings → Providers → Claude API';
|
|
1018
|
+
if (String(code) === 'CLAUDE_API_CAP_REACHED') return `Switch this chat to the subscription, or raise the monthly cap in ${place}`;
|
|
1019
|
+
if (switchedOff) return `Switch this chat to the subscription, or switch Claude API on in ${place}`;
|
|
1020
|
+
return `Switch this chat to the subscription, or set Claude API up in ${place}`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const CLAUDE_API_STOP_NOTE_LABELS = 2;
|
|
1024
|
+
const CLAUDE_API_STOP_NOTE_LABEL_CHARS = 40;
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The note for a self-started turn on the key the Claude API claim check
|
|
1028
|
+
* refused (`refusal`: `{ code, error, switchedOff? }`), with the background tasks the
|
|
1029
|
+
* relay stopped and those no stop reaches (`tasks`: `{ stopped, unreached }`
|
|
1030
|
+
* of `{ label }`). One paragraph whose lead stays within
|
|
1031
|
+
* FAILURE_NOTE_LEAD_MAX, so it still reads as a failure note and the
|
|
1032
|
+
* transcript's buttons find its code: at most two labels per list, cut to
|
|
1033
|
+
* 40 characters, then only the counts if the lead is still too long. The
|
|
1034
|
+
* fixed sentences and the code are never cut.
|
|
1035
|
+
*/
|
|
1036
|
+
export function buildClaudeApiStopNoteText({ refusal = {}, tasks = {} } = {}) {
|
|
1037
|
+
const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
|
|
1038
|
+
const stableCode = `relay.${code.toLowerCase().replace(/_/g, '-')}`;
|
|
1039
|
+
const reason = String(refusal.error || '').trim()
|
|
1040
|
+
.replace(/,? so this turn did not run\.$/, '.')
|
|
1041
|
+
.replace(/\s*This turn did not run\.$/, '');
|
|
1042
|
+
const stopped = Array.isArray(tasks.stopped) ? tasks.stopped : [];
|
|
1043
|
+
const unreached = Array.isArray(tasks.unreached) ? tasks.unreached : [];
|
|
1044
|
+
const cut = (label) => {
|
|
1045
|
+
const text = String(label || '').trim().replace(/\s+/g, ' ') || 'a task';
|
|
1046
|
+
return text.length > CLAUDE_API_STOP_NOTE_LABEL_CHARS ? `${text.slice(0, CLAUDE_API_STOP_NOTE_LABEL_CHARS - 1).trimEnd()}…` : text;
|
|
1047
|
+
};
|
|
1048
|
+
const named = (list, shown) => {
|
|
1049
|
+
const labels = list.slice(0, shown).map((task) => cut(task.label));
|
|
1050
|
+
const more = list.length - labels.length;
|
|
1051
|
+
return `${labels.join(', ')}${more ? ` and ${more} more` : ''}`;
|
|
1052
|
+
};
|
|
1053
|
+
const tasksText = (shown) => [
|
|
1054
|
+
stopped.length
|
|
1055
|
+
? (shown ? `Background tasks stopped: ${named(stopped, shown)}. ` : `${stopped.length} background task${stopped.length === 1 ? '' : 's'} stopped. `)
|
|
1056
|
+
: '',
|
|
1057
|
+
unreached.length
|
|
1058
|
+
? (shown
|
|
1059
|
+
? `Still running (stop them in the task panel): ${named(unreached, shown)}. `
|
|
1060
|
+
: `${unreached.length}${stopped.length ? ' more' : ` background task${unreached.length === 1 ? '' : 's'}`} still running (stop them in the task panel). `)
|
|
1061
|
+
: '',
|
|
1062
|
+
].join('');
|
|
1063
|
+
const lead = (shown) => `${reason} Claude Code started this turn by itself when a background task reported back. `
|
|
1064
|
+
+ 'It is being stopped, but its first request may already be billed, and a short turn can end first. '
|
|
1065
|
+
+ tasksText(shown)
|
|
1066
|
+
+ `${claudeApiRemedyText({ code, switchedOff: refusal.switchedOff === true })}. `;
|
|
1067
|
+
let shown = CLAUDE_API_STOP_NOTE_LABELS;
|
|
1068
|
+
while (shown > 0 && lead(shown).length > FAILURE_NOTE_LEAD_MAX) shown -= 1;
|
|
1069
|
+
return `${lead(shown)}Error code: ${stableCode}.`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Whether a worker of `workerKind` must not claim `row`, a Claude chat's
|
|
1074
|
+
* turn: null when it may, else `{ conversationId, messageId, rowBilling,
|
|
1075
|
+
* workerKind, reason }`. The row's billing is its stamp (queue.provider),
|
|
1076
|
+
* else its chat's. A worker that states no kind is taken at its chat's
|
|
1077
|
+
* word, except on a chat that has switched since (a worker from before the
|
|
1078
|
+
* switch existed: it is replaced once, when idle).
|
|
1079
|
+
*/
|
|
1080
|
+
export function claudeBillingFence({ stmts, row, workerKind = null, claudeApiClaimCheck = null } = {}) {
|
|
1081
|
+
if (!row) return null;
|
|
1082
|
+
const runtime = stmts?.getRuntimeSessionByConversation?.get?.(row.conversation_id) || null;
|
|
1083
|
+
const chatBilling = normalizeClaudeBilling(runtime?.provider_type);
|
|
1084
|
+
const rowBilling = normalizeClaudeBilling(row.provider) || chatBilling;
|
|
1085
|
+
if (!rowBilling) return null;
|
|
1086
|
+
const kind = normalizeWorkerKind(workerKind);
|
|
1087
|
+
const base = {
|
|
1088
|
+
conversationId: String(row.conversation_id || ''),
|
|
1089
|
+
messageId: String(row.id || ''),
|
|
1090
|
+
rowBilling,
|
|
1091
|
+
workerKind: kind,
|
|
1092
|
+
};
|
|
1093
|
+
if (rowBilling === 'claude-api' && typeof claudeApiClaimCheck === 'function') {
|
|
1094
|
+
let refusal = null;
|
|
1095
|
+
try {
|
|
1096
|
+
refusal = claudeApiClaimCheck() || null;
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
// A check that cannot answer does not let a turn run on the key: the
|
|
1099
|
+
// row fails closed as unconfigured. Subscription rows never get here.
|
|
1100
|
+
console.warn(`[claude-billing] Claude API claim check failed for ${String(row.id || '').slice(0, 8)}: ${error?.message || error} — the row fails closed`);
|
|
1101
|
+
refusal = {
|
|
1102
|
+
code: 'CLAUDE_API_NOT_CONFIGURED',
|
|
1103
|
+
error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.',
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
if (refusal) return { ...base, reason: 'claude-api-unavailable', refusal };
|
|
1107
|
+
}
|
|
1108
|
+
if (kind) return kind === rowBilling ? null : { ...base, reason: 'kind-mismatch' };
|
|
1109
|
+
if (String(runtime?.billing_switched_at || '').trim()) return { ...base, reason: 'kind-unknown' };
|
|
1110
|
+
return null;
|
|
967
1111
|
}
|
|
968
1112
|
|
|
969
1113
|
// A requeue for a turn that produced nothing and was only briefly in flight is
|
|
@@ -1029,9 +1173,13 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1029
1173
|
relayRestartOrchestrator = null,
|
|
1030
1174
|
inFlightProcessingCount = 0,
|
|
1031
1175
|
telemetry = null,
|
|
1176
|
+
workerKind = null,
|
|
1177
|
+
onBillingFence = null,
|
|
1178
|
+
claudeApiClaimCheck = null,
|
|
1032
1179
|
} = {}) {
|
|
1033
1180
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
1034
1181
|
const routingWithWorker = routingEnabled && requesterSid;
|
|
1182
|
+
let billingFenced = null;
|
|
1035
1183
|
const retryLimit = Math.max(0, Number(transientRetryLimit) || 0);
|
|
1036
1184
|
const backoffBaseMs = Math.max(1, Number(transientRetryBackoffMs) || 1);
|
|
1037
1185
|
const supervisor = sessionWorkerSupervisor || null;
|
|
@@ -1112,6 +1260,12 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1112
1260
|
requesterSessionId: requesterSid,
|
|
1113
1261
|
ownerLeaseMs,
|
|
1114
1262
|
affinityOnly,
|
|
1263
|
+
workerKind,
|
|
1264
|
+
claudeApiClaimCheck,
|
|
1265
|
+
onBillingFence: (fence) => {
|
|
1266
|
+
billingFenced = fence;
|
|
1267
|
+
if (typeof onBillingFence === 'function') onBillingFence(fence);
|
|
1268
|
+
},
|
|
1115
1269
|
});
|
|
1116
1270
|
emitWorkerLoopTelemetry(telemetry, {
|
|
1117
1271
|
event: message ? 'queue.dequeue.success' : 'queue.dequeue.empty',
|
|
@@ -1134,7 +1288,9 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1134
1288
|
}
|
|
1135
1289
|
return {
|
|
1136
1290
|
message,
|
|
1137
|
-
|
|
1291
|
+
// The next row is of another billing than this worker: it waits for
|
|
1292
|
+
// the swap (claude-billing-swap-service.mjs).
|
|
1293
|
+
blockedReason: !message && billingFenced ? 'billing-swap' : null,
|
|
1138
1294
|
worker: routingWithWorker ? (supervisor?.getWorkerState?.(requesterSid) || null) : null,
|
|
1139
1295
|
lifecycle: routingWithWorker ? (supervisor?.getLifecycleState?.(requesterSid) || null) : null,
|
|
1140
1296
|
attempts: attempt + 1,
|
|
@@ -1204,6 +1360,9 @@ export function buildDequeuedRelayMessage({
|
|
|
1204
1360
|
}
|
|
1205
1361
|
}
|
|
1206
1362
|
const providerType = String(runtimeSession?.provider_type || '').trim().toLowerCase() || null;
|
|
1363
|
+
// A Claude chat's billing for this row: its stamp, else the chat's. The
|
|
1364
|
+
// worker runs it only when that is its own kind.
|
|
1365
|
+
const billing = normalizeClaudeBilling(msg.provider) || normalizeClaudeBilling(providerType) || null;
|
|
1207
1366
|
// The Cursor worker declares one subagent per enabled model, because the
|
|
1208
1367
|
// SDK's built-in subagent model menu is stuck at `inherit` +
|
|
1209
1368
|
// `composer-2.5-fast` (see cursor-subagent-roster.mjs). Delivered per turn
|
|
@@ -1241,6 +1400,7 @@ export function buildDequeuedRelayMessage({
|
|
|
1241
1400
|
model: String(msg.model || '').trim() || defaultModel,
|
|
1242
1401
|
modelVariantId: String(msg.model_variant_id || '').trim() || String(msg.model || '').trim() || null,
|
|
1243
1402
|
providerType,
|
|
1403
|
+
...(billing ? { billing } : {}),
|
|
1244
1404
|
providerModel: String(runtimeSession?.provider_model || '').trim() || null,
|
|
1245
1405
|
claudeNativeSessionId: String(runtimeSession?.claude_native_session_id || '').trim() || null,
|
|
1246
1406
|
cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
|
|
@@ -1844,6 +2004,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1844
2004
|
getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
|
|
1845
2005
|
getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
|
|
1846
2006
|
getDeepSeekProviderSettings = () => ({ enabled: false, model: '', models: [] }),
|
|
2007
|
+
getClaudeApiProviderSettings = () => ({ enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} }),
|
|
1847
2008
|
rebindUnstartedOpenAIConversationModel = null,
|
|
1848
2009
|
normalizeRelayMode,
|
|
1849
2010
|
DEFAULT_RELAY_MODE,
|
|
@@ -1883,6 +2044,27 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1883
2044
|
fetchUsageSummary = null,
|
|
1884
2045
|
planUsageService = null,
|
|
1885
2046
|
usageLimitPauseService = null,
|
|
2047
|
+
// The Claude billing switch (claude-billing-switch-service.mjs): the
|
|
2048
|
+
// billing route, and the send-path guards that refuse a switch by send.
|
|
2049
|
+
claudeBillingSwitchService = null,
|
|
2050
|
+
// The Claude API booking (claude-api-usage-service.mjs).
|
|
2051
|
+
claudeApiUsageService = null,
|
|
2052
|
+
// The delivery fence's runtime half for GET /api/pending, as on the
|
|
2053
|
+
// socket: the Claude API claim check (null: no refusal) and what a fenced
|
|
2054
|
+
// row does (the swap, or fail closed).
|
|
2055
|
+
claudeApiClaimCheck = null,
|
|
2056
|
+
onClaudeBillingFence = null,
|
|
2057
|
+
// The Claude API monthly cap's one seam (`{ reached, error? }`; a throw
|
|
2058
|
+
// counts as reached): an agent's send to a chat on the key is refused
|
|
2059
|
+
// while it is reached. Absent in older wiring and most tests: no cap.
|
|
2060
|
+
getClaudeApiCapState = () => ({ reached: false }),
|
|
2061
|
+
// Asks whether the cap flipped after a booked report (the runtime then
|
|
2062
|
+
// re-announces the Claude chats' billing states). A no-op without it.
|
|
2063
|
+
checkClaudeApiCap = () => {},
|
|
2064
|
+
// The notes for self-started turns stopped on the key, once per chat per
|
|
2065
|
+
// episode (plan-usage-claude-api.mjs); the runtime shares its own, which
|
|
2066
|
+
// the cap watch and the settings route clear.
|
|
2067
|
+
claudeApiStopLedger = createClaudeApiStopNoteLedger(),
|
|
1886
2068
|
getCursorPlanAllowanceSettings = () => ({ cursorModelsUsd: null, otherModelsUsd: null, resetDay: 1 }),
|
|
1887
2069
|
getGrokPlanAllowanceSettings = () => ({ monthlyUsd: null, resetDay: 1 }),
|
|
1888
2070
|
opaqueResponseRecoveryWaitMs = OPAQUE_RESPONSE_RECOVERY_WAIT_MS,
|
|
@@ -2722,6 +2904,92 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2722
2904
|
return buildRelayStopFailure();
|
|
2723
2905
|
}
|
|
2724
2906
|
|
|
2907
|
+
// The key stop's request (`{ source, code, error, episode }`), or null for any other control.
|
|
2908
|
+
function claudeApiCapStopOf(control) {
|
|
2909
|
+
let request = null;
|
|
2910
|
+
try { request = JSON.parse(String(control?.request || 'null')); } catch { request = null; }
|
|
2911
|
+
return request?.source === CLAUDE_API_CAP_STOP_SOURCE ? request : null;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// The Claude API claim check for a continuation on the key, failing closed
|
|
2915
|
+
// as the delivery fence does: `{ code, error }` or null. Without the
|
|
2916
|
+
// runtime's check (older wiring, tests) the cap seam alone is asked.
|
|
2917
|
+
function claudeApiContinuationRefusal() {
|
|
2918
|
+
try {
|
|
2919
|
+
if (typeof claudeApiClaimCheck === 'function') return claudeApiClaimCheck() || null;
|
|
2920
|
+
let cap = null;
|
|
2921
|
+
try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
|
|
2922
|
+
return cap?.reached === true
|
|
2923
|
+
? { 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.` }
|
|
2924
|
+
: null;
|
|
2925
|
+
} catch (error) {
|
|
2926
|
+
console.warn(`[claude-billing] Claude API claim check failed for a continuation: ${error?.message || error} — it is stopped`);
|
|
2927
|
+
return { code: 'CLAUDE_API_NOT_CONFIGURED', error: 'The relay could not check Claude API (key, switch, cap), so this turn did not run.' };
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// The episode of a refusal, for its note (claudeApiStopLedger): the cap's
|
|
2932
|
+
// month and value, or the refusal's code.
|
|
2933
|
+
function claudeApiStopEpisode(code) {
|
|
2934
|
+
if (code !== 'CLAUDE_API_CAP_REACHED') return String(code || 'refused');
|
|
2935
|
+
try {
|
|
2936
|
+
return `cap:${String(getClaudeApiCapState()?.episode || '')}`;
|
|
2937
|
+
} catch {
|
|
2938
|
+
return 'cap:unreadable';
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// Stops the live background tasks of a key worker whose self-started turn
|
|
2943
|
+
// is refused: each report would start another such turn, and a billing
|
|
2944
|
+
// switch waits for them. `{ stopped, unreached }`, each `[{ taskId,
|
|
2945
|
+
// label, reason? }]`: the stops sent, and the tasks no stop reaches (not
|
|
2946
|
+
// stoppable, or the worker has no socket to take it).
|
|
2947
|
+
function stopClaudeApiBackgroundTasks(conversationId, sessionId) {
|
|
2948
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
2949
|
+
const workerSessionId = String(sessionId || conversation?.sdk_session_id || conversationId);
|
|
2950
|
+
const keys = [...new Set([conversationId, workerSessionId, String(conversation?.sdk_session_id || '')].filter(Boolean))];
|
|
2951
|
+
const stopped = [];
|
|
2952
|
+
const unreached = [];
|
|
2953
|
+
const seen = new Set();
|
|
2954
|
+
for (const task of keys.flatMap((key) => backgroundTaskStore?.get?.(key) || [])) {
|
|
2955
|
+
const taskId = String(task?.taskId || '').trim();
|
|
2956
|
+
if (!taskId || seen.has(taskId)) continue;
|
|
2957
|
+
seen.add(taskId);
|
|
2958
|
+
const label = String(task?.description || task?.command || task?.taskType || '').trim().replace(/\s+/g, ' ').slice(0, 80) || taskId;
|
|
2959
|
+
if (task?.stoppable === false) {
|
|
2960
|
+
unreached.push({ taskId, label, reason: 'no-stop' });
|
|
2961
|
+
} else if (sendWorkerControl?.(workerSessionId, { type: 'stop_background_task', taskId })) {
|
|
2962
|
+
stopped.push({ taskId, label });
|
|
2963
|
+
} else {
|
|
2964
|
+
unreached.push({ taskId, label, reason: 'worker-not-connected' });
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
if (stopped.length || unreached.length) {
|
|
2968
|
+
console.log(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)} background tasks stopped=${stopped.length} unreached=${unreached.length}`);
|
|
2969
|
+
}
|
|
2970
|
+
return { stopped, unreached };
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
/**
|
|
2974
|
+
* The note for a self-started turn on the key the claim check refused,
|
|
2975
|
+
* written when the turn is registered (a short turn can end before the
|
|
2976
|
+
* worker's control poller takes the stop): the refusal, what the stop can
|
|
2977
|
+
* and can't do, the background tasks stopped and those no stop reached,
|
|
2978
|
+
* and the way out, its code last where the transcript's buttons look.
|
|
2979
|
+
*/
|
|
2980
|
+
function writeClaudeApiStopNote({ conversationId, relayMode, refusal, tasks, now }) {
|
|
2981
|
+
const text = buildClaudeApiStopNoteText({ refusal, tasks });
|
|
2982
|
+
const noteId = uuidv4();
|
|
2983
|
+
stmts.insertMsg.run(noteId, conversationId, 'assistant', text, null, relayMode, null, now, null, null, null);
|
|
2984
|
+
stmts.updateConvTime?.run?.(now, conversationId);
|
|
2985
|
+
io.emit('assistant_message', {
|
|
2986
|
+
conversationId,
|
|
2987
|
+
messageId: noteId,
|
|
2988
|
+
message: { id: noteId, role: 'assistant', text, model: null, mode: relayMode, attachments: [], timestamp: now },
|
|
2989
|
+
});
|
|
2990
|
+
return noteId;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2725
2993
|
function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
|
|
2726
2994
|
const id = String(queueMessageId || '').trim();
|
|
2727
2995
|
if (!id) return 0;
|
|
@@ -3509,6 +3777,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3509
3777
|
return res.json({ ok: true, control: formatRelayControlResponse(control) });
|
|
3510
3778
|
});
|
|
3511
3779
|
|
|
3780
|
+
/**
|
|
3781
|
+
* A continuation on the key, stopped because the Claude API claim check
|
|
3782
|
+
* refused it: its note was written, and its tasks stopped, when it was
|
|
3783
|
+
* registered, so the row fails quietly.
|
|
3784
|
+
*/
|
|
3785
|
+
function settleClaudeApiContinuationStop({ queueRow, queueMessageId }) {
|
|
3786
|
+
if (Number(stmts.dropStaleContinuation?.run?.(queueMessageId)?.changes || 0) > 0) {
|
|
3787
|
+
io.emit('message_status', { messageId: queueMessageId, conversationId: queueRow.conversation_id, status: 'failed' });
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3512
3791
|
app.post('/api/control/:controlId/result', auth, (req, res) => {
|
|
3513
3792
|
const controlId = String(req.params.controlId || '').trim();
|
|
3514
3793
|
if (!controlId) {
|
|
@@ -3571,7 +3850,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3571
3850
|
if (String(control.type || '').trim() === 'abort_turn') {
|
|
3572
3851
|
const queueMessageId = String(control.queue_message_id || '').trim();
|
|
3573
3852
|
const queueRow = queueMessageId ? (stmts.findQById.get(queueMessageId) || null) : null;
|
|
3574
|
-
|
|
3853
|
+
const capStop = queueRow ? claudeApiCapStopOf(control) : null;
|
|
3854
|
+
if (capStop) {
|
|
3855
|
+
settleClaudeApiContinuationStop({ queueRow, queueMessageId });
|
|
3856
|
+
} else if (queueRow) {
|
|
3575
3857
|
const failureRecord = buildRelayStopFailurePayload();
|
|
3576
3858
|
failQueueMessage({
|
|
3577
3859
|
queueRow,
|
|
@@ -4479,7 +4761,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4479
4761
|
}
|
|
4480
4762
|
|
|
4481
4763
|
// POST /api/message — browser sends a message
|
|
4482
|
-
|
|
4764
|
+
// The send route's handler. The billing route runs it too, for an agent's
|
|
4765
|
+
// send that switches the chat's billing with its message: that request
|
|
4766
|
+
// carries `oarBillingSwitch` (set by the route, never read from a body),
|
|
4767
|
+
// and the switch happens in the same transaction as the enqueue.
|
|
4768
|
+
const handleSendMessage = async (req, res) => {
|
|
4483
4769
|
const {
|
|
4484
4770
|
messageId: clientMessageId,
|
|
4485
4771
|
clientId,
|
|
@@ -4566,7 +4852,90 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4566
4852
|
: (stmts.getRuntimeSessionByConversation.get(conversationId) || null);
|
|
4567
4853
|
const configuredOpenAI = getOpenAIProviderSettings();
|
|
4568
4854
|
const requestedProviderType = normalizeRequestedProviderType(providerType || provider);
|
|
4569
|
-
const
|
|
4855
|
+
const boundProviderType = String(existingRuntimeSession?.provider_type || '').trim().toLowerCase();
|
|
4856
|
+
// A Claude chat runs on the subscription (`claude`) or the saved API key
|
|
4857
|
+
// (`claude-api`). A send never moves it: the billing route does
|
|
4858
|
+
// (claude-billing-switch-service.mjs). A send that names the other
|
|
4859
|
+
// billing is refused, and so is one from a page that still showed the
|
|
4860
|
+
// other one (`billing`), so nobody's turn runs on a bill they did not
|
|
4861
|
+
// see. A send without `billing` (an older page, an agent) is not checked.
|
|
4862
|
+
const boundClaudeBilling = shouldCreateConversation ? '' : normalizeClaudeBilling(boundProviderType);
|
|
4863
|
+
const billingSwitch = boundClaudeBilling && req.oarBillingSwitch
|
|
4864
|
+
? { to: normalizeClaudeBilling(req.oarBillingSwitch.to), by: String(req.oarBillingSwitch.by || 'agent') }
|
|
4865
|
+
: null;
|
|
4866
|
+
if (boundClaudeBilling && !billingSwitch) {
|
|
4867
|
+
const namedBilling = normalizeClaudeBilling(requestedProviderType);
|
|
4868
|
+
if (namedBilling && namedBilling !== boundClaudeBilling) {
|
|
4869
|
+
return res.status(409).json({
|
|
4870
|
+
error: `This chat runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Switch its billing first; a send does not.`,
|
|
4871
|
+
code: 'CLAUDE_BILLING_SWITCH_REQUIRED',
|
|
4872
|
+
conversationId,
|
|
4873
|
+
billing: boundClaudeBilling,
|
|
4874
|
+
});
|
|
4875
|
+
}
|
|
4876
|
+
const shownBilling = normalizeClaudeBilling(req.body?.billing);
|
|
4877
|
+
if (shownBilling && shownBilling !== boundClaudeBilling) {
|
|
4878
|
+
// The text is not lost: it goes into the chat's draft (after what is
|
|
4879
|
+
// there), where every page shows it, so a page's own retry and a
|
|
4880
|
+
// replay from the outbox both leave it for the user to send again.
|
|
4881
|
+
const keptText = String(trimmedText || '').trim();
|
|
4882
|
+
const conversationRow = keptText ? (stmts.getConvAnyStatus?.get?.(conversationId) || null) : null;
|
|
4883
|
+
if (conversationRow) {
|
|
4884
|
+
const draft = String(conversationRow.draft_text || '');
|
|
4885
|
+
if (!draft.includes(keptText)) {
|
|
4886
|
+
const keptDraft = draft.trim() ? `${draft.replace(/\s+$/, '')}\n\n${keptText}` : keptText;
|
|
4887
|
+
const keptAt = new Date().toISOString();
|
|
4888
|
+
db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = NULL WHERE id = ?`)
|
|
4889
|
+
.run(keptDraft, keptAt, conversationId);
|
|
4890
|
+
// The draft's attachments stay what they are: the update must not
|
|
4891
|
+
// clear them on the other pages.
|
|
4892
|
+
let keptAttachments = [];
|
|
4893
|
+
try {
|
|
4894
|
+
const parsed = JSON.parse(String(conversationRow.draft_attachments || '[]'));
|
|
4895
|
+
keptAttachments = Array.isArray(parsed) ? parsed : [];
|
|
4896
|
+
} catch {
|
|
4897
|
+
keptAttachments = [];
|
|
4898
|
+
}
|
|
4899
|
+
io.emit('conversation_draft_updated', {
|
|
4900
|
+
conversationId,
|
|
4901
|
+
draftText: keptDraft,
|
|
4902
|
+
draftAttachments: keptAttachments,
|
|
4903
|
+
draftUpdatedAt: keptAt,
|
|
4904
|
+
draftUpdatedByClientId: null,
|
|
4905
|
+
senderClientId: null,
|
|
4906
|
+
});
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
return res.status(409).json({
|
|
4910
|
+
error: `This chat now runs on the ${boundClaudeBilling === 'claude-api' ? 'Claude API key' : 'Claude subscription'}. Send again to confirm.`,
|
|
4911
|
+
code: 'CLAUDE_BILLING_CHANGED',
|
|
4912
|
+
conversationId,
|
|
4913
|
+
billing: boundClaudeBilling,
|
|
4914
|
+
keptInDraft: true,
|
|
4915
|
+
});
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
4918
|
+
// A switch with the message: the rest of the send resolves the model,
|
|
4919
|
+
// effort and limits against the billing it switches to.
|
|
4920
|
+
const runtimeProviderType = billingSwitch?.to || boundProviderType;
|
|
4921
|
+
// An agent's send to a chat on the key while this month's cap is reached
|
|
4922
|
+
// is refused before anything is stored, so the agent hears it at once (a
|
|
4923
|
+
// page's send is queued and refused at the claim, with its note there).
|
|
4924
|
+
// A send that switches to the key was refused by the switch's own check;
|
|
4925
|
+
// one that names the key for a chat already on it (a switch that changes
|
|
4926
|
+
// nothing) is refused here like any other.
|
|
4927
|
+
if (remoteRequest.remote && (!billingSwitch || billingSwitch.to === 'claude-api') && runtimeProviderType === 'claude-api') {
|
|
4928
|
+
let cap = null;
|
|
4929
|
+
try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
|
|
4930
|
+
if (cap?.reached === true) {
|
|
4931
|
+
return res.status(409).json({
|
|
4932
|
+
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.`,
|
|
4933
|
+
code: 'CLAUDE_API_CAP_REACHED',
|
|
4934
|
+
conversationId,
|
|
4935
|
+
billing: 'claude-api',
|
|
4936
|
+
});
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4570
4939
|
const runtimeUsesOpenAI = runtimeProviderType === 'openai';
|
|
4571
4940
|
// Providers that resell each other's model ids ("claude-opus-5" is served by
|
|
4572
4941
|
// Claude, Copilot and Cursor alike) make a model id useless for deciding
|
|
@@ -4582,7 +4951,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4582
4951
|
|| runtimeProviderType === 'cursor'
|
|
4583
4952
|
|| runtimeProviderType === 'grok'
|
|
4584
4953
|
|| runtimeProviderType === 'claude-cloud'
|
|
4585
|
-
|| runtimeProviderType === 'deepseek'
|
|
4954
|
+
|| runtimeProviderType === 'deepseek'
|
|
4955
|
+
|| runtimeProviderType === 'claude-api';
|
|
4586
4956
|
const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
|
|
4587
4957
|
const availableOpenAIModels = new Set(
|
|
4588
4958
|
(Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
|
|
@@ -4653,6 +5023,13 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4653
5023
|
code: 'OPENAI_MODEL_REQUIRES_NEW_CONVERSATION',
|
|
4654
5024
|
});
|
|
4655
5025
|
}
|
|
5026
|
+
// A Copilot conversation is bound too, for the model-id guards below only:
|
|
5027
|
+
// Copilot resells Claude and GPT models under ids of its own
|
|
5028
|
+
// ("claude-opus-5.5"), so a Claude-form id another provider lists
|
|
5029
|
+
// ("claude-opus-5-5") is a model Copilot lacks, answered by the Copilot
|
|
5030
|
+
// check further down, never a switch to Cursor, Grok or DeepSeek. (It is
|
|
5031
|
+
// not in runtimeBoundToExplicitProvider: that one also picks the catalog.)
|
|
5032
|
+
const runtimeBoundForModelGuards = runtimeBoundToExplicitProvider || runtimeProviderType === 'github';
|
|
4656
5033
|
// Cursor conversations are bootstrap-created only, so an existing
|
|
4657
5034
|
// github/openai/claude conversation cannot cross to the Cursor provider
|
|
4658
5035
|
// mid-conversation. Cursor settings are read lazily so the hot path stays
|
|
@@ -4662,7 +5039,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4662
5039
|
let requestTargetsCursor = requestedProviderType === 'cursor';
|
|
4663
5040
|
if (
|
|
4664
5041
|
!requestTargetsCursor
|
|
4665
|
-
&& !
|
|
5042
|
+
&& !runtimeBoundForModelGuards
|
|
4666
5043
|
&& requestedOpenAIModel
|
|
4667
5044
|
&& !requestedModelAvailableInGitHub
|
|
4668
5045
|
) {
|
|
@@ -4687,7 +5064,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4687
5064
|
let requestTargetsGrok = requestedProviderType === 'grok';
|
|
4688
5065
|
if (
|
|
4689
5066
|
!requestTargetsGrok
|
|
4690
|
-
&& !
|
|
5067
|
+
&& !runtimeBoundForModelGuards
|
|
4691
5068
|
&& requestedOpenAIModel
|
|
4692
5069
|
&& !requestedModelAvailableInGitHub
|
|
4693
5070
|
) {
|
|
@@ -4713,7 +5090,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4713
5090
|
let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
|
|
4714
5091
|
if (
|
|
4715
5092
|
!requestTargetsDeepSeek
|
|
4716
|
-
&& !
|
|
5093
|
+
&& !runtimeBoundForModelGuards
|
|
4717
5094
|
&& requestedOpenAIModel
|
|
4718
5095
|
&& !requestedModelAvailableInGitHub
|
|
4719
5096
|
) {
|
|
@@ -4743,6 +5120,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4743
5120
|
code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
|
|
4744
5121
|
});
|
|
4745
5122
|
}
|
|
5123
|
+
// Claude API conversations are bootstrap-created only as well, and asked
|
|
5124
|
+
// for by name only: their model ids are the Claude provider's. Moving a
|
|
5125
|
+
// chat between the subscription and the key is not a send's to make here.
|
|
5126
|
+
const runtimeUsesClaudeApi = runtimeProviderType === 'claude-api';
|
|
5127
|
+
if (!shouldCreateConversation && !runtimeUsesClaudeApi && requestedProviderType === 'claude-api') {
|
|
5128
|
+
return res.status(409).json({
|
|
5129
|
+
error: 'Claude API requires creating a new Claude API conversation',
|
|
5130
|
+
code: 'CLAUDE_API_REQUIRES_NEW_CONVERSATION',
|
|
5131
|
+
});
|
|
5132
|
+
}
|
|
4746
5133
|
if (
|
|
4747
5134
|
useOpenAIProvider
|
|
4748
5135
|
&& requestedOpenAIModel
|
|
@@ -4790,8 +5177,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4790
5177
|
// Claude conversations resolve models against the Claude provider
|
|
4791
5178
|
// catalog (the Copilot catalog does not know these ids). Per-turn model
|
|
4792
5179
|
// switching is allowed: each Claude turn is a fresh query() with resume.
|
|
4793
|
-
|
|
4794
|
-
|
|
5180
|
+
// A Claude API conversation runs the same CLI on an API key: the same
|
|
5181
|
+
// rules, against its own model list and efforts.
|
|
5182
|
+
const runtimeUsesClaude = runtimeProviderType === 'claude' || runtimeUsesClaudeApi;
|
|
5183
|
+
const configuredClaude = runtimeUsesClaude
|
|
5184
|
+
? (runtimeUsesClaudeApi ? getClaudeApiProviderSettings() : getClaudeProviderSettings())
|
|
5185
|
+
: null;
|
|
4795
5186
|
const requestedClaudeModel = runtimeUsesClaude ? String(model || '').trim() : '';
|
|
4796
5187
|
const availableClaudeModels = new Set([
|
|
4797
5188
|
String(configuredClaude?.model || '').trim(),
|
|
@@ -5017,6 +5408,24 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5017
5408
|
error: deepseekModel ? null : 'DeepSeek model is not configured',
|
|
5018
5409
|
}
|
|
5019
5410
|
: githubModelResolution)))));
|
|
5411
|
+
// A model Copilot does not list is named with its close matches, as the
|
|
5412
|
+
// bootstrap names it: "claude-opus-5-5" (the Claude form) gets "did you
|
|
5413
|
+
// mean 'claude-opus-5.5'?". Refused before anything is stored.
|
|
5414
|
+
if (!modelResolution.ok && modelResolution === githubModelResolution && explicitModelGiven) {
|
|
5415
|
+
const copilotCheck = checkRequestedProviderModel({
|
|
5416
|
+
requestedModel: requestedOpenAIModel,
|
|
5417
|
+
provider: 'github',
|
|
5418
|
+
models: Array.isArray(githubModelResolution.available) ? githubModelResolution.available : [],
|
|
5419
|
+
});
|
|
5420
|
+
if (copilotCheck.status === 'unknown') {
|
|
5421
|
+
return res.status(400).json({
|
|
5422
|
+
error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
|
|
5423
|
+
code: 'COPILOT_MODEL_UNAVAILABLE',
|
|
5424
|
+
supportedModels: copilotCheck.models,
|
|
5425
|
+
...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
|
|
5426
|
+
});
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
5020
5429
|
if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
|
|
5021
5430
|
const requestedModel = String(modelResolution.model || '').trim();
|
|
5022
5431
|
const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
|
|
@@ -5331,7 +5740,22 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5331
5740
|
const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
|
|
5332
5741
|
let conversationPreferences;
|
|
5333
5742
|
let imageOperation = null;
|
|
5743
|
+
// The switch's announcement, made once the send has committed.
|
|
5744
|
+
let announceBillingSwitch = null;
|
|
5334
5745
|
const persistMessageAndQueue = db.transaction(() => {
|
|
5746
|
+
// First, so the row below is stamped with the new billing; a refused
|
|
5747
|
+
// switch undoes the whole send.
|
|
5748
|
+
if (billingSwitch) {
|
|
5749
|
+
const switched = claudeBillingSwitchService?.switchBilling?.({
|
|
5750
|
+
conversationId: convId,
|
|
5751
|
+
to: billingSwitch.to,
|
|
5752
|
+
by: billingSwitch.by,
|
|
5753
|
+
model: claudeModel,
|
|
5754
|
+
deferAnnounce: true,
|
|
5755
|
+
}) || { ok: false, status: 501, code: 'CLAUDE_BILLING_UNAVAILABLE', error: 'The billing switch is unavailable' };
|
|
5756
|
+
if (!switched.ok) throw Object.assign(new Error(switched.error), { billingRefusal: switched });
|
|
5757
|
+
announceBillingSwitch = switched.announce || null;
|
|
5758
|
+
}
|
|
5335
5759
|
stmts.insertMsg.run(
|
|
5336
5760
|
msgId,
|
|
5337
5761
|
convId,
|
|
@@ -5394,7 +5818,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5394
5818
|
&& claudeModel !== String(existingRuntimeSession?.provider_model || '').trim()
|
|
5395
5819
|
&& typeof stmts.updateRuntimeSessionProvider?.run === 'function'
|
|
5396
5820
|
) {
|
|
5397
|
-
stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
|
|
5821
|
+
stmts.updateRuntimeSessionProvider.run(runtimeUsesClaudeApi ? 'claude-api' : 'claude', claudeModel, claudeModel, now, runtimeSession.id);
|
|
5398
5822
|
}
|
|
5399
5823
|
// DeepSeek follows the composer the same way: a relaunched worker reads
|
|
5400
5824
|
// provider_model, so it has to be the model last used.
|
|
@@ -5482,7 +5906,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5482
5906
|
});
|
|
5483
5907
|
try {
|
|
5484
5908
|
persistMessageAndQueue();
|
|
5909
|
+
try { announceBillingSwitch?.(); } catch {}
|
|
5485
5910
|
} catch (error) {
|
|
5911
|
+
if (error?.billingRefusal) {
|
|
5912
|
+
const refused = error.billingRefusal;
|
|
5913
|
+
return res.status(refused.status || 409).json({
|
|
5914
|
+
error: refused.error,
|
|
5915
|
+
code: refused.code,
|
|
5916
|
+
conversationId: convId,
|
|
5917
|
+
...(refused.model ? { model: refused.model } : {}),
|
|
5918
|
+
});
|
|
5919
|
+
}
|
|
5486
5920
|
// A replayed send (Background Sync outbox) with a caller-supplied id hits
|
|
5487
5921
|
// the messages.id primary key. Surface it as a clean 409 so the replay
|
|
5488
5922
|
// can treat "already accepted" as success instead of retrying forever.
|
|
@@ -5613,7 +6047,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5613
6047
|
messageId: msgId,
|
|
5614
6048
|
conversationId: convId,
|
|
5615
6049
|
runtimeSessionId: runtimeSession?.id || null,
|
|
5616
|
-
runtimeProviderType:
|
|
6050
|
+
runtimeProviderType: billingSwitch?.to
|
|
6051
|
+
|| String(runtimeSession?.provider_type || (useOpenAIProvider ? 'openai' : 'github')).trim().toLowerCase(),
|
|
6052
|
+
...(billingSwitch ? { billingSwitched: true } : {}),
|
|
5617
6053
|
runtimeProviderModel: runtimeSession?.provider_model || (useOpenAIProvider ? openAIModel : null),
|
|
5618
6054
|
ownerSessionId: ownerSessionId || null,
|
|
5619
6055
|
preferredRelayMode: conversationPreferences.preferredRelayMode,
|
|
@@ -5639,6 +6075,73 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5639
6075
|
}
|
|
5640
6076
|
: null,
|
|
5641
6077
|
});
|
|
6078
|
+
};
|
|
6079
|
+
app.post('/api/message', auth, handleSendMessage);
|
|
6080
|
+
|
|
6081
|
+
// POST /api/conversation/:id/billing — move a Claude chat between the
|
|
6082
|
+
// subscription and the saved API key (`to`: "claude" | "claude-api"): the
|
|
6083
|
+
// composer's toggle, the usage-limit note's button (`by: "usage-limit"`)
|
|
6084
|
+
// and an agent's `send` with a provider (`by: "agent"`). With `message`
|
|
6085
|
+
// (the body /api/message takes), the switch and the enqueue are one
|
|
6086
|
+
// transaction: a send that is refused leaves the billing as it was, and a
|
|
6087
|
+
// refused switch sends nothing. Applies from the next turn; a running one
|
|
6088
|
+
// finishes on its billing.
|
|
6089
|
+
app.post('/api/conversation/:id/billing', auth, async (req, res) => {
|
|
6090
|
+
if (!claudeBillingSwitchService) {
|
|
6091
|
+
return res.status(501).json({ ok: false, error: 'The billing switch is unavailable', code: 'CLAUDE_BILLING_UNAVAILABLE' });
|
|
6092
|
+
}
|
|
6093
|
+
const conversationId = String(req.params?.id || '').trim();
|
|
6094
|
+
const to = normalizeClaudeBilling(req.body?.to ?? req.body?.provider);
|
|
6095
|
+
const byValue = String(req.body?.by || '').trim().toLowerCase();
|
|
6096
|
+
const by = ['user', 'agent', 'usage-limit'].includes(byValue) ? byValue : 'user';
|
|
6097
|
+
const message = req.body?.message && typeof req.body.message === 'object' && !Array.isArray(req.body.message)
|
|
6098
|
+
? req.body.message
|
|
6099
|
+
: null;
|
|
6100
|
+
// Checked first, so a refusal names the switch's reason before the send's.
|
|
6101
|
+
const check = claudeBillingSwitchService.checkSwitch({ conversationId, to, model: message?.model || '' });
|
|
6102
|
+
if (!check.ok) {
|
|
6103
|
+
return res.status(check.status || 409).json({
|
|
6104
|
+
ok: false,
|
|
6105
|
+
error: check.error,
|
|
6106
|
+
code: check.code,
|
|
6107
|
+
conversationId,
|
|
6108
|
+
...(check.model ? { model: check.model } : {}),
|
|
6109
|
+
});
|
|
6110
|
+
}
|
|
6111
|
+
if (message) {
|
|
6112
|
+
const sendRequest = Object.create(req);
|
|
6113
|
+
sendRequest.body = {
|
|
6114
|
+
...message,
|
|
6115
|
+
conversationId,
|
|
6116
|
+
newConversation: false,
|
|
6117
|
+
provider: undefined,
|
|
6118
|
+
providerType: undefined,
|
|
6119
|
+
billing: undefined,
|
|
6120
|
+
};
|
|
6121
|
+
sendRequest.oarBillingSwitch = { to: check.to, by };
|
|
6122
|
+
return handleSendMessage(sendRequest, res);
|
|
6123
|
+
}
|
|
6124
|
+
if (by === 'usage-limit' && check.to !== 'claude-api') {
|
|
6125
|
+
return res.status(400).json({ ok: false, error: 'The usage-limit note switches to the API key only', code: 'CLAUDE_BILLING_INVALID' });
|
|
6126
|
+
}
|
|
6127
|
+
const result = claudeBillingSwitchService.switchBilling({ conversationId, to: check.to, by });
|
|
6128
|
+
if (!result?.ok) {
|
|
6129
|
+
return res.status(result?.status || 409).json({
|
|
6130
|
+
ok: false,
|
|
6131
|
+
error: result?.error || 'The billing switch failed',
|
|
6132
|
+
code: result?.code || null,
|
|
6133
|
+
conversationId,
|
|
6134
|
+
...(result?.model ? { model: result.model } : {}),
|
|
6135
|
+
});
|
|
6136
|
+
}
|
|
6137
|
+
return res.json({
|
|
6138
|
+
ok: true,
|
|
6139
|
+
conversationId,
|
|
6140
|
+
changed: result.changed,
|
|
6141
|
+
provider: result.provider,
|
|
6142
|
+
released: !!result.released,
|
|
6143
|
+
billing: claudeBillingSwitchService.billingState(conversationId),
|
|
6144
|
+
});
|
|
5642
6145
|
});
|
|
5643
6146
|
|
|
5644
6147
|
app.post('/api/heartbeat', auth, (req, res) => {
|
|
@@ -5704,7 +6207,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5704
6207
|
|| (stored.supported === true) !== (steering.supported === true)
|
|
5705
6208
|
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5706
6209
|
// The running CLI's compaction window (context-usage modal).
|
|
5707
|
-
|| JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null)
|
|
6210
|
+
|| JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null)
|
|
6211
|
+
// Whether a billing swap may replace the worker now, and why not.
|
|
6212
|
+
|| (stored.releasable ?? null) !== (steering.releasable ?? null)
|
|
6213
|
+
|| (stored.releaseHold || null) !== (steering.releaseHold || null)
|
|
6214
|
+
|| (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
|
|
5708
6215
|
if (workerForSteering && changed) {
|
|
5709
6216
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
5710
6217
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -5713,6 +6220,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5713
6220
|
io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
|
|
5714
6221
|
}
|
|
5715
6222
|
}
|
|
6223
|
+
// The worker's kind (a Claude chat's billing runs only on its own kind):
|
|
6224
|
+
// recorded when it says one and the registry has another.
|
|
6225
|
+
const heartbeatKind = normalizeWorkerKind(req.body?.workerKind);
|
|
6226
|
+
if (heartbeatKind) {
|
|
6227
|
+
const workerForKind = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
|
|
6228
|
+
if (workerForKind && workerForKind.kind !== heartbeatKind) {
|
|
6229
|
+
sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
|
|
6230
|
+
}
|
|
6231
|
+
}
|
|
5716
6232
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5717
6233
|
// The worker's digest of its live background-task ids against the set
|
|
5718
6234
|
// held for it (keyed as the worker posts, by its session id). They
|
|
@@ -5930,9 +6446,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5930
6446
|
return res.status(404).json({ error: 'Runtime session not found for conversation' });
|
|
5931
6447
|
}
|
|
5932
6448
|
// The DeepSeek worker runs the same CLI (against DeepSeek) and resumes
|
|
5933
|
-
// its sessions the same way
|
|
6449
|
+
// its sessions the same way; the Claude API worker is the Claude one on
|
|
6450
|
+
// an API key.
|
|
5934
6451
|
const nativeSessionProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
|
|
5935
|
-
if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek') {
|
|
6452
|
+
if (nativeSessionProvider !== 'claude' && nativeSessionProvider !== 'deepseek' && nativeSessionProvider !== 'claude-api') {
|
|
5936
6453
|
return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
|
|
5937
6454
|
}
|
|
5938
6455
|
if (typeof stmts.updateRuntimeSessionClaudeNativeSessionId?.run !== 'function') {
|
|
@@ -5974,9 +6491,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5974
6491
|
return res.status(404).json({ error: 'Runtime session not found for conversation' });
|
|
5975
6492
|
}
|
|
5976
6493
|
// The Claude Cloud worker reports the same shape (the cloud session's
|
|
5977
|
-
// context and token usage), and so
|
|
6494
|
+
// context and token usage), and so do the DeepSeek and Claude API ones
|
|
6495
|
+
// (the same CLI).
|
|
5978
6496
|
const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
|
|
5979
|
-
if (
|
|
6497
|
+
if (!['claude', 'claude-cloud', 'deepseek', 'claude-api'].includes(contextUsageProvider)) {
|
|
5980
6498
|
return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
|
|
5981
6499
|
}
|
|
5982
6500
|
if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
|
|
@@ -6057,6 +6575,42 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6057
6575
|
res.json({ ok: true, at: booked.at, models: booked.models.map((entry) => entry.model) });
|
|
6058
6576
|
});
|
|
6059
6577
|
|
|
6578
|
+
// POST /api/claude-api-usage — both Claude workers (the subscription's and
|
|
6579
|
+
// the Claude API one) report, after every turn, the CLI process's own
|
|
6580
|
+
// totals per model with Claude Code's cost estimate (`runCounts`) and the
|
|
6581
|
+
// CLI's session totals (`sessionTotals`). The relay books what turns on
|
|
6582
|
+
// the key cost (claude-api-usage-service.mjs); a subscription report only
|
|
6583
|
+
// keeps the session's mark, against which a resumed run's first result is
|
|
6584
|
+
// measured. What a report ran on is the posting worker's registered kind;
|
|
6585
|
+
// a body that says otherwise is logged as a fence failure.
|
|
6586
|
+
app.post('/api/claude-api-usage', auth, (req, res) => {
|
|
6587
|
+
touchCli();
|
|
6588
|
+
if (!claudeApiUsageService) return res.status(501).json({ error: 'Claude API usage storage is unavailable' });
|
|
6589
|
+
const conversationId = String(req.body?.conversationId || '').trim();
|
|
6590
|
+
if (!conversationId) return res.status(400).json({ error: 'Missing conversationId' });
|
|
6591
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation.get(conversationId);
|
|
6592
|
+
if (!runtimeSession) return res.status(404).json({ error: 'Runtime session not found for conversation' });
|
|
6593
|
+
if (!normalizeClaudeBilling(runtimeSession.provider_type)) {
|
|
6594
|
+
return res.status(409).json({ error: 'Conversation is not a Claude conversation' });
|
|
6595
|
+
}
|
|
6596
|
+
const parsed = normalizeClaudeApiUsageReport(req.body);
|
|
6597
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
6598
|
+
const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
|
|
6599
|
+
const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
|
|
6600
|
+
const statedKind = normalizeClaudeBilling(parsed.report.billing);
|
|
6601
|
+
if (registeredKind && statedKind && registeredKind !== statedKind) {
|
|
6602
|
+
console.warn(`[${ts()}] BILLING FENCE FAILURE usage conv=${conversationId.slice(0, 8)} stated=${statedKind} registered=${registeredKind} — booked as the registered kind`);
|
|
6603
|
+
}
|
|
6604
|
+
const billing = registeredKind || statedKind;
|
|
6605
|
+
if (!billing) return res.status(400).json({ error: 'Missing billing' });
|
|
6606
|
+
const booked = claudeApiUsageService.recordReport({ conversationId, billing, report: parsed.report });
|
|
6607
|
+
if (!booked) return res.status(400).json({ error: 'Missing usable usage metrics' });
|
|
6608
|
+
if (booked.booked.length) {
|
|
6609
|
+
try { checkClaudeApiCap('usage'); } catch {}
|
|
6610
|
+
}
|
|
6611
|
+
res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
|
|
6612
|
+
});
|
|
6613
|
+
|
|
6060
6614
|
// POST /api/claude-plan-usage — Claude worker reports the session's
|
|
6061
6615
|
// structured /usage data (plan rate-limit windows + session cost totals).
|
|
6062
6616
|
//
|
|
@@ -6320,6 +6874,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6320
6874
|
});
|
|
6321
6875
|
|
|
6322
6876
|
// GET /api/pending — CLI fetches next pending message
|
|
6877
|
+
// The kind a poller of GET /api/pending is fenced as: the one its worker
|
|
6878
|
+
// stated, else none, as on the socket. A worker of unknown kind gets the
|
|
6879
|
+
// rows of a chat that never switched, and none of a chat that has
|
|
6880
|
+
// (kind-unknown): such a chat waits for a worker that says its kind.
|
|
6881
|
+
function httpPendingWorkerKind(requesterSessionId) {
|
|
6882
|
+
const stated = requesterSessionId ? sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind : null;
|
|
6883
|
+
return normalizeWorkerKind(stated) || null;
|
|
6884
|
+
}
|
|
6885
|
+
|
|
6323
6886
|
app.get('/api/pending', auth, async (req, res) => {
|
|
6324
6887
|
touchCli();
|
|
6325
6888
|
const requester = readBridgeIdentity(req);
|
|
@@ -6447,6 +7010,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6447
7010
|
sessionWorkerSupervisor,
|
|
6448
7011
|
relayRestartOrchestrator,
|
|
6449
7012
|
inFlightProcessingCount: Number(counts.processingCount || 0),
|
|
7013
|
+
// No Claude worker polls here; one that does is fenced as on the
|
|
7014
|
+
// socket: by the kind it stated, the key's claim check, and a fenced
|
|
7015
|
+
// row asks for the swap or fails closed as there.
|
|
7016
|
+
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7017
|
+
claudeApiClaimCheck,
|
|
7018
|
+
onBillingFence: onClaudeBillingFence,
|
|
6450
7019
|
telemetry: (payload = {}) => {
|
|
6451
7020
|
if (!sessionWorkerRoutingEnabled) return;
|
|
6452
7021
|
emitSessionWorkerTelemetry(payload.event, {
|
|
@@ -6664,6 +7233,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6664
7233
|
sessionWorkerSupervisor,
|
|
6665
7234
|
relayRestartOrchestrator,
|
|
6666
7235
|
inFlightProcessingCount: Number(queueCounts()?.processingCount || 0),
|
|
7236
|
+
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7237
|
+
claudeApiClaimCheck,
|
|
7238
|
+
onBillingFence: onClaudeBillingFence,
|
|
6667
7239
|
});
|
|
6668
7240
|
if (recoveryDequeue?.message) {
|
|
6669
7241
|
msg = recoveryDequeue.message;
|
|
@@ -7384,7 +7956,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7384
7956
|
? (usageLimitPauseService?.planPause?.({
|
|
7385
7957
|
terminalError: req.body.terminalError,
|
|
7386
7958
|
conversationId: targetConversationId,
|
|
7387
|
-
|
|
7959
|
+
// What the refused turn ran on (its row's billing), and the chat's
|
|
7960
|
+
// billing of now: a chat switched to the API key meanwhile carries on
|
|
7961
|
+
// there instead of waiting for the subscription's reset.
|
|
7962
|
+
providerType: normalizeClaudeBilling(q?.provider)
|
|
7963
|
+
|| stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
|
|
7964
|
+
chatProviderType: stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type,
|
|
7388
7965
|
queueRow: q,
|
|
7389
7966
|
partialText: req.body?.partialText,
|
|
7390
7967
|
}) || null)
|
|
@@ -7504,6 +8081,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7504
8081
|
const conversationProvider = String(
|
|
7505
8082
|
stmts.getRuntimeSessionByConversation?.get(targetConversationId)?.provider_type || '',
|
|
7506
8083
|
).trim().toLowerCase() || 'github';
|
|
8084
|
+
const responderKind = sessionWorkerRegistry?.getWorker?.(normalizeSessionWorkerId(responseBridgeIdentity?.sessionId))?.kind || null;
|
|
7507
8085
|
const executedProvider = resolveExecutedProviderForResponse({
|
|
7508
8086
|
stmts,
|
|
7509
8087
|
responseBridgeIdentity,
|
|
@@ -7511,13 +8089,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7511
8089
|
// Image operations are executed by this server against the conversation's
|
|
7512
8090
|
// own provider API, then finalized through an identity-less self-post.
|
|
7513
8091
|
serverExecutedOperation: !!q?.image_operation_id,
|
|
8092
|
+
responderKind,
|
|
7514
8093
|
});
|
|
8094
|
+
// A Claude chat's row ran on a worker of another billing than it was
|
|
8095
|
+
// stamped with: the delivery fence failed. What ran is recorded.
|
|
8096
|
+
const stampedBilling = normalizeClaudeBilling(q?.provider);
|
|
8097
|
+
const ranBilling = normalizeClaudeBilling(executedProvider);
|
|
8098
|
+
// A claimed turn on the key answered (it passed the claim check): the
|
|
8099
|
+
// next refusal of a self-started turn there is a new episode, with its
|
|
8100
|
+
// own note. A continuation that slipped through before its stop is not.
|
|
8101
|
+
if (ranBilling === 'claude-api' && String(q?.kind || '') !== 'continuation') claudeApiStopLedger.clear(targetConversationId);
|
|
8102
|
+
if (stampedBilling && ranBilling && stampedBilling !== ranBilling) {
|
|
8103
|
+
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`);
|
|
8104
|
+
}
|
|
8105
|
+
// A Claude chat that switched billing mid-turn finished that turn on the
|
|
8106
|
+
// billing it started with: not a mismatch.
|
|
8107
|
+
const switchedClaudeTurn = ranBilling && normalizeClaudeBilling(conversationProvider) && (!stampedBilling || stampedBilling === ranBilling);
|
|
7515
8108
|
if (executedProvider === 'unknown') {
|
|
7516
8109
|
// An identity that names no runtime session is itself an anomaly (a stale
|
|
7517
8110
|
// or forged header), just not evidence of cross-provider execution. Say so
|
|
7518
8111
|
// rather than staying silent.
|
|
7519
8112
|
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) {
|
|
8113
|
+
} else if (executedProvider !== conversationProvider && !switchedClaudeTurn) {
|
|
7521
8114
|
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
8115
|
}
|
|
7523
8116
|
const responderConversationCheck = resolveResponderConversationMismatch({
|
|
@@ -7874,8 +8467,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7874
8467
|
// worker (detached background shells) serving both Copilot-CLI providers.
|
|
7875
8468
|
// A Claude Cloud session can take a turn of its own too: one the agent
|
|
7876
8469
|
// starts when its background work reports back, or one started on
|
|
7877
|
-
// claude.ai; the cloud worker follows it and registers it here.
|
|
7878
|
-
|
|
8470
|
+
// claude.ai; the cloud worker follows it and registers it here. The Claude
|
|
8471
|
+
// API worker is the Claude one on an API key.
|
|
8472
|
+
const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud', 'claude-api']);
|
|
7879
8473
|
|
|
7880
8474
|
// POST /api/continuation-turn — a session worker's CLI started a turn on its
|
|
7881
8475
|
// own (a background task's notification). The synthetic queue row gives that
|
|
@@ -7928,7 +8522,35 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7928
8522
|
const attemptId = uuidv4();
|
|
7929
8523
|
const now = new Date().toISOString();
|
|
7930
8524
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
7931
|
-
|
|
8525
|
+
// A Claude chat's continuation runs on the worker that started it,
|
|
8526
|
+
// whose billing may no longer be the chat's (a switch waiting for this
|
|
8527
|
+
// very background work): stamped with that worker's kind.
|
|
8528
|
+
const continuationBilling = normalizeClaudeBilling(sessionWorkerRegistry?.getWorker?.(ownerSessionId)?.kind);
|
|
8529
|
+
// A turn the CLI starts by itself on the key passes no claim, so the
|
|
8530
|
+
// claim check (the cap, the key, the switch) is asked here: when it
|
|
8531
|
+
// refuses, the row is still minted (the worker needs its id), with a
|
|
8532
|
+
// stop for it that the worker's control poller picks up; the stop's
|
|
8533
|
+
// result stops the worker's background tasks and fails the row.
|
|
8534
|
+
let capStop = null;
|
|
8535
|
+
if ((continuationBilling || normalizeClaudeBilling(providerType)) === 'claude-api') {
|
|
8536
|
+
const refusal = claudeApiContinuationRefusal();
|
|
8537
|
+
if (refusal) {
|
|
8538
|
+
const code = String(refusal.code || 'CLAUDE_API_NOT_CONFIGURED');
|
|
8539
|
+
capStop = {
|
|
8540
|
+
source: CLAUDE_API_CAP_STOP_SOURCE,
|
|
8541
|
+
code,
|
|
8542
|
+
error: String(refusal.error || '').trim() || 'Claude API is unavailable, so this turn did not run.',
|
|
8543
|
+
// Its note says to switch Claude API on rather than to set it up.
|
|
8544
|
+
...(refusal.switchedOff === true ? { switchedOff: true } : {}),
|
|
8545
|
+
episode: claudeApiStopEpisode(code),
|
|
8546
|
+
};
|
|
8547
|
+
} else {
|
|
8548
|
+
claudeApiStopLedger.clear(conversationId);
|
|
8549
|
+
}
|
|
8550
|
+
}
|
|
8551
|
+
// The row, its stamp and a cap stop commit together (a database without
|
|
8552
|
+
// transactions, a test's stand-in, runs them in order).
|
|
8553
|
+
const insertContinuationRow = () => {
|
|
7932
8554
|
stmts.insertContinuationQ.run(
|
|
7933
8555
|
messageId,
|
|
7934
8556
|
conversationId,
|
|
@@ -7945,6 +8567,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7945
8567
|
attemptId,
|
|
7946
8568
|
operationId,
|
|
7947
8569
|
);
|
|
8570
|
+
if (continuationBilling) stmts.setQueueProvider?.run?.(continuationBilling, messageId);
|
|
8571
|
+
if (capStop) {
|
|
8572
|
+
insertRelayControlRequest.run(uuidv4(), 'abort_turn', conversationId, messageId, ownerSessionId, JSON.stringify(capStop), now, now);
|
|
8573
|
+
}
|
|
8574
|
+
};
|
|
8575
|
+
const insertContinuation = typeof db.transaction === 'function' ? db.transaction(insertContinuationRow) : insertContinuationRow;
|
|
8576
|
+
try {
|
|
8577
|
+
insertContinuation();
|
|
7948
8578
|
} catch (error) {
|
|
7949
8579
|
// Lost the race against a concurrent duplicate registration: the unique
|
|
7950
8580
|
// index on continuation_op_id guarantees the winner's row exists now.
|
|
@@ -7954,7 +8584,20 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7954
8584
|
}
|
|
7955
8585
|
throw error;
|
|
7956
8586
|
}
|
|
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)}` : ''}`);
|
|
8587
|
+
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' : ''}`);
|
|
8588
|
+
if (capStop) {
|
|
8589
|
+
// Not left to the stop: a turn that ends before the worker's control
|
|
8590
|
+
// poller takes it still gets its tasks stopped and the note.
|
|
8591
|
+
try {
|
|
8592
|
+
const tasks = stopClaudeApiBackgroundTasks(conversationId, ownerSessionId);
|
|
8593
|
+
if (!claudeApiStopLedger.has(conversationId, capStop.episode)) {
|
|
8594
|
+
writeClaudeApiStopNote({ conversationId, relayMode, refusal: capStop, tasks, now });
|
|
8595
|
+
claudeApiStopLedger.note(conversationId, capStop.episode);
|
|
8596
|
+
}
|
|
8597
|
+
} catch (error) {
|
|
8598
|
+
console.warn(`[${ts()}] CLAUDE API STOP conv=${conversationId.slice(0, 8)}: ${error?.message || error}`);
|
|
8599
|
+
}
|
|
8600
|
+
}
|
|
7958
8601
|
io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
|
|
7959
8602
|
io.emit('queue_updated', { continuation: 1 });
|
|
7960
8603
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
@@ -8208,7 +8851,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8208
8851
|
// The providers whose workers name a shell task's output (a file, a spool
|
|
8209
8852
|
// or a snapshot). A task of theirs without one comes from a worker that
|
|
8210
8853
|
// started before the console existed.
|
|
8211
|
-
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek']);
|
|
8854
|
+
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok', 'deepseek', 'claude-api']);
|
|
8212
8855
|
const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
|
|
8213
8856
|
|
|
8214
8857
|
// Why a task has no output to show, for the console's `none` answer. The
|
|
@@ -8256,7 +8899,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8256
8899
|
// 256 KB; with `before` (a startOffset of an earlier answer): the lines
|
|
8257
8900
|
// ending there. Everything is cleaned (controls out, colours kept,
|
|
8258
8901
|
// credentials scrubbed) for every reader. Another relay's agent reads it
|
|
8259
|
-
// too (
|
|
8902
|
+
// too (relay_tool read_task_output, read permission): no inbound switch,
|
|
8260
8903
|
// nothing it reads changes anything.
|
|
8261
8904
|
app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
|
|
8262
8905
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
@@ -8947,12 +9590,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8947
9590
|
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
|
|
8948
9591
|
return res.json({ ok: true, mismatch: true });
|
|
8949
9592
|
}
|
|
8950
|
-
|
|
9593
|
+
const handBackClass = String(req.body?.class || '').trim();
|
|
9594
|
+
if (q && q.status === 'processing' && (handBackClass === 'steering-held' || handBackClass === 'kind-mismatch')) {
|
|
8951
9595
|
// The worker refused to push the message because its live turn is
|
|
8952
9596
|
// holding steering (an open question card, a compaction, an adoption).
|
|
8953
9597
|
// Not a failure: no retry increment, no backoff, and the owner is kept —
|
|
8954
9598
|
// the same worker takes it again the moment it signals ready, which is
|
|
8955
9599
|
// when the hold ends, so the message steers into the resumed turn.
|
|
9600
|
+
// `kind-mismatch`: a Claude chat's row reached a worker of the other
|
|
9601
|
+
// billing; back to pending (re-stamped to the chat's billing by the
|
|
9602
|
+
// queue trigger) for the worker the billing swap brings up.
|
|
8956
9603
|
const result = db.prepare(`
|
|
8957
9604
|
UPDATE queue
|
|
8958
9605
|
SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
|
|
@@ -8962,7 +9609,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8962
9609
|
settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
8963
9610
|
settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
8964
9611
|
io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
|
|
8965
|
-
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class
|
|
9612
|
+
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=${handBackClass} retry=${Number(q.retry_count || 0)}`);
|
|
8966
9613
|
}
|
|
8967
9614
|
return res.json({ ok: true, held: true });
|
|
8968
9615
|
}
|