@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
|
@@ -68,6 +68,11 @@ import {
|
|
|
68
68
|
outputDirOf,
|
|
69
69
|
usableOutputPath,
|
|
70
70
|
} from './task-output-discovery.mjs';
|
|
71
|
+
import {
|
|
72
|
+
checkClaudeSubscriptionInit,
|
|
73
|
+
describeClaudeSubscriptionFailure,
|
|
74
|
+
isKnownApiKeySource,
|
|
75
|
+
} from './claude-subscription-key-check.mjs';
|
|
71
76
|
|
|
72
77
|
/**
|
|
73
78
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -448,6 +453,13 @@ function isPhantomResult(sdkMessage) {
|
|
|
448
453
|
* every live task is stopped via the SDK and the wind-down proceeds through
|
|
449
454
|
* the normal notification/continuation path.
|
|
450
455
|
*/
|
|
456
|
+
|
|
457
|
+
// A Claude chat's billing named by a delivered row or a worker kind: the
|
|
458
|
+
// subscription ('claude') or the saved API key ('claude-api'); '' otherwise.
|
|
459
|
+
function claudeBillingOf(value) {
|
|
460
|
+
const billing = String(value || '').trim().toLowerCase();
|
|
461
|
+
return billing === 'claude' || billing === 'claude-api' ? billing : '';
|
|
462
|
+
}
|
|
451
463
|
export function createClaudeSessionRunner({
|
|
452
464
|
api,
|
|
453
465
|
sdkSessionId,
|
|
@@ -571,7 +583,7 @@ export function createClaudeSessionRunner({
|
|
|
571
583
|
// worker asks for a heartbeat, on which the relay learns which process
|
|
572
584
|
// holds the session and asks for the set again.
|
|
573
585
|
onBackgroundTaskSetStale = () => {},
|
|
574
|
-
// Whether CLI processes carry the relay's `
|
|
586
|
+
// Whether CLI processes carry the relay's `relay_tool` tool
|
|
575
587
|
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
576
588
|
remoteRelayToolGate = null,
|
|
577
589
|
// Records every sub-agent's frames as its timeline, run-scoped (see
|
|
@@ -583,6 +595,16 @@ export function createClaudeSessionRunner({
|
|
|
583
595
|
backend = null,
|
|
584
596
|
dbg = () => {},
|
|
585
597
|
} = {}) {
|
|
598
|
+
// The backend that maps models, effort, thinking and images into its own
|
|
599
|
+
// terms. A backend on Claude's own models (`claudeModels`, the Claude API
|
|
600
|
+
// backend) brings only its environment, its failures and what it reports:
|
|
601
|
+
// everything model-shaped stays exactly as the Claude worker has it.
|
|
602
|
+
const modelBackend = backend && backend.claudeModels !== true ? backend : null;
|
|
603
|
+
// The kind of worker this runner is part of: 'claude' on the host's login,
|
|
604
|
+
// else the backend's provider ('claude-api', 'deepseek').
|
|
605
|
+
const workerKind = String(backend?.providerType || backend?.id || 'claude').trim().toLowerCase();
|
|
606
|
+
// A relay-announced billing swap is noted once per worker.
|
|
607
|
+
let billingSwapNoted = false;
|
|
586
608
|
// Neither Claude's plan windows nor its usage limit exist on another backend.
|
|
587
609
|
const readPlanUsageForTurn = backend && !backend.reportsPlanUsage ? async () => null : readPlanUsageImpl;
|
|
588
610
|
const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
|
|
@@ -590,6 +612,9 @@ export function createClaudeSessionRunner({
|
|
|
590
612
|
api,
|
|
591
613
|
dbg,
|
|
592
614
|
takeWorkflowRuns: () => drainSettledWorkflowRuns(),
|
|
615
|
+
// Both Claude billings report to the Claude API booking; DeepSeek has
|
|
616
|
+
// its own.
|
|
617
|
+
...(claudeBillingOf(workerKind) ? { billingUsageRoute: '/api/claude-api-usage', billingKind: workerKind } : {}),
|
|
593
618
|
...(backend
|
|
594
619
|
? {
|
|
595
620
|
describeFailure: backend.describeFailure,
|
|
@@ -597,12 +622,17 @@ export function createClaudeSessionRunner({
|
|
|
597
622
|
failureCodePrefix: backend.id || 'deepseek',
|
|
598
623
|
reportsPlanUsage: backend.reportsPlanUsage !== false,
|
|
599
624
|
usageRoute: backend.usageRoute || null,
|
|
600
|
-
|
|
601
|
-
|
|
625
|
+
...(modelBackend
|
|
626
|
+
? {
|
|
627
|
+
usageModelKey: modelBackend.relayModel,
|
|
628
|
+
relayModel: (value) => (value ? modelBackend.relayModel(value) : value),
|
|
629
|
+
}
|
|
630
|
+
: {}),
|
|
602
631
|
}
|
|
603
|
-
:
|
|
632
|
+
// The subscription worker's own refusal: a turn that came up on an API key.
|
|
633
|
+
: { describeFailure: describeClaudeSubscriptionFailure }),
|
|
604
634
|
});
|
|
605
|
-
// `
|
|
635
|
+
// `relay_tool` is registered only while this relay has paired remotes.
|
|
606
636
|
// Asked once when the worker starts, and again whenever a CLI process winds
|
|
607
637
|
// down, so a remote paired later reaches the next process. A cold spawn
|
|
608
638
|
// waits for the pending answer (handlePendingPayload): the tool list is
|
|
@@ -706,7 +736,7 @@ export function createClaudeSessionRunner({
|
|
|
706
736
|
// Per-message model wins so the composer can switch Claude models between
|
|
707
737
|
// turns; the conversation's provider model and worker default are fallbacks.
|
|
708
738
|
const requestedModel = String(message.model || '').trim();
|
|
709
|
-
const ownModel =
|
|
739
|
+
const ownModel = modelBackend ? modelBackend.isModel(requestedModel) : requestedModel.toLowerCase().startsWith('claude-');
|
|
710
740
|
const perTurnModel = requestedModel.toLowerCase() !== 'auto' && ownModel
|
|
711
741
|
? requestedModel
|
|
712
742
|
: '';
|
|
@@ -1821,8 +1851,8 @@ export function createClaudeSessionRunner({
|
|
|
1821
1851
|
// A backend's CLI names a sub-agent's model in Claude's terms (the
|
|
1822
1852
|
// spawn's "opus", a Claude id of a CLI without the alias pins): the
|
|
1823
1853
|
// badge shows the backend's model that runs it.
|
|
1824
|
-
const model = knownModel &&
|
|
1825
|
-
? (
|
|
1854
|
+
const model = knownModel && modelBackend?.subagentModel
|
|
1855
|
+
? (modelBackend.subagentModel(knownModel, processRef.initModel || processRef.model) || knownModel)
|
|
1826
1856
|
: knownModel;
|
|
1827
1857
|
return {
|
|
1828
1858
|
taskId,
|
|
@@ -2460,7 +2490,7 @@ export function createClaudeSessionRunner({
|
|
|
2460
2490
|
// set to `auto` published responses with model: null.
|
|
2461
2491
|
// A backend's CLI names the model in its own terms ("deepseek-flash[1m]").
|
|
2462
2492
|
const initModel = String(sdkMessage.model || '').trim();
|
|
2463
|
-
proc.initModel = (
|
|
2493
|
+
proc.initModel = (modelBackend && initModel ? modelBackend.relayModel(initModel) : initModel) || proc.initModel;
|
|
2464
2494
|
// A fresh init with no active turn AND nothing queued to deliver is the CLI
|
|
2465
2495
|
// opening a turn on its own — a background-task continuation ("you will be
|
|
2466
2496
|
// notified"). The real SDK does NOT emit a user-message replay for these
|
|
@@ -2600,7 +2630,7 @@ export function createClaudeSessionRunner({
|
|
|
2600
2630
|
// `auto` composer keeps the CLI's own behavior — only the recorded
|
|
2601
2631
|
// model follows the switch.
|
|
2602
2632
|
const reportedFallback = String(sdkMessage.fallbackModel || '').trim();
|
|
2603
|
-
const fallbackModel =
|
|
2633
|
+
const fallbackModel = modelBackend && reportedFallback ? modelBackend.relayModel(reportedFallback) : reportedFallback;
|
|
2604
2634
|
if (fallbackModel) {
|
|
2605
2635
|
proc.initModel = fallbackModel;
|
|
2606
2636
|
if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
|
|
@@ -3604,10 +3634,10 @@ export function createClaudeSessionRunner({
|
|
|
3604
3634
|
const model = resolvePerTurnModel(message);
|
|
3605
3635
|
// A backend's effort is its own (DeepSeek: low/high/max), and so is its
|
|
3606
3636
|
// thinking switch (DeepSeek: always on).
|
|
3607
|
-
const effort =
|
|
3637
|
+
const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
|
|
3608
3638
|
const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
|
|
3609
3639
|
const thinkingState = getThinking() || {};
|
|
3610
|
-
const thinkingEnabled =
|
|
3640
|
+
const thinkingEnabled = modelBackend ? modelBackend.thinkingEnabled(effort) : parseThinkingEnabled(thinkingState.enabled);
|
|
3611
3641
|
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
3612
3642
|
const attribution = getAttribution() || null;
|
|
3613
3643
|
const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
|
|
@@ -3640,7 +3670,11 @@ export function createClaudeSessionRunner({
|
|
|
3640
3670
|
appendClass: modeAppendClass(relayMode),
|
|
3641
3671
|
permissionMode: permissionModeForRelayMode(relayMode),
|
|
3642
3672
|
// What only a fresh CLI of the backend takes (adaptProcess compares it).
|
|
3643
|
-
spawnKey:
|
|
3673
|
+
spawnKey: modelBackend ? modelBackend.spawnKey(message, { model }) : '',
|
|
3674
|
+
// Whether the API answered since the last result (noteApiTraffic), and
|
|
3675
|
+
// the error a backend's init check ended the process with.
|
|
3676
|
+
trafficSinceResult: false,
|
|
3677
|
+
failClosed: null,
|
|
3644
3678
|
liveTasks: new Map(),
|
|
3645
3679
|
knownTasks: new Set(),
|
|
3646
3680
|
// taskId → the row of a task that left the live set, until its
|
|
@@ -3834,6 +3868,17 @@ export function createClaudeSessionRunner({
|
|
|
3834
3868
|
let streamError = null;
|
|
3835
3869
|
try {
|
|
3836
3870
|
for await (const sdkMessage of processRef.turn) {
|
|
3871
|
+
// A backend that checks the CLI's credential (the Claude API
|
|
3872
|
+
// backend: the key's source on every init) stops the process on a
|
|
3873
|
+
// wrong one before the init is handled: the open turns fail with
|
|
3874
|
+
// its note (cleanupProcess), and nothing more is read.
|
|
3875
|
+
const refused = refuseOnInit(processRef, sdkMessage);
|
|
3876
|
+
if (refused) {
|
|
3877
|
+
streamError = refused;
|
|
3878
|
+
abortController.abort();
|
|
3879
|
+
break;
|
|
3880
|
+
}
|
|
3881
|
+
noteApiTraffic(processRef, sdkMessage);
|
|
3837
3882
|
await onSdkMessage(processRef, sdkMessage);
|
|
3838
3883
|
syncDeliveryReadiness();
|
|
3839
3884
|
}
|
|
@@ -3859,6 +3904,42 @@ export function createClaudeSessionRunner({
|
|
|
3859
3904
|
return processRef;
|
|
3860
3905
|
}
|
|
3861
3906
|
|
|
3907
|
+
/**
|
|
3908
|
+
* The backend's verdict on a `system/init` (`checkInit`): null to go on, or
|
|
3909
|
+
* the Error that ends the process. `afterTraffic` says whether the turn had
|
|
3910
|
+
* already talked to the API since the last result.
|
|
3911
|
+
*/
|
|
3912
|
+
function refuseOnInit(processRef, sdkMessage) {
|
|
3913
|
+
// A backend checks its own credential; the subscription worker checks
|
|
3914
|
+
// that the CLI took no API key (claude-subscription-key-check.mjs).
|
|
3915
|
+
const checkInit = typeof backend?.checkInit === 'function'
|
|
3916
|
+
? backend.checkInit
|
|
3917
|
+
: (backend ? null : checkClaudeSubscriptionInit);
|
|
3918
|
+
if (typeof checkInit !== 'function') return null;
|
|
3919
|
+
if (sdkMessage?.type !== 'system' || sdkMessage?.subtype !== 'init') return null;
|
|
3920
|
+
if (!backend && sdkMessage.apiKeySource !== undefined && !isKnownApiKeySource(sdkMessage.apiKeySource) && !processRef.unknownKeySourceLogged) {
|
|
3921
|
+
processRef.unknownKeySourceLogged = true;
|
|
3922
|
+
dbg('init names an API key source this relay does not know; the subscription turn goes on', String(sdkMessage.apiKeySource).slice(0, 40));
|
|
3923
|
+
}
|
|
3924
|
+
let refused = null;
|
|
3925
|
+
try {
|
|
3926
|
+
refused = checkInit(sdkMessage, { afterTraffic: processRef.trafficSinceResult === true });
|
|
3927
|
+
} catch (error) {
|
|
3928
|
+
refused = error instanceof Error ? error : new Error(String(error));
|
|
3929
|
+
}
|
|
3930
|
+
if (!refused) return null;
|
|
3931
|
+
processRef.failClosed = refused;
|
|
3932
|
+
dbg('init refused by the backend', refused.code || '', refused.message);
|
|
3933
|
+
return refused;
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
/** Whether the API has answered since the last result: a stream event or an assistant frame. */
|
|
3937
|
+
function noteApiTraffic(processRef, sdkMessage) {
|
|
3938
|
+
const type = sdkMessage?.type;
|
|
3939
|
+
if (type === 'stream_event' || type === 'assistant') processRef.trafficSinceResult = true;
|
|
3940
|
+
else if (type === 'result') processRef.trafficSinceResult = false;
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3862
3943
|
function expectSettingsReinit() {
|
|
3863
3944
|
settingsReinitsOwed += 1;
|
|
3864
3945
|
settingsWindowOpen = true;
|
|
@@ -3877,6 +3958,24 @@ export function createClaudeSessionRunner({
|
|
|
3877
3958
|
return !proc.suppressingTurn;
|
|
3878
3959
|
}
|
|
3879
3960
|
|
|
3961
|
+
/**
|
|
3962
|
+
* What keeps the live process from being released now, for the relay's
|
|
3963
|
+
* billing swap ('' when nothing does, or there is no process): 'question',
|
|
3964
|
+
* 'compaction', 'turn' or 'background'. The same conditions as
|
|
3965
|
+
* canReleaseForRespawn, named.
|
|
3966
|
+
*/
|
|
3967
|
+
function releaseHold() {
|
|
3968
|
+
// A delivered message on its way in (waiting for a draining process or
|
|
3969
|
+
// a cold spawn): a turn, though no process may exist yet.
|
|
3970
|
+
if (admittingDeliveries > 0) return 'turn';
|
|
3971
|
+
if (!proc || proc.closing || proc.aborted) return '';
|
|
3972
|
+
if (proc.pendingControlRequests > 0) return 'question';
|
|
3973
|
+
if (isCompacting() || Date.now() < proc.compactReplayUntil) return 'compaction';
|
|
3974
|
+
if (proc.activeCtx || proc.pendingDelivered.length || proc.suppressingTurn) return 'turn';
|
|
3975
|
+
if (proc.liveTasks.size || isContinuationImminent()) return 'background';
|
|
3976
|
+
return '';
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3880
3979
|
/**
|
|
3881
3980
|
* The running CLI's compaction window (the one it was spawned with) and
|
|
3882
3981
|
* whether background work would block the respawn that applies another
|
|
@@ -3900,14 +3999,14 @@ export function createClaudeSessionRunner({
|
|
|
3900
3999
|
async function adaptProcess(message, delivery = {}) {
|
|
3901
4000
|
const relayMode = message.relayMode || 'agent';
|
|
3902
4001
|
const model = resolvePerTurnModel(message);
|
|
3903
|
-
const effort =
|
|
4002
|
+
const effort = modelBackend ? modelBackend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
|
|
3904
4003
|
|
|
3905
4004
|
// A backend's effort, and the model its CLI pins Claude Code's aliases
|
|
3906
4005
|
// to, are read by the CLI at spawn only: a change takes a fresh process
|
|
3907
4006
|
// on the same session, when nothing lives in the old one. Busy, the
|
|
3908
4007
|
// delivery runs on the old process (setModel below) and the next
|
|
3909
4008
|
// turn-opening one tries again.
|
|
3910
|
-
if (
|
|
4009
|
+
if (modelBackend && modelBackend.spawnKey(message, { model }) !== proc.spawnKey && canReleaseForRespawn()) {
|
|
3911
4010
|
gracefulShutdown('backend-spawn');
|
|
3912
4011
|
await drainClosingProcess(10_000);
|
|
3913
4012
|
return null;
|
|
@@ -3964,14 +4063,14 @@ export function createClaudeSessionRunner({
|
|
|
3964
4063
|
const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
|
|
3965
4064
|
if (model !== proc.model || repinAfterFallback) {
|
|
3966
4065
|
expectSettingsReinit();
|
|
3967
|
-
const cliModel =
|
|
4066
|
+
const cliModel = modelBackend && model ? modelBackend.cliModel(model) : model;
|
|
3968
4067
|
await Promise.resolve(proc.turn.setModel?.(cliModel || undefined)).catch((error) => {
|
|
3969
4068
|
dbg('setModel failed', error?.message || String(error));
|
|
3970
4069
|
});
|
|
3971
4070
|
proc.model = model;
|
|
3972
4071
|
if (model) proc.modelFallback = '';
|
|
3973
4072
|
}
|
|
3974
|
-
if (!
|
|
4073
|
+
if (!modelBackend && effort !== proc.effort) {
|
|
3975
4074
|
// 'ultracode' is not an effortLevel the CLI accepts — it maps to the
|
|
3976
4075
|
// session-scoped ultracode/enableWorkflows flags (with the effort pinned
|
|
3977
4076
|
// to the xhigh the flag implies); every other change clears those flags
|
|
@@ -4014,7 +4113,7 @@ export function createClaudeSessionRunner({
|
|
|
4014
4113
|
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
4015
4114
|
let reassertDisplay = false;
|
|
4016
4115
|
// A backend's thinking is fixed at spawn (above).
|
|
4017
|
-
if (!
|
|
4116
|
+
if (!modelBackend && thinkingEnabled !== proc.thinkingEnabled) {
|
|
4018
4117
|
if (thinkingEnabled === true) {
|
|
4019
4118
|
// Enabling applies live. The display MUST be re-sent afterwards, in
|
|
4020
4119
|
// this order: a session spawned with thinking off carries no display
|
|
@@ -4145,9 +4244,14 @@ export function createClaudeSessionRunner({
|
|
|
4145
4244
|
function steeringState() {
|
|
4146
4245
|
// A closing/aborted process is about to die — nothing about its last turn
|
|
4147
4246
|
// should hold the composer of the worker that replaces it.
|
|
4247
|
+
// `releasable`: the relay may replace this worker now (a billing swap):
|
|
4248
|
+
// no CLI process and no delivery on its way in, or a process
|
|
4249
|
+
// canReleaseForRespawn would let go of.
|
|
4148
4250
|
if (!proc || proc.closing || proc.aborted) {
|
|
4251
|
+
const admitting = admittingDeliveries > 0;
|
|
4149
4252
|
return {
|
|
4150
4253
|
turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
|
|
4254
|
+
releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
|
|
4151
4255
|
};
|
|
4152
4256
|
}
|
|
4153
4257
|
const ctx = proc.activeCtx || null;
|
|
@@ -4175,7 +4279,18 @@ export function createClaudeSessionRunner({
|
|
|
4175
4279
|
// `supported` is the worker-advertised opt-in the client's composer reads
|
|
4176
4280
|
// (the same flag the Copilot SDK worker publishes) — its only steering
|
|
4177
4281
|
// gate, so every snapshot carries it, idle ones included.
|
|
4178
|
-
|
|
4282
|
+
const hold = releaseHold();
|
|
4283
|
+
return {
|
|
4284
|
+
turnActive,
|
|
4285
|
+
canSteer,
|
|
4286
|
+
holdReason,
|
|
4287
|
+
messageId,
|
|
4288
|
+
supported: true,
|
|
4289
|
+
autoCompactWindow,
|
|
4290
|
+
releasable: !hold,
|
|
4291
|
+
releaseHold: hold || null,
|
|
4292
|
+
backgroundTaskCount: proc.liveTasks.size,
|
|
4293
|
+
};
|
|
4179
4294
|
}
|
|
4180
4295
|
|
|
4181
4296
|
/**
|
|
@@ -4200,6 +4315,20 @@ export function createClaudeSessionRunner({
|
|
|
4200
4315
|
async function handlePendingPayload(pending) {
|
|
4201
4316
|
const message = pending?.message || null;
|
|
4202
4317
|
if (!message) return false;
|
|
4318
|
+
// A Claude chat's row runs only on a worker of its billing: one of the
|
|
4319
|
+
// other kind (a relay that missed a switch) goes back to the queue, with
|
|
4320
|
+
// no retry penalty, for the worker the switch brings up.
|
|
4321
|
+
const deliveredBilling = claudeBillingOf(message.billing || message.providerType);
|
|
4322
|
+
if (deliveredBilling && claudeBillingOf(workerKind) && deliveredBilling !== workerKind) {
|
|
4323
|
+
dbg('delivery of another billing handed back', message.id || '(no id)', `${deliveredBilling} != ${workerKind}`);
|
|
4324
|
+
if (message.id) {
|
|
4325
|
+
await api('POST', '/api/requeue', { messageId: message.id, class: 'kind-mismatch', ...attemptFields(message) }).catch((error) => {
|
|
4326
|
+
if (isStaleAttemptError(error)) dbg('kind-mismatch hand-back refused as stale_attempt', message.id);
|
|
4327
|
+
else dbg('kind-mismatch hand-back failed', message.id, error?.message || String(error));
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
return false;
|
|
4331
|
+
}
|
|
4203
4332
|
// An older relay (the worker updated on disk before the relay restarted)
|
|
4204
4333
|
// treats the hand-back as a failed delivery — retry, backoff, worker
|
|
4205
4334
|
// marked errored — so against one the message is pushed the legacy way.
|
|
@@ -4216,6 +4345,21 @@ export function createClaudeSessionRunner({
|
|
|
4216
4345
|
syncDeliveryReadiness();
|
|
4217
4346
|
};
|
|
4218
4347
|
syncDeliveryReadiness();
|
|
4348
|
+
// The relay replaced the previous worker for a billing switch: said once,
|
|
4349
|
+
// on the first message this worker runs.
|
|
4350
|
+
const billingSwap = pending?.settings?.billingSwap;
|
|
4351
|
+
if (billingSwap && typeof billingSwap === 'object' && !billingSwapNoted) {
|
|
4352
|
+
billingSwapNoted = true;
|
|
4353
|
+
const to = claudeBillingOf(billingSwap.to) || workerKind;
|
|
4354
|
+
publisher.postActivity(
|
|
4355
|
+
message,
|
|
4356
|
+
to === 'claude-api'
|
|
4357
|
+
? 'Restarted the session on the Claude API key (billing switch)'
|
|
4358
|
+
: 'Restarted the session on the Claude subscription (billing switch)',
|
|
4359
|
+
null,
|
|
4360
|
+
{ kind: 'billing_switch_respawn', from: claudeBillingOf(billingSwap.from) || null, to },
|
|
4361
|
+
);
|
|
4362
|
+
}
|
|
4219
4363
|
// This delivery's own record: a window respawn taken for it (one at most).
|
|
4220
4364
|
const delivery = { windowRespawn: null };
|
|
4221
4365
|
// Its note goes on this message's row, once, whichever delivery's spawn
|
|
@@ -4247,7 +4391,7 @@ export function createClaudeSessionRunner({
|
|
|
4247
4391
|
// Awaited only when there is one: a cold spawn with the decision
|
|
4248
4392
|
// already in pushes synchronously.
|
|
4249
4393
|
if (proc) await drainClosingProcess();
|
|
4250
|
-
// The tool set is fixed per CLI process: let the
|
|
4394
|
+
// The tool set is fixed per CLI process: let the relay_tool
|
|
4251
4395
|
// decision settle first. The worker's first delivery can arrive
|
|
4252
4396
|
// before its startup GET has answered (seen live: 180 ms after the
|
|
4253
4397
|
// spawn). ready() never rejects and is bounded by the gate's timeout.
|
|
@@ -4279,7 +4423,7 @@ export function createClaudeSessionRunner({
|
|
|
4279
4423
|
noteWindowRespawn();
|
|
4280
4424
|
// A model that takes no image input gets every image by its path.
|
|
4281
4425
|
const content = buildClaudeUserContent(message, {
|
|
4282
|
-
embedImages:
|
|
4426
|
+
embedImages: modelBackend ? modelBackend.acceptsImages(resolvePerTurnModel(message)) : true,
|
|
4283
4427
|
});
|
|
4284
4428
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
4285
4429
|
const procRef = proc;
|
|
@@ -4412,6 +4556,7 @@ export function createClaudeSessionRunner({
|
|
|
4412
4556
|
}
|
|
4413
4557
|
|
|
4414
4558
|
return {
|
|
4559
|
+
workerKind,
|
|
4415
4560
|
handlePendingPayload,
|
|
4416
4561
|
canAcceptSteering,
|
|
4417
4562
|
isDeliveryHeld,
|
|
@@ -143,6 +143,8 @@ export async function runClaudeSessionWorker({
|
|
|
143
143
|
let heartbeatTimer = null;
|
|
144
144
|
heartbeat = createHeartbeatController({
|
|
145
145
|
api,
|
|
146
|
+
// What this worker runs on: the relay hands it only rows of that billing.
|
|
147
|
+
workerKind: turnRunner.workerKind,
|
|
146
148
|
pollMs: HEARTBEAT_MS,
|
|
147
149
|
getSessionReady: () => true,
|
|
148
150
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
@@ -188,6 +190,7 @@ export async function runClaudeSessionWorker({
|
|
|
188
190
|
onServerHello: () => heartbeat.requestPulse(),
|
|
189
191
|
getSessionId: () => sdkSessionId,
|
|
190
192
|
getPid: () => process.pid,
|
|
193
|
+
workerKind: turnRunner.workerKind,
|
|
191
194
|
onDeliver: async (pending, reason) => {
|
|
192
195
|
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
193
196
|
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The subscription side of the Claude billing check (plan decision 4).
|
|
2
|
+
//
|
|
3
|
+
// A Claude chat on the host's subscription must never run on an API key
|
|
4
|
+
// without being asked: the key's owner would pay. The worker strips every
|
|
5
|
+
// Anthropic credential from the CLI's environment (WP0), but a settings
|
|
6
|
+
// file's `env.ANTHROPIC_API_KEY`, an `apiKeyHelper` or a Console `/login`
|
|
7
|
+
// can still hand Claude Code a key. Its `system/init` names where the
|
|
8
|
+
// credential came from (`apiKeySource`); a turn that comes up on an API key
|
|
9
|
+
// is stopped there, as the Claude API worker stops one that comes up on
|
|
10
|
+
// anything but its key (claude-api-backend.mjs, checkClaudeApiInit). At most
|
|
11
|
+
// the one request that may beat the init has gone out (decision 24).
|
|
12
|
+
|
|
13
|
+
export const CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE = 'subscription-key-source';
|
|
14
|
+
|
|
15
|
+
// The sources that bill an API key, not the subscription: a key in the
|
|
16
|
+
// environment (a settings file's env block), a helper that prints one, and
|
|
17
|
+
// the key a Console login keeps. Every other source (`none`, `oauth`, ...)
|
|
18
|
+
// lets the turn go on; one the relay does not know is logged.
|
|
19
|
+
export const API_BILLED_KEY_SOURCES = Object.freeze(['ANTHROPIC_API_KEY', 'apiKeyHelper', '/login managed key']);
|
|
20
|
+
|
|
21
|
+
const KNOWN_KEY_SOURCES = Object.freeze([
|
|
22
|
+
...API_BILLED_KEY_SOURCES,
|
|
23
|
+
'none',
|
|
24
|
+
'user',
|
|
25
|
+
'project',
|
|
26
|
+
'org',
|
|
27
|
+
'temporary',
|
|
28
|
+
'oauth',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const GUIDANCE = 'Remove ANTHROPIC_API_KEY from the env of the host\'s Claude Code settings (~/.claude/settings.json, '
|
|
32
|
+
+ 'or the folder\'s .claude/settings*.json), remove an apiKeyHelper, or sign the Console login out, then send the '
|
|
33
|
+
+ 'message again. To run this chat on the API key saved in Settings, switch its billing to API key.';
|
|
34
|
+
|
|
35
|
+
/** Whether a source the CLI named is one the relay knows. */
|
|
36
|
+
export function isKnownApiKeySource(value) {
|
|
37
|
+
return KNOWN_KEY_SOURCES.includes(String(value ?? ''));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Null when the CLI's `system/init` came up without an API key, else the
|
|
42
|
+
* Error that stops the turn. `afterTraffic`: the turn had already talked to
|
|
43
|
+
* the API, so one request may have gone out on that key.
|
|
44
|
+
*/
|
|
45
|
+
export function checkClaudeSubscriptionInit(sdkMessage, { afterTraffic = false } = {}) {
|
|
46
|
+
if (!sdkMessage || sdkMessage.type !== 'system' || sdkMessage.subtype !== 'init') return null;
|
|
47
|
+
const source = typeof sdkMessage.apiKeySource === 'string' ? sdkMessage.apiKeySource : '';
|
|
48
|
+
if (!API_BILLED_KEY_SOURCES.includes(source)) return null;
|
|
49
|
+
const error = new Error(
|
|
50
|
+
`Claude Code took an API key for this subscription turn (source "${source}"), so the turn was stopped: `
|
|
51
|
+
+ 'it would have been billed to that key, not the subscription.'
|
|
52
|
+
+ (afterTraffic ? ' At most one request may have gone out on that key.' : ''),
|
|
53
|
+
);
|
|
54
|
+
error.code = CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE;
|
|
55
|
+
error.apiKeySource = source;
|
|
56
|
+
error.guidance = GUIDANCE;
|
|
57
|
+
return error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `{ code, guidance }` for a turn the check stopped, else null (the publisher's describeFailure). */
|
|
61
|
+
export function describeClaudeSubscriptionFailure(_errorText, { error = null } = {}) {
|
|
62
|
+
if (error?.code !== CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE) return null;
|
|
63
|
+
return { code: CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE, guidance: error.guidance || GUIDANCE };
|
|
64
|
+
}
|
|
@@ -10,7 +10,7 @@ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.m
|
|
|
10
10
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
11
11
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
12
12
|
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
13
|
-
import { claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
13
|
+
import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
14
14
|
|
|
15
15
|
const PLAN_BOARD_ACTIONS = [
|
|
16
16
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -72,7 +72,8 @@ export function createClaudeTurnPublisher({
|
|
|
72
72
|
dbg = () => {},
|
|
73
73
|
takeWorkflowRuns = null,
|
|
74
74
|
// A backend's own reading of a failed request (`{ code, guidance }` or
|
|
75
|
-
// null) and its name in the notes; null for Claude.
|
|
75
|
+
// null) and its name in the notes; null for Claude. It gets the error a
|
|
76
|
+
// turn ended with, when there is one, beside its text and status.
|
|
76
77
|
describeFailure = null,
|
|
77
78
|
runtimeLabel = 'Claude',
|
|
78
79
|
// The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
|
|
@@ -89,6 +90,12 @@ export function createClaudeTurnPublisher({
|
|
|
89
90
|
// The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
|
|
90
91
|
// → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
|
|
91
92
|
relayModel = (value) => value,
|
|
93
|
+
// The Claude API booking (POST /api/claude-api-usage), which both Claude
|
|
94
|
+
// workers report to after every turn, whatever else they report: a chat
|
|
95
|
+
// moves between the subscription and the key and keeps its session.
|
|
96
|
+
// `billingKind` is the worker's kind.
|
|
97
|
+
billingUsageRoute = null,
|
|
98
|
+
billingKind = null,
|
|
92
99
|
} = {}) {
|
|
93
100
|
/** `{ code, stableCode }` of a failure code, in the backend's namespace. */
|
|
94
101
|
function failureCodes(code) {
|
|
@@ -108,9 +115,9 @@ export function createClaudeTurnPublisher({
|
|
|
108
115
|
return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
function backendFailure(errorText, status = null) {
|
|
118
|
+
function backendFailure(errorText, status = null, error = null) {
|
|
112
119
|
if (typeof describeFailure !== 'function') return null;
|
|
113
|
-
const described = describeFailure(errorText, { status });
|
|
120
|
+
const described = describeFailure(errorText, { status, ...(error ? { error } : {}) });
|
|
114
121
|
return described?.code ? described : null;
|
|
115
122
|
}
|
|
116
123
|
|
|
@@ -223,6 +230,7 @@ export function createClaudeTurnPublisher({
|
|
|
223
230
|
* Advisory: a failure here must not disturb the turn's response.
|
|
224
231
|
*/
|
|
225
232
|
async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
233
|
+
if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
226
234
|
if (usageRoute) {
|
|
227
235
|
await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
228
236
|
return;
|
|
@@ -417,6 +425,110 @@ export function createClaudeTurnPublisher({
|
|
|
417
425
|
});
|
|
418
426
|
}
|
|
419
427
|
|
|
428
|
+
// The process's own totals per model with their cost, for the Claude API
|
|
429
|
+
// booking: counted like `run` above, per process and CLI session, with the
|
|
430
|
+
// CLI's cost estimate beside the tokens. A resumed process's first result
|
|
431
|
+
// adds the turn's `usage` (its main agent loop, no cost); the relay books
|
|
432
|
+
// that turn from the session totals the report carries as well.
|
|
433
|
+
const billingRun = { key: '', results: 0, totals: new Map(), lastSession: new Map() };
|
|
434
|
+
const billedStates = new WeakSet();
|
|
435
|
+
|
|
436
|
+
function billingCounts(state, result, { runId, sessionId, resumedSessionId }) {
|
|
437
|
+
const model = result.model || state.responseModel;
|
|
438
|
+
const key = `${runId}\n${sessionId}`;
|
|
439
|
+
if (billingRun.key !== key) {
|
|
440
|
+
billingRun.key = key;
|
|
441
|
+
billingRun.results = 0;
|
|
442
|
+
billingRun.totals = new Map();
|
|
443
|
+
billingRun.lastSession = new Map();
|
|
444
|
+
}
|
|
445
|
+
const firstResultOfRun = billingRun.results === 0;
|
|
446
|
+
const session = claudeBillingTotals(result.modelUsage, { usage: result.usage, model });
|
|
447
|
+
const add = (entries) => {
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const current = billingRun.totals.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
|
|
450
|
+
current.hitTokens += entry.hitTokens;
|
|
451
|
+
current.missTokens += entry.missTokens;
|
|
452
|
+
current.outputTokens += entry.outputTokens;
|
|
453
|
+
current.costUsd += entry.costUsd;
|
|
454
|
+
current.costUnknown = current.costUnknown || entry.costUnknown;
|
|
455
|
+
billingRun.totals.set(entry.model, current);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
if (!billedStates.has(state)) {
|
|
459
|
+
billedStates.add(state);
|
|
460
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
461
|
+
const resumed = firstResultOfRun && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
462
|
+
if (!hasModelUsage || resumed) {
|
|
463
|
+
add(claudeBillingTotals(null, { usage: result.usage, model }));
|
|
464
|
+
} else {
|
|
465
|
+
add(session.map((entry) => {
|
|
466
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
467
|
+
return {
|
|
468
|
+
model: entry.model,
|
|
469
|
+
hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
|
|
470
|
+
missTokens: Math.max(0, entry.missTokens - last.missTokens),
|
|
471
|
+
outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
|
|
472
|
+
costUsd: Math.max(0, entry.costUsd - last.costUsd),
|
|
473
|
+
costUnknown: entry.costUnknown,
|
|
474
|
+
};
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
// The baseline only rises: a late result of an older turn below it
|
|
478
|
+
// must not make the next one count twice.
|
|
479
|
+
if (hasModelUsage) {
|
|
480
|
+
for (const entry of session) {
|
|
481
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
482
|
+
billingRun.lastSession.set(entry.model, {
|
|
483
|
+
hitTokens: Math.max(last.hitTokens, entry.hitTokens),
|
|
484
|
+
missTokens: Math.max(last.missTokens, entry.missTokens),
|
|
485
|
+
outputTokens: Math.max(last.outputTokens, entry.outputTokens),
|
|
486
|
+
costUsd: Math.max(last.costUsd, entry.costUsd),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
billingRun.results += 1;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
firstResultOfRun,
|
|
494
|
+
runCounts: [...billingRun.totals.values()].map((entry) => ({ ...entry })),
|
|
495
|
+
sessionTotals: session,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Ship the turn's counts to the Claude API booking: the process's own
|
|
501
|
+
* running totals (`runCounts`) and the CLI's session totals
|
|
502
|
+
* (`sessionTotals`), with the worker's kind, the process (`runId`), the
|
|
503
|
+
* session it resumed, and the turn (`turnId`). Advisory, like the plan usage.
|
|
504
|
+
*/
|
|
505
|
+
async function publishBillingUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
506
|
+
const result = state.result;
|
|
507
|
+
const claudeNativeSessionId = String(result?.sessionId || '').trim();
|
|
508
|
+
if (!billingUsageRoute || !claudeNativeSessionId || !runId) return;
|
|
509
|
+
const { firstResultOfRun, runCounts, sessionTotals } = billingCounts(state, result, {
|
|
510
|
+
runId: String(runId),
|
|
511
|
+
sessionId: claudeNativeSessionId,
|
|
512
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
513
|
+
});
|
|
514
|
+
if (!runCounts.length && !sessionTotals.length) return;
|
|
515
|
+
await api('POST', billingUsageRoute, {
|
|
516
|
+
conversationId: message.conversationId,
|
|
517
|
+
sdkSessionId,
|
|
518
|
+
claudeNativeSessionId,
|
|
519
|
+
runId: String(runId),
|
|
520
|
+
billing: billingKind || null,
|
|
521
|
+
turnId: String(message.id || '') || null,
|
|
522
|
+
at: new Date().toISOString(),
|
|
523
|
+
firstResultOfRun,
|
|
524
|
+
...(resumedSessionId ? { resumedSessionId: String(resumedSessionId) } : {}),
|
|
525
|
+
runCounts,
|
|
526
|
+
sessionTotals,
|
|
527
|
+
}).catch((error) => {
|
|
528
|
+
dbg('billing usage publish failed', error?.message || String(error));
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
420
532
|
/**
|
|
421
533
|
* Ship what the CLI reported about the subscription's usage limit: the
|
|
422
534
|
* relay warns before the limit and shows the reset once it is reached.
|
|
@@ -670,7 +782,7 @@ export function createClaudeTurnPublisher({
|
|
|
670
782
|
});
|
|
671
783
|
return;
|
|
672
784
|
}
|
|
673
|
-
const described = backendFailure(errorText);
|
|
785
|
+
const described = backendFailure(errorText, null, error);
|
|
674
786
|
if (described) {
|
|
675
787
|
await publishResponse(message, {
|
|
676
788
|
text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,
|