@oar-sh/oar 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a relay's turns on the saved Claude API key cost, booked from the
|
|
5
|
+
* workers' reports (POST /api/claude-api-usage): the figures behind the Claude
|
|
6
|
+
* API card and its monthly cap (plan decisions 10–12).
|
|
7
|
+
*
|
|
8
|
+
* Both Claude workers report after every turn, the subscription one and the
|
|
9
|
+
* Claude API one, because a chat moves between the two and keeps its Claude
|
|
10
|
+
* Code session. A report carries, per model:
|
|
11
|
+
* - `runCounts`: what this CLI process (`runId`) has run so far, counted from
|
|
12
|
+
* zero, with Claude Code's own cost estimate (`costUSD`);
|
|
13
|
+
* - `sessionTotals`: the CLI's cumulative totals for the session, which a
|
|
14
|
+
* process resumed on the session (after a switch, a restart, an idle
|
|
15
|
+
* shutdown) re-reports in its first result, the earlier turns included.
|
|
16
|
+
*
|
|
17
|
+
* Booking (claude_api_usage_checkpoints, migration 0033):
|
|
18
|
+
* - Spend is the rise of a run's counts over what was booked for that run; a
|
|
19
|
+
* report at or below it is a retried or reordered one and books nothing.
|
|
20
|
+
* Only `claude-api` reports book spend; a subscription report only keeps
|
|
21
|
+
* its checkpoint, the session's mark.
|
|
22
|
+
* - A resumed run's first result is where the run's own counts fall short:
|
|
23
|
+
* they hold that turn's main agent loop only. That turn is booked as the
|
|
24
|
+
* rise of `sessionTotals` over the session's mark (the checkpoint with the
|
|
25
|
+
* latest turn), when the mark covers the session's last finished turn; the
|
|
26
|
+
* run's checkpoint then starts from the report's own counts, so its next
|
|
27
|
+
* result books only its own rise.
|
|
28
|
+
* - A mark that is older than the last finished turn (a report was lost, or
|
|
29
|
+
* turns ran on a relay that did not report) is not used: the turn's own
|
|
30
|
+
* counts are booked, as tokens without a price, and that is logged.
|
|
31
|
+
* - Totals that fall below the mark (a CLI that counts the session from
|
|
32
|
+
* zero after a resume) are booked from the run's own counts.
|
|
33
|
+
*
|
|
34
|
+
* Spend goes to claude_api_usage_totals (migration 0034) per local day and
|
|
35
|
+
* model; a model Claude Code has no price for is booked as tokens only.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { claudeBaseModelId } from '../../shared/model-id.mjs';
|
|
39
|
+
import { migrateClaudeApiUsageCheckpoints } from '../migrations/0033-claude-api-usage-checkpoints.mjs';
|
|
40
|
+
import { migrateClaudeApiUsageTotals } from '../migrations/0034-claude-api-usage-totals.mjs';
|
|
41
|
+
|
|
42
|
+
export const CLAUDE_API_USAGE_BILLINGS = Object.freeze(['claude', 'claude-api']);
|
|
43
|
+
const TOKEN_FIELDS = Object.freeze(['hitTokens', 'missTokens', 'outputTokens']);
|
|
44
|
+
const CHECKPOINT_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
function count(value) {
|
|
48
|
+
const number = Number(value ?? 0);
|
|
49
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cost(value) {
|
|
53
|
+
const number = Number(value ?? 0);
|
|
54
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bookedModel(value) {
|
|
58
|
+
return claudeBaseModelId(String(value ?? '').trim()).toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `[{ model, hitTokens, missTokens, outputTokens, costUsd, costUnknown }]`, summed per booked model id. */
|
|
62
|
+
export function normalizeClaudeApiUsageEntries(entries) {
|
|
63
|
+
const byModel = new Map();
|
|
64
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
65
|
+
const model = bookedModel(entry?.model);
|
|
66
|
+
if (!model) continue;
|
|
67
|
+
const current = byModel.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
|
|
68
|
+
for (const field of TOKEN_FIELDS) current[field] += count(entry?.[field]);
|
|
69
|
+
current.costUsd += cost(entry?.costUsd);
|
|
70
|
+
current.costUnknown = current.costUnknown || entry?.costUnknown === true;
|
|
71
|
+
byModel.set(model, current);
|
|
72
|
+
}
|
|
73
|
+
return [...byModel.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The report a worker posts, checked: `{ ok, report }` or `{ ok: false,
|
|
78
|
+
* error }`. `billing` is the caller's to settle (the worker's registered
|
|
79
|
+
* kind); the body's is only a fallback.
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeClaudeApiUsageReport(body = {}) {
|
|
82
|
+
const nativeSessionId = String(body?.claudeNativeSessionId || '').trim();
|
|
83
|
+
const runId = String(body?.runId || '').trim();
|
|
84
|
+
const at = String(body?.at || '').trim();
|
|
85
|
+
if (!nativeSessionId) return { ok: false, error: 'Missing claudeNativeSessionId' };
|
|
86
|
+
if (!runId) return { ok: false, error: 'Missing runId' };
|
|
87
|
+
if (!Number.isFinite(Date.parse(at))) return { ok: false, error: 'Missing or invalid at' };
|
|
88
|
+
const runCounts = normalizeClaudeApiUsageEntries(body?.runCounts);
|
|
89
|
+
const sessionTotals = normalizeClaudeApiUsageEntries(body?.sessionTotals);
|
|
90
|
+
if (!runCounts.length && !sessionTotals.length) return { ok: false, error: 'Missing runCounts and sessionTotals' };
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
report: {
|
|
94
|
+
nativeSessionId,
|
|
95
|
+
runId,
|
|
96
|
+
at: new Date(Date.parse(at)).toISOString(),
|
|
97
|
+
turnId: String(body?.turnId || '').trim() || null,
|
|
98
|
+
firstResultOfRun: body?.firstResultOfRun === true,
|
|
99
|
+
resumedSessionId: String(body?.resumedSessionId || '').trim() || null,
|
|
100
|
+
billing: String(body?.billing || '').trim().toLowerCase(),
|
|
101
|
+
runCounts,
|
|
102
|
+
sessionTotals,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dayFormatter(timeZone) {
|
|
108
|
+
try {
|
|
109
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
110
|
+
} catch {
|
|
111
|
+
return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createClaudeApiUsageService({
|
|
116
|
+
db,
|
|
117
|
+
now = () => Date.now(),
|
|
118
|
+
// The relay's time zone (the cap's calendar month is the relay's); a test
|
|
119
|
+
// names one.
|
|
120
|
+
timeZone = undefined,
|
|
121
|
+
logger = console,
|
|
122
|
+
} = {}) {
|
|
123
|
+
if (!db) throw new Error('createClaudeApiUsageService requires db');
|
|
124
|
+
// The schema boot runs these too; a boot where they failed retries here,
|
|
125
|
+
// before the statements below are prepared.
|
|
126
|
+
migrateClaudeApiUsageCheckpoints(db);
|
|
127
|
+
migrateClaudeApiUsageTotals(db);
|
|
128
|
+
const formatDay = dayFormatter(timeZone);
|
|
129
|
+
const localDay = (iso) => formatDay.format(new Date(iso));
|
|
130
|
+
|
|
131
|
+
const getRunCheckpoint = db.prepare(`
|
|
132
|
+
SELECT * FROM claude_api_usage_checkpoints
|
|
133
|
+
WHERE conversation_id = ? AND native_session_id = ? AND run_id = ? AND model = ?
|
|
134
|
+
`);
|
|
135
|
+
// The session's mark: the latest report's run, per model.
|
|
136
|
+
const getMarkRun = db.prepare(`
|
|
137
|
+
SELECT run_id, last_turn_id, last_turn_at FROM claude_api_usage_checkpoints
|
|
138
|
+
WHERE native_session_id = ? AND run_id != ? AND last_turn_at IS NOT NULL
|
|
139
|
+
ORDER BY last_turn_at DESC LIMIT 1
|
|
140
|
+
`);
|
|
141
|
+
const listMarkRows = db.prepare(`
|
|
142
|
+
SELECT * FROM claude_api_usage_checkpoints WHERE native_session_id = ? AND run_id = ?
|
|
143
|
+
`);
|
|
144
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
145
|
+
// The session's last finished turn before this one: the latest reply of
|
|
146
|
+
// the chat that answers a queue row (relay notes answer none).
|
|
147
|
+
const getPreviousTurn = messageColumns.has('source_message_id')
|
|
148
|
+
? db.prepare(`
|
|
149
|
+
SELECT source_message_id AS turn_id, timestamp FROM messages
|
|
150
|
+
WHERE conversation_id = ? AND role = 'assistant'
|
|
151
|
+
AND source_message_id IS NOT NULL AND source_message_id != ?
|
|
152
|
+
ORDER BY timestamp DESC, rowid DESC LIMIT 1
|
|
153
|
+
`)
|
|
154
|
+
: null;
|
|
155
|
+
const upsertCheckpoint = db.prepare(`
|
|
156
|
+
INSERT INTO claude_api_usage_checkpoints (
|
|
157
|
+
conversation_id, native_session_id, run_id, model, billing,
|
|
158
|
+
run_hit_tokens, run_miss_tokens, run_output_tokens, run_cost_usd,
|
|
159
|
+
session_hit_tokens, session_miss_tokens, session_output_tokens, session_cost_usd,
|
|
160
|
+
cost_unknown, last_turn_id, last_turn_at, updated_at
|
|
161
|
+
) VALUES (
|
|
162
|
+
@conversationId, @nativeSessionId, @runId, @model, @billing,
|
|
163
|
+
@runHit, @runMiss, @runOutput, @runCost,
|
|
164
|
+
@sessionHit, @sessionMiss, @sessionOutput, @sessionCost,
|
|
165
|
+
@costUnknown, @lastTurnId, @lastTurnAt, @updatedAt
|
|
166
|
+
)
|
|
167
|
+
ON CONFLICT (conversation_id, native_session_id, run_id, model) DO UPDATE SET
|
|
168
|
+
billing = excluded.billing,
|
|
169
|
+
run_hit_tokens = excluded.run_hit_tokens,
|
|
170
|
+
run_miss_tokens = excluded.run_miss_tokens,
|
|
171
|
+
run_output_tokens = excluded.run_output_tokens,
|
|
172
|
+
run_cost_usd = excluded.run_cost_usd,
|
|
173
|
+
session_hit_tokens = excluded.session_hit_tokens,
|
|
174
|
+
session_miss_tokens = excluded.session_miss_tokens,
|
|
175
|
+
session_output_tokens = excluded.session_output_tokens,
|
|
176
|
+
session_cost_usd = excluded.session_cost_usd,
|
|
177
|
+
cost_unknown = excluded.cost_unknown,
|
|
178
|
+
last_turn_id = excluded.last_turn_id,
|
|
179
|
+
last_turn_at = excluded.last_turn_at,
|
|
180
|
+
updated_at = excluded.updated_at
|
|
181
|
+
`);
|
|
182
|
+
const addTotals = db.prepare(`
|
|
183
|
+
INSERT INTO claude_api_usage_totals (
|
|
184
|
+
day, model, hit_tokens, miss_tokens, output_tokens, cost_usd, unknown_cost_tokens, turn_count,
|
|
185
|
+
first_seen_at, last_seen_at, updated_at
|
|
186
|
+
) VALUES (@day, @model, @hit, @miss, @output, @cost, @unknownTokens, 1, @at, @at, @updatedAt)
|
|
187
|
+
ON CONFLICT (day, model) DO UPDATE SET
|
|
188
|
+
hit_tokens = hit_tokens + excluded.hit_tokens,
|
|
189
|
+
miss_tokens = miss_tokens + excluded.miss_tokens,
|
|
190
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
191
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
192
|
+
unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
|
|
193
|
+
turn_count = turn_count + 1,
|
|
194
|
+
first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
|
|
195
|
+
last_seen_at = MAX(last_seen_at, excluded.last_seen_at),
|
|
196
|
+
updated_at = excluded.updated_at
|
|
197
|
+
`);
|
|
198
|
+
const pruneCheckpoints = db.prepare(`
|
|
199
|
+
DELETE FROM claude_api_usage_checkpoints
|
|
200
|
+
WHERE updated_at < ?
|
|
201
|
+
AND run_id != (
|
|
202
|
+
SELECT newest.run_id FROM claude_api_usage_checkpoints newest
|
|
203
|
+
WHERE newest.native_session_id = claude_api_usage_checkpoints.native_session_id
|
|
204
|
+
ORDER BY newest.last_turn_at DESC LIMIT 1
|
|
205
|
+
)
|
|
206
|
+
`);
|
|
207
|
+
const readDays = db.prepare(`
|
|
208
|
+
SELECT * FROM claude_api_usage_totals WHERE day >= ? AND day <= ? ORDER BY day ASC, model ASC
|
|
209
|
+
`);
|
|
210
|
+
let lastPruneAt = 0;
|
|
211
|
+
|
|
212
|
+
const tokensOf = (entry) => TOKEN_FIELDS.reduce((sum, field) => sum + count(entry?.[field]), 0);
|
|
213
|
+
const subtract = (from, base) => {
|
|
214
|
+
const out = { model: from.model, costUnknown: from.costUnknown === true };
|
|
215
|
+
for (const field of TOKEN_FIELDS) out[field] = count(from[field]) - count(base?.[field]);
|
|
216
|
+
out.costUsd = cost(from.costUsd) - cost(base?.costUsd);
|
|
217
|
+
return out;
|
|
218
|
+
};
|
|
219
|
+
const fromRow = (row, prefix) => (row
|
|
220
|
+
? {
|
|
221
|
+
hitTokens: count(row[`${prefix}_hit_tokens`]),
|
|
222
|
+
missTokens: count(row[`${prefix}_miss_tokens`]),
|
|
223
|
+
outputTokens: count(row[`${prefix}_output_tokens`]),
|
|
224
|
+
costUsd: cost(row[`${prefix}_cost_usd`]),
|
|
225
|
+
}
|
|
226
|
+
: null);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Book one report. `billing` is the kind of the worker that posted it.
|
|
230
|
+
* Returns `{ billing, basis, booked: [{ model, ...delta }] }`; `basis` says
|
|
231
|
+
* how a resumed run's first result was booked ('mark', 'run', 'stale-mark',
|
|
232
|
+
* 'no-mark'), null otherwise.
|
|
233
|
+
*/
|
|
234
|
+
function recordReport({ conversationId, billing, report }) {
|
|
235
|
+
const convId = String(conversationId || '').trim();
|
|
236
|
+
const kind = CLAUDE_API_USAGE_BILLINGS.includes(billing) ? billing : '';
|
|
237
|
+
if (!convId || !kind || !report) return null;
|
|
238
|
+
const updatedAt = new Date(now()).toISOString();
|
|
239
|
+
const runByModel = new Map(report.runCounts.map((entry) => [entry.model, entry]));
|
|
240
|
+
const sessionByModel = new Map(report.sessionTotals.map((entry) => [entry.model, entry]));
|
|
241
|
+
const models = [...new Set([...runByModel.keys(), ...sessionByModel.keys()])];
|
|
242
|
+
const booked = [];
|
|
243
|
+
let basis = null;
|
|
244
|
+
const write = db.transaction(() => {
|
|
245
|
+
const seenRun = models.some((model) => getRunCheckpoint.get(convId, report.nativeSessionId, report.runId, model));
|
|
246
|
+
const resumedFirst = !seenRun
|
|
247
|
+
&& report.firstResultOfRun
|
|
248
|
+
&& report.resumedSessionId === report.nativeSessionId;
|
|
249
|
+
// A resumed run's first result, measured against the session's mark.
|
|
250
|
+
let markRows = null;
|
|
251
|
+
if (resumedFirst) {
|
|
252
|
+
const markRun = getMarkRun.get(report.nativeSessionId, report.runId) || null;
|
|
253
|
+
if (!markRun) {
|
|
254
|
+
basis = 'no-mark';
|
|
255
|
+
} else {
|
|
256
|
+
// The mark covers the last finished turn when that turn's reply is
|
|
257
|
+
// the mark's own, or no reply came after the mark (a turn that
|
|
258
|
+
// left none, a stop, still has its report). A reply of another
|
|
259
|
+
// turn after the mark means a report went missing.
|
|
260
|
+
const previous = getPreviousTurn?.get(convId, report.turnId || '') || null;
|
|
261
|
+
const fresh = !previous
|
|
262
|
+
|| String(previous.turn_id) === String(markRun.last_turn_id || '')
|
|
263
|
+
|| String(previous.timestamp || '') <= String(markRun.last_turn_at || '');
|
|
264
|
+
if (!fresh) {
|
|
265
|
+
basis = 'stale-mark';
|
|
266
|
+
} else {
|
|
267
|
+
markRows = new Map(listMarkRows.all(report.nativeSessionId, markRun.run_id).map((row) => [row.model, row]));
|
|
268
|
+
const dropped = [...sessionByModel.values()].some((entry) => {
|
|
269
|
+
const mark = fromRow(markRows.get(entry.model), 'session');
|
|
270
|
+
return mark && TOKEN_FIELDS.some((field) => count(entry[field]) < mark[field]);
|
|
271
|
+
});
|
|
272
|
+
basis = dropped ? 'run' : 'mark';
|
|
273
|
+
if (dropped) markRows = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const model of models) {
|
|
278
|
+
const run = runByModel.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
|
|
279
|
+
const session = sessionByModel.get(model) || null;
|
|
280
|
+
const costUnknown = run.costUnknown === true || session?.costUnknown === true;
|
|
281
|
+
const previous = fromRow(getRunCheckpoint.get(convId, report.nativeSessionId, report.runId, model), 'run');
|
|
282
|
+
let delta = null;
|
|
283
|
+
let priceKnown = !costUnknown;
|
|
284
|
+
if (previous) {
|
|
285
|
+
// The same run again: its rise; a report below it is stale.
|
|
286
|
+
const rise = subtract(run, previous);
|
|
287
|
+
if (TOKEN_FIELDS.some((field) => rise[field] < 0)) continue;
|
|
288
|
+
delta = rise;
|
|
289
|
+
} else if (basis === 'mark') {
|
|
290
|
+
const mark = fromRow(markRows?.get(model), 'session');
|
|
291
|
+
delta = session ? subtract(session, mark) : { ...run };
|
|
292
|
+
} else if (basis === 'stale-mark' || basis === 'no-mark' || basis === 'run') {
|
|
293
|
+
// A resumed run's first result without a usable mark: the turn's
|
|
294
|
+
// own counts, its main agent loop only, which carry no price.
|
|
295
|
+
delta = { ...run, costUsd: 0 };
|
|
296
|
+
priceKnown = false;
|
|
297
|
+
} else {
|
|
298
|
+
delta = { ...run };
|
|
299
|
+
}
|
|
300
|
+
for (const field of TOKEN_FIELDS) delta[field] = Math.max(0, count(delta[field]));
|
|
301
|
+
delta.costUsd = priceKnown ? Math.max(0, Number(delta.costUsd) || 0) : 0;
|
|
302
|
+
upsertCheckpoint.run({
|
|
303
|
+
conversationId: convId,
|
|
304
|
+
nativeSessionId: report.nativeSessionId,
|
|
305
|
+
runId: report.runId,
|
|
306
|
+
model,
|
|
307
|
+
billing: kind,
|
|
308
|
+
runHit: count(run.hitTokens),
|
|
309
|
+
runMiss: count(run.missTokens),
|
|
310
|
+
runOutput: count(run.outputTokens),
|
|
311
|
+
runCost: cost(run.costUsd),
|
|
312
|
+
sessionHit: count(session?.hitTokens),
|
|
313
|
+
sessionMiss: count(session?.missTokens),
|
|
314
|
+
sessionOutput: count(session?.outputTokens),
|
|
315
|
+
sessionCost: cost(session?.costUsd),
|
|
316
|
+
costUnknown: costUnknown ? 1 : 0,
|
|
317
|
+
lastTurnId: report.turnId,
|
|
318
|
+
lastTurnAt: report.at,
|
|
319
|
+
updatedAt,
|
|
320
|
+
});
|
|
321
|
+
if (kind !== 'claude-api' || tokensOf(delta) + delta.costUsd <= 0) continue;
|
|
322
|
+
addTotals.run({
|
|
323
|
+
day: localDay(report.at),
|
|
324
|
+
model,
|
|
325
|
+
hit: delta.hitTokens,
|
|
326
|
+
miss: delta.missTokens,
|
|
327
|
+
output: delta.outputTokens,
|
|
328
|
+
cost: delta.costUsd,
|
|
329
|
+
unknownTokens: priceKnown ? 0 : tokensOf(delta),
|
|
330
|
+
at: report.at,
|
|
331
|
+
updatedAt,
|
|
332
|
+
});
|
|
333
|
+
booked.push({ model, ...delta, priceKnown });
|
|
334
|
+
}
|
|
335
|
+
if (now() - lastPruneAt > PRUNE_EVERY_MS) {
|
|
336
|
+
lastPruneAt = now();
|
|
337
|
+
pruneCheckpoints.run(new Date(now() - CHECKPOINT_RETENTION_MS).toISOString());
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
write();
|
|
341
|
+
if (basis === 'stale-mark' || basis === 'no-mark') {
|
|
342
|
+
try {
|
|
343
|
+
logger.warn?.(`[claude-api-usage] conv=${convId.slice(0, 8)} resumed run ${report.runId.slice(0, 8)}: ${basis === 'no-mark' ? 'no mark' : 'mark stale'}; its first turn is booked from its own counts, without a price`);
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
return { billing: kind, basis, booked };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Booked rows per day and model, `fromDay`..`toDay` inclusive (local `YYYY-MM-DD`). */
|
|
350
|
+
function readDaysBetween(fromDay, toDay) {
|
|
351
|
+
return readDays.all(String(fromDay || ''), String(toDay || '')).map((row) => ({ ...row }));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
recordReport,
|
|
356
|
+
readDaysBetween,
|
|
357
|
+
localDay,
|
|
358
|
+
// The zone `localDay` keeps (the card names it).
|
|
359
|
+
timeZone: formatDay.resolvedOptions().timeZone || '',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The worker swap behind the Claude billing switch.
|
|
5
|
+
*
|
|
6
|
+
* A Claude chat's worker is of one kind for its whole life: `claude` runs the
|
|
7
|
+
* CLI on the host's subscription login, `claude-api` on the saved API key
|
|
8
|
+
* (which reaches only that kind's launch). After a switch, the chat's next
|
|
9
|
+
* row is of the other billing, and the delivery refuses it to the worker it
|
|
10
|
+
* has (claudeBillingFence, messages-routes.mjs). This service then replaces
|
|
11
|
+
* that worker, between turns:
|
|
12
|
+
*
|
|
13
|
+
* - it waits while the session owns a turn the relay handed out (a
|
|
14
|
+
* `processing` row: running, delivered and not yet started, a cold spawn
|
|
15
|
+
* on its way), and while the worker is not releasable: a turn, an open
|
|
16
|
+
* question, a compaction, background tasks (the worker's steering snapshot
|
|
17
|
+
* says which, and the composer shows it); a worker with no CLI process and
|
|
18
|
+
* no delivery on its way in is releasable;
|
|
19
|
+
* - it stops the worker and every process of the session, and launches the
|
|
20
|
+
* new kind only once they are gone, never reusing a live one, so two CLIs
|
|
21
|
+
* never share the session's transcript;
|
|
22
|
+
* - a stop that fails is retried with a backoff, and after a few the chat
|
|
23
|
+
* gets a note (`onStuck`);
|
|
24
|
+
* - the first delivery to the new worker carries a note of the swap
|
|
25
|
+
* (`takeSwapNote`), which the worker shows in the turn.
|
|
26
|
+
*
|
|
27
|
+
* A switch back before the swap needs nothing here: the rows are re-stamped,
|
|
28
|
+
* the fence lets them through, and the waiting state ages out.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const CLAUDE_BILLING_SWAP_WAIT_TTL_MS = 15_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What keeps a worker from being replaced now: null when nothing does, else
|
|
35
|
+
* 'turn', 'question', 'compaction' or 'background'. From the steering
|
|
36
|
+
* snapshot its heartbeat sends; a worker too old to say `releasable` is read
|
|
37
|
+
* from the rest of it.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeWorkerReleaseHold(worker) {
|
|
40
|
+
if (!worker) return null;
|
|
41
|
+
const steering = worker.steering || null;
|
|
42
|
+
if (steering && typeof steering.releasable === 'boolean') {
|
|
43
|
+
return steering.releasable ? null : (steering.releaseHold || 'turn');
|
|
44
|
+
}
|
|
45
|
+
if (steering) {
|
|
46
|
+
if (steering.holdReason === 'question') return 'question';
|
|
47
|
+
if (steering.holdReason === 'compaction') return 'compaction';
|
|
48
|
+
if (steering.turnActive) return 'turn';
|
|
49
|
+
if (steering.autoCompactWindow?.backgroundWork) return 'background';
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return String(worker.status || '') === 'processing' ? 'turn' : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createClaudeBillingSwapService({
|
|
56
|
+
// sid → the registry's entry for the session's worker, or null.
|
|
57
|
+
getWorker = () => null,
|
|
58
|
+
// sid → whether the session owns a turn the relay handed out and has not
|
|
59
|
+
// seen finish (a `processing` queue row: a running turn, a delivery not yet
|
|
60
|
+
// acknowledged, a continuation). The relay's own record, asked before the
|
|
61
|
+
// worker's last snapshot, which can lag a claim by a pulse.
|
|
62
|
+
hasRunningTurn = () => false,
|
|
63
|
+
// async sid → { ok, error?, remainingPids? }: every process of the session
|
|
64
|
+
// stopped, and the registry and the supervisor's restart state cleared.
|
|
65
|
+
stopWorker,
|
|
66
|
+
// async sid → { ok, error? }: launch the session's worker (its kind follows
|
|
67
|
+
// the chat's billing), never reusing a live process.
|
|
68
|
+
launchWorker,
|
|
69
|
+
// ({ sdkSessionId, conversationId, rowBilling, attempts, error }): the
|
|
70
|
+
// old worker would not stop, again; once per episode.
|
|
71
|
+
onStuck = () => {},
|
|
72
|
+
// ({ sdkSessionId, conversationId, pending }) whenever the waiting state
|
|
73
|
+
// of a chat changes (null once it is over).
|
|
74
|
+
onPendingChange = () => {},
|
|
75
|
+
now = () => Date.now(),
|
|
76
|
+
retryBaseMs = 5_000,
|
|
77
|
+
retryMaxMs = 60_000,
|
|
78
|
+
stuckAfter = 3,
|
|
79
|
+
// A running-turn probe that fails is logged at most this often per session,
|
|
80
|
+
// and reaches the chat once it has failed `stuckAfter` times over at least
|
|
81
|
+
// `probeStuckAfterMs` (a brief database hiccup says nothing).
|
|
82
|
+
probeLogEveryMs = 60_000,
|
|
83
|
+
probeStuckAfterMs = 30_000,
|
|
84
|
+
logger = console,
|
|
85
|
+
} = {}) {
|
|
86
|
+
if (typeof stopWorker !== 'function' || typeof launchWorker !== 'function') {
|
|
87
|
+
throw new Error('createClaudeBillingSwapService requires stopWorker and launchWorker');
|
|
88
|
+
}
|
|
89
|
+
const bySession = new Map();
|
|
90
|
+
const swapNotes = new Map();
|
|
91
|
+
|
|
92
|
+
function stateFor(sid) {
|
|
93
|
+
let state = bySession.get(sid);
|
|
94
|
+
if (!state) {
|
|
95
|
+
state = {
|
|
96
|
+
inFlight: false,
|
|
97
|
+
failures: 0,
|
|
98
|
+
nextAttemptAt: 0,
|
|
99
|
+
// The two causes of a stuck swap are noted apart: one does not
|
|
100
|
+
// silence the other.
|
|
101
|
+
stuckNoted: false,
|
|
102
|
+
probeFailures: 0,
|
|
103
|
+
probeFailingSince: 0,
|
|
104
|
+
probeStuckNoted: false,
|
|
105
|
+
lastProbeLogAt: -Infinity,
|
|
106
|
+
pending: null,
|
|
107
|
+
conversationId: '',
|
|
108
|
+
};
|
|
109
|
+
bySession.set(sid, state);
|
|
110
|
+
}
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setPending(sid, state, pending) {
|
|
115
|
+
const before = JSON.stringify(state.pending ? { ...state.pending, seenAt: 0 } : null);
|
|
116
|
+
state.pending = pending ? { ...pending, seenAt: now() } : null;
|
|
117
|
+
const after = JSON.stringify(state.pending ? { ...state.pending, seenAt: 0 } : null);
|
|
118
|
+
if (before === after) return;
|
|
119
|
+
try {
|
|
120
|
+
onPendingChange({
|
|
121
|
+
sdkSessionId: sid,
|
|
122
|
+
conversationId: state.conversationId,
|
|
123
|
+
pending: state.pending ? publicPending(state.pending) : null,
|
|
124
|
+
});
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function publicPending(pending) {
|
|
129
|
+
return {
|
|
130
|
+
to: pending.to,
|
|
131
|
+
waitingFor: pending.waitingFor || null,
|
|
132
|
+
backgroundTaskCount: Number(pending.backgroundTaskCount || 0),
|
|
133
|
+
swapping: pending.swapping === true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function run(sid, state, fence) {
|
|
138
|
+
let stopped = null;
|
|
139
|
+
try {
|
|
140
|
+
stopped = await stopWorker(sid);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
stopped = { ok: false, error: error?.message || String(error) };
|
|
143
|
+
}
|
|
144
|
+
if (!stopped?.ok) {
|
|
145
|
+
state.failures += 1;
|
|
146
|
+
state.nextAttemptAt = now() + Math.min(retryMaxMs, retryBaseMs * (2 ** (state.failures - 1)));
|
|
147
|
+
try { logger.warn?.(`[claude-billing] swap of ${sid.slice(0, 8)} could not stop the old worker (${state.failures}): ${stopped?.error || 'unknown'}`); } catch {}
|
|
148
|
+
if (state.failures >= stuckAfter && !state.stuckNoted) {
|
|
149
|
+
state.stuckNoted = true;
|
|
150
|
+
try {
|
|
151
|
+
onStuck({
|
|
152
|
+
sdkSessionId: sid,
|
|
153
|
+
conversationId: state.conversationId,
|
|
154
|
+
rowBilling: fence.rowBilling,
|
|
155
|
+
attempts: state.failures,
|
|
156
|
+
error: stopped?.error || null,
|
|
157
|
+
reason: 'stop',
|
|
158
|
+
});
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
setPending(sid, state, { to: fence.rowBilling, waitingFor: 'stop', backgroundTaskCount: 0 });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
swapNotes.set(sid, { from: fence.workerKind || null, to: fence.rowBilling });
|
|
165
|
+
let launched = null;
|
|
166
|
+
try {
|
|
167
|
+
launched = await launchWorker(sid);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
launched = { ok: false, error: error?.message || String(error) };
|
|
170
|
+
}
|
|
171
|
+
state.failures = 0;
|
|
172
|
+
state.stuckNoted = false;
|
|
173
|
+
state.nextAttemptAt = 0;
|
|
174
|
+
try {
|
|
175
|
+
logger.log?.(`[claude-billing] swapped ${sid.slice(0, 8)} ${fence.workerKind || 'unknown'} → ${fence.rowBilling}${launched?.ok ? '' : ` (launch: ${launched?.error || 'pending'})`}`);
|
|
176
|
+
} catch {}
|
|
177
|
+
setPending(sid, state, null);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A delivery refused a row to this session's worker (the fence). Starts
|
|
182
|
+
* the swap when the worker is releasable; otherwise notes what it waits
|
|
183
|
+
* for. Returns `{ state: 'swapping' | 'waiting' | 'in-flight' | 'backoff',
|
|
184
|
+
* waitingFor? }`.
|
|
185
|
+
*/
|
|
186
|
+
function request(fence = {}) {
|
|
187
|
+
const sid = String(fence.requesterSessionId || '').trim();
|
|
188
|
+
if (!sid || !fence.rowBilling) return { state: 'ignored' };
|
|
189
|
+
const state = stateFor(sid);
|
|
190
|
+
state.conversationId = String(fence.conversationId || state.conversationId || '');
|
|
191
|
+
if (state.inFlight) return { state: 'in-flight' };
|
|
192
|
+
if (now() < state.nextAttemptAt) {
|
|
193
|
+
if (state.pending) state.pending.seenAt = now();
|
|
194
|
+
return { state: 'backoff' };
|
|
195
|
+
}
|
|
196
|
+
const worker = getWorker(sid);
|
|
197
|
+
let running = false;
|
|
198
|
+
try {
|
|
199
|
+
running = hasRunningTurn(sid) === true;
|
|
200
|
+
// The probe answers: its failure streak, and its note, are over.
|
|
201
|
+
state.probeFailures = 0;
|
|
202
|
+
state.probeFailingSince = 0;
|
|
203
|
+
state.probeStuckNoted = false;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Unknown counts as running: the old worker is never stopped on a
|
|
206
|
+
// guess. Said in the log (not on every fence), and a probe that keeps
|
|
207
|
+
// failing reaches the chat as the stuck note.
|
|
208
|
+
running = true;
|
|
209
|
+
noteProbeFailure(sid, state, fence, error);
|
|
210
|
+
}
|
|
211
|
+
const hold = running ? 'turn' : claudeWorkerReleaseHold(worker);
|
|
212
|
+
if (hold) {
|
|
213
|
+
setPending(sid, state, {
|
|
214
|
+
to: fence.rowBilling,
|
|
215
|
+
waitingFor: hold,
|
|
216
|
+
backgroundTaskCount: hold === 'background' ? Number(worker?.steering?.backgroundTaskCount || 0) : 0,
|
|
217
|
+
});
|
|
218
|
+
return { state: 'waiting', waitingFor: hold };
|
|
219
|
+
}
|
|
220
|
+
state.inFlight = true;
|
|
221
|
+
setPending(sid, state, { to: fence.rowBilling, waitingFor: null, swapping: true });
|
|
222
|
+
void run(sid, state, fence).finally(() => { state.inFlight = false; });
|
|
223
|
+
return { state: 'swapping' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function noteProbeFailure(sid, state, fence, error) {
|
|
227
|
+
if (state.probeFailures === 0) state.probeFailingSince = now();
|
|
228
|
+
state.probeFailures += 1;
|
|
229
|
+
const message = String(error?.message || error || 'unknown');
|
|
230
|
+
if (now() - state.lastProbeLogAt >= probeLogEveryMs) {
|
|
231
|
+
state.lastProbeLogAt = now();
|
|
232
|
+
try { logger.warn?.(`[claude-billing] swap of ${sid.slice(0, 8)} cannot tell whether a turn runs (${state.probeFailures}): ${message}; waiting`); } catch {}
|
|
233
|
+
}
|
|
234
|
+
const lasting = now() - state.probeFailingSince >= probeStuckAfterMs;
|
|
235
|
+
if (state.probeFailures >= stuckAfter && lasting && !state.probeStuckNoted) {
|
|
236
|
+
state.probeStuckNoted = true;
|
|
237
|
+
try {
|
|
238
|
+
onStuck({
|
|
239
|
+
sdkSessionId: sid,
|
|
240
|
+
conversationId: state.conversationId,
|
|
241
|
+
rowBilling: fence.rowBilling,
|
|
242
|
+
attempts: state.probeFailures,
|
|
243
|
+
error: message,
|
|
244
|
+
reason: 'probe',
|
|
245
|
+
});
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The note the first delivery after a swap carries, once. */
|
|
251
|
+
function takeSwapNote(sid) {
|
|
252
|
+
const key = String(sid || '').trim();
|
|
253
|
+
const note = swapNotes.get(key) || null;
|
|
254
|
+
if (note) swapNotes.delete(key);
|
|
255
|
+
return note;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What a chat waits for, for the composer: null when nothing, or the
|
|
260
|
+
* fence stopped asking (a switch back, the swap done) long enough ago.
|
|
261
|
+
*/
|
|
262
|
+
function pendingFor(sid) {
|
|
263
|
+
const state = bySession.get(String(sid || '').trim());
|
|
264
|
+
if (!state?.pending) return null;
|
|
265
|
+
if (!state.inFlight && now() - Number(state.pending.seenAt || 0) > CLAUDE_BILLING_SWAP_WAIT_TTL_MS) return null;
|
|
266
|
+
return publicPending(state.pending);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
request,
|
|
271
|
+
takeSwapNote,
|
|
272
|
+
pendingFor,
|
|
273
|
+
};
|
|
274
|
+
}
|