@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
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Claude billing switch: a Claude chat moves between the host's Claude
|
|
5
|
+
* subscription (`claude`) and the API key saved in Settings → Providers →
|
|
6
|
+
* Claude API (`claude-api`), and keeps its Claude Code session: both run the
|
|
7
|
+
* same CLI on the same `~/.claude`.
|
|
8
|
+
*
|
|
9
|
+
* The chat's billing is `runtime_sessions.provider_type`. A switch, in one
|
|
10
|
+
* transaction:
|
|
11
|
+
* - checks the target: the provider usable (a key for the API, the Claude
|
|
12
|
+
* provider on for the subscription), the monthly cap, the chat's model in
|
|
13
|
+
* the target's list, and the history rule of decision 23;
|
|
14
|
+
* - writes the billing, when and by whom;
|
|
15
|
+
* - re-stamps the chat's rows that have not started (`queue.provider`), so
|
|
16
|
+
* they run on the new billing; a row already running finishes on its own;
|
|
17
|
+
* - to the API key, releases a turn held at the subscription's usage limit;
|
|
18
|
+
* - leaves one note in the transcript (a second switch before the next turn
|
|
19
|
+
* rewrites it);
|
|
20
|
+
* - and, for an agent's `send`, enqueues its message (`enqueue`).
|
|
21
|
+
*
|
|
22
|
+
* The worker swap itself is the delivery's (claude-billing-swap-service.mjs):
|
|
23
|
+
* the next row of the new billing is not handed to a worker of the old kind,
|
|
24
|
+
* which is replaced once it is idle.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
import { claudeBaseModelId, isClaudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
30
|
+
|
|
31
|
+
export const CLAUDE_BILLING_PROVIDERS = Object.freeze(['claude', 'claude-api']);
|
|
32
|
+
export const CLAUDE_BILLING_NOTE_KIND = 'billing-switch';
|
|
33
|
+
export const CLAUDE_BILLING_SWITCHED_BY = Object.freeze(['user', 'agent', 'usage-limit']);
|
|
34
|
+
|
|
35
|
+
// Decision 23: whether a chat with turns on the other billing may move to
|
|
36
|
+
// this one. Set from the WP3 live check (a subscription session's history on
|
|
37
|
+
// the key, and back); 'blocked' disables the switch for such chats.
|
|
38
|
+
export const CLAUDE_BILLING_HISTORY_RULE = Object.freeze({
|
|
39
|
+
'claude-api': 'allowed',
|
|
40
|
+
claude: 'allowed',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const CLAUDE_BILLING_LABELS = Object.freeze({
|
|
44
|
+
claude: 'Subscription',
|
|
45
|
+
'claude-api': 'API key',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function normalizeClaudeBilling(value) {
|
|
49
|
+
const billing = String(value || '').trim().toLowerCase();
|
|
50
|
+
return CLAUDE_BILLING_PROVIDERS.includes(billing) ? billing : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function otherClaudeBilling(billing) {
|
|
54
|
+
return normalizeClaudeBilling(billing) === 'claude' ? 'claude-api' : 'claude';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The transcript note for a switch. */
|
|
58
|
+
export function buildClaudeBillingNoteText({ to, by = 'user' } = {}) {
|
|
59
|
+
const who = by === 'agent' ? ' (switched by an agent)' : (by === 'usage-limit' ? ' (switched at the usage limit)' : '');
|
|
60
|
+
if (normalizeClaudeBilling(to) === 'claude-api') {
|
|
61
|
+
return `💳 Billing: this chat runs on the Claude API key from the next turn${who}. `
|
|
62
|
+
+ 'The first turn on the key has no prompt cache and costs more.';
|
|
63
|
+
}
|
|
64
|
+
return `💳 Billing: this chat runs on the Claude subscription from the next turn${who}.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refusal(status, code, error, extra = {}) {
|
|
68
|
+
return { ok: false, status, code, error, ...extra };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function modelListFor(settings) {
|
|
72
|
+
return new Set([
|
|
73
|
+
String(settings?.model || '').trim(),
|
|
74
|
+
...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
|
|
75
|
+
...(Array.isArray(settings?.models) ? settings.models : []),
|
|
76
|
+
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createClaudeBillingSwitchService({
|
|
80
|
+
db,
|
|
81
|
+
stmts,
|
|
82
|
+
emit = () => {},
|
|
83
|
+
getClaudeProviderSettings = () => null,
|
|
84
|
+
getClaudeApiProviderSettings = () => null,
|
|
85
|
+
// The monthly cap: `{ reached, error? }`, from the runtime's
|
|
86
|
+
// getClaudeApiCapState seam (the claim check asks the same one; WP5 fills
|
|
87
|
+
// it in). Not reached by default.
|
|
88
|
+
getClaudeApiCapState = () => ({ reached: false }),
|
|
89
|
+
historyRule = CLAUDE_BILLING_HISTORY_RULE,
|
|
90
|
+
// Releases a turn held at the subscription's usage limit
|
|
91
|
+
// (usage-limit-pause-service.mjs); runs inside the switch's transaction.
|
|
92
|
+
releaseUsageLimitPause = null,
|
|
93
|
+
// The runtime row → what the worker swap waits for (claude-billing-swap-
|
|
94
|
+
// service.mjs, pendingFor), or null.
|
|
95
|
+
getPending = () => null,
|
|
96
|
+
onQueueChanged = () => {},
|
|
97
|
+
now = () => Date.now(),
|
|
98
|
+
logger = console,
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!db || !stmts) throw new Error('createClaudeBillingSwitchService requires db and stmts');
|
|
101
|
+
|
|
102
|
+
const runtimeColumns = new Set(db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => column.name));
|
|
103
|
+
const queueColumns = new Set(db.prepare(`PRAGMA table_info(queue)`).all().map((column) => column.name));
|
|
104
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
105
|
+
const recordsSwitch = runtimeColumns.has('billing_switched_at') && runtimeColumns.has('billing_switched_by');
|
|
106
|
+
|
|
107
|
+
const getRuntime = db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ? LIMIT 1`);
|
|
108
|
+
const setBilling = recordsSwitch
|
|
109
|
+
? db.prepare(`
|
|
110
|
+
UPDATE runtime_sessions
|
|
111
|
+
SET provider_type = ?, billing_switched_at = ?, billing_switched_by = ?, last_used_at = ?
|
|
112
|
+
WHERE id = ?
|
|
113
|
+
`)
|
|
114
|
+
: db.prepare(`UPDATE runtime_sessions SET provider_type = ?, last_used_at = ? WHERE id = ?`);
|
|
115
|
+
const setModel = db.prepare(`UPDATE runtime_sessions SET provider_model = ?, model = ? WHERE id = ?`);
|
|
116
|
+
const restampRows = queueColumns.has('provider')
|
|
117
|
+
? db.prepare(`UPDATE queue SET provider = ? WHERE conversation_id = ? AND status IN ('pending', 'parked')`)
|
|
118
|
+
: null;
|
|
119
|
+
const countUnstartedRows = db.prepare(`
|
|
120
|
+
SELECT COUNT(*) AS n FROM queue WHERE conversation_id = ? AND status IN ('pending', 'parked')
|
|
121
|
+
`);
|
|
122
|
+
// The turns that ran on a billing other than `?`: replies with a recorded
|
|
123
|
+
// provider. A reply from before it was recorded (or a relay note) names
|
|
124
|
+
// none and does not count: an old chat is judged by the turns it has run
|
|
125
|
+
// since, and a history the API still refuses meets the turn's own refusal.
|
|
126
|
+
const countOtherBillingTurns = messageColumns.has('executed_provider')
|
|
127
|
+
? db.prepare(`
|
|
128
|
+
SELECT COUNT(*) AS n FROM messages
|
|
129
|
+
WHERE conversation_id = ? AND role = 'assistant'
|
|
130
|
+
AND executed_provider IS NOT NULL AND LOWER(executed_provider) != ?
|
|
131
|
+
`)
|
|
132
|
+
: null;
|
|
133
|
+
const latestMessage = messageColumns.has('kind')
|
|
134
|
+
? db.prepare(`SELECT id, role, kind, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC, rowid DESC LIMIT 1`)
|
|
135
|
+
: null;
|
|
136
|
+
const rewriteNote = db.prepare(`UPDATE messages SET text = ?, timestamp = ? WHERE id = ?`);
|
|
137
|
+
|
|
138
|
+
function readSettings(read) {
|
|
139
|
+
try {
|
|
140
|
+
const settings = read();
|
|
141
|
+
return settings && typeof settings === 'object' ? settings : {};
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function chatModel(runtime) {
|
|
148
|
+
return String(runtime?.provider_model || runtime?.model || '').trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the chat may move to `to` now: `{ ok: true, runtime, from, to,
|
|
153
|
+
* model }`, or a refusal `{ ok: false, status, code, error }`. `model` is a
|
|
154
|
+
* model asked for with the switch, checked in place of the chat's own.
|
|
155
|
+
*/
|
|
156
|
+
function checkSwitch({ conversationId, to, model = '' } = {}) {
|
|
157
|
+
const convId = String(conversationId || '').trim();
|
|
158
|
+
const target = normalizeClaudeBilling(to);
|
|
159
|
+
if (!target) return refusal(400, 'CLAUDE_BILLING_INVALID', 'Billing must be "claude" (subscription) or "claude-api" (API key)');
|
|
160
|
+
const runtime = convId ? (getRuntime.get(convId) || null) : null;
|
|
161
|
+
if (!runtime) return refusal(404, 'CONVERSATION_NOT_FOUND', 'Conversation not found');
|
|
162
|
+
const from = normalizeClaudeBilling(runtime.provider_type);
|
|
163
|
+
if (!from) {
|
|
164
|
+
return refusal(409, 'CLAUDE_BILLING_NOT_CLAUDE', 'Only a Claude chat can switch between the subscription and the API key', {
|
|
165
|
+
provider: String(runtime.provider_type || '').trim().toLowerCase() || 'github',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (from === target) return { ok: true, runtime, from, to: target, model: chatModel(runtime), changed: false };
|
|
169
|
+
const settings = target === 'claude-api'
|
|
170
|
+
? readSettings(getClaudeApiProviderSettings)
|
|
171
|
+
: readSettings(getClaudeProviderSettings);
|
|
172
|
+
if (target === 'claude-api' && settings.enabled !== true) {
|
|
173
|
+
return refusal(409, 'CLAUDE_API_NOT_CONFIGURED', settings.configured === true
|
|
174
|
+
? 'Claude API is switched off. Switch it on in Settings → Providers → Claude API.'
|
|
175
|
+
: 'Claude API has no key. Save one in Settings → Providers → Claude API.');
|
|
176
|
+
}
|
|
177
|
+
if (target === 'claude' && settings.enabled !== true) {
|
|
178
|
+
return refusal(409, 'CLAUDE_NOT_CONFIGURED', 'The Claude provider is switched off. Switch it on in Settings → Providers → Claude.');
|
|
179
|
+
}
|
|
180
|
+
if (target === 'claude-api') {
|
|
181
|
+
let cap = null;
|
|
182
|
+
// A cap that cannot be read is taken as reached: the key's bill is at stake.
|
|
183
|
+
try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
|
|
184
|
+
if (cap?.reached === true) {
|
|
185
|
+
return refusal(409, 'CLAUDE_API_CAP_REACHED', String(cap.error || '').trim()
|
|
186
|
+
|| "This month's Claude API spend has reached the cap set in Settings → Providers → Claude API.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const wanted = String(model || '').trim() || chatModel(runtime);
|
|
190
|
+
if (wanted) {
|
|
191
|
+
const list = modelListFor(settings);
|
|
192
|
+
const exact = list.has(wanted.toLowerCase());
|
|
193
|
+
// A `[1m]` model needs its long-context variant listed; a plain id is
|
|
194
|
+
// also taken by its base.
|
|
195
|
+
const listed = exact || (!isClaudeLongContextModelId(wanted) && list.has(claudeBaseModelId(wanted).toLowerCase()));
|
|
196
|
+
if (!listed) {
|
|
197
|
+
return refusal(409, 'CLAUDE_BILLING_MODEL_UNAVAILABLE',
|
|
198
|
+
`${wanted} is not in the ${target === 'claude-api' ? 'Claude API' : 'Claude'} model list. Pick a model it has, then switch.`,
|
|
199
|
+
{ model: wanted });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (String(historyRule?.[target] || 'allowed') === 'blocked' && countOtherBillingTurns) {
|
|
203
|
+
const turns = Number(countOtherBillingTurns.get(runtime.conversation_id, target)?.n || 0);
|
|
204
|
+
if (turns > 0) {
|
|
205
|
+
return refusal(409, 'CLAUDE_BILLING_HISTORY', target === 'claude-api'
|
|
206
|
+
? 'This chat already has subscription turns, and the API refuses that history. Start a new Claude API chat.'
|
|
207
|
+
: 'This chat already has API key turns, and the subscription refuses that history. Start a new Claude chat.');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, runtime, from, to: target, model: wanted, changed: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What the composer's toggle and the usage-limit note read about a chat's billing. */
|
|
214
|
+
function billingState(conversationId) {
|
|
215
|
+
const runtime = getRuntime.get(String(conversationId || '').trim()) || null;
|
|
216
|
+
const provider = normalizeClaudeBilling(runtime?.provider_type);
|
|
217
|
+
if (!provider) return null;
|
|
218
|
+
const to = otherClaudeBilling(provider);
|
|
219
|
+
const check = checkSwitch({ conversationId, to });
|
|
220
|
+
let pending = null;
|
|
221
|
+
try { pending = getPending(runtime) || null; } catch { pending = null; }
|
|
222
|
+
return {
|
|
223
|
+
provider,
|
|
224
|
+
pending,
|
|
225
|
+
switchedAt: runtime?.billing_switched_at || null,
|
|
226
|
+
switchedBy: runtime?.billing_switched_by || null,
|
|
227
|
+
switchable: {
|
|
228
|
+
to,
|
|
229
|
+
allowed: check.ok === true,
|
|
230
|
+
code: check.ok ? null : check.code,
|
|
231
|
+
reason: check.ok ? null : check.error,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Switch the chat's billing. `enqueue({ runtime, billing })` runs inside
|
|
238
|
+
* the transaction after the switch (an agent's message), also when the
|
|
239
|
+
* chat already has that billing; a throw undoes the switch. A switch to
|
|
240
|
+
* the key releases a turn held at the subscription's usage limit.
|
|
241
|
+
*/
|
|
242
|
+
function switchBilling({ conversationId, to, by = 'user', model = '', enqueue = null, deferAnnounce = false } = {}) {
|
|
243
|
+
const who = CLAUDE_BILLING_SWITCHED_BY.includes(by) ? by : 'user';
|
|
244
|
+
let outcome = null;
|
|
245
|
+
let note = null;
|
|
246
|
+
let released = null;
|
|
247
|
+
let enqueued = null;
|
|
248
|
+
const run = db.transaction(() => {
|
|
249
|
+
const check = checkSwitch({ conversationId, to, model });
|
|
250
|
+
if (!check.ok) {
|
|
251
|
+
outcome = check;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const at = new Date(now()).toISOString();
|
|
255
|
+
const { runtime } = check;
|
|
256
|
+
if (check.changed) {
|
|
257
|
+
if (recordsSwitch) setBilling.run(check.to, at, who, at, runtime.id);
|
|
258
|
+
else setBilling.run(check.to, at, runtime.id);
|
|
259
|
+
if (String(model || '').trim() && check.model !== chatModel(runtime)) setModel.run(check.model, check.model, runtime.id);
|
|
260
|
+
restampRows?.run(check.to, runtime.conversation_id);
|
|
261
|
+
note = writeNote(runtime.conversation_id, check.to, who, at);
|
|
262
|
+
if (check.to === 'claude-api' && typeof releaseUsageLimitPause === 'function') {
|
|
263
|
+
released = releaseUsageLimitPause({ conversationId: runtime.conversation_id, by: 'billing-switch' }) || null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (typeof enqueue === 'function') enqueued = enqueue({ runtime, billing: check.to }) ?? null;
|
|
267
|
+
outcome = {
|
|
268
|
+
ok: true,
|
|
269
|
+
changed: check.changed,
|
|
270
|
+
conversationId: runtime.conversation_id,
|
|
271
|
+
provider: check.to,
|
|
272
|
+
from: check.from,
|
|
273
|
+
at,
|
|
274
|
+
queuedRows: Number(countUnstartedRows.get(runtime.conversation_id)?.n || 0),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
run();
|
|
278
|
+
if (!outcome?.ok) return outcome;
|
|
279
|
+
const result = {
|
|
280
|
+
...outcome,
|
|
281
|
+
noteMessageId: note?.messageId || null,
|
|
282
|
+
released: released ? { messageId: released.messageId || null } : null,
|
|
283
|
+
enqueued,
|
|
284
|
+
};
|
|
285
|
+
// Called inside a caller's transaction (the send route's persist step),
|
|
286
|
+
// the switch is not committed yet: the caller announces it once it is.
|
|
287
|
+
if (deferAnnounce) return { ...result, announce: () => announce(outcome, note, released, who) };
|
|
288
|
+
announce(outcome, note, released, who);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// What the clients and the queue hear of a committed switch.
|
|
293
|
+
function announce(outcome, note, released, who) {
|
|
294
|
+
try { released?.announce?.(); } catch {}
|
|
295
|
+
if (note) {
|
|
296
|
+
try {
|
|
297
|
+
emit(note.created ? 'assistant_message' : 'message_updated', {
|
|
298
|
+
conversationId: outcome.conversationId,
|
|
299
|
+
messageId: note.messageId,
|
|
300
|
+
message: {
|
|
301
|
+
id: note.messageId,
|
|
302
|
+
role: 'assistant',
|
|
303
|
+
text: note.text,
|
|
304
|
+
kind: CLAUDE_BILLING_NOTE_KIND,
|
|
305
|
+
model: null,
|
|
306
|
+
mode: null,
|
|
307
|
+
attachments: [],
|
|
308
|
+
timestamp: note.timestamp,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
if (outcome.changed) {
|
|
314
|
+
try {
|
|
315
|
+
logger.log?.(`[claude-billing] conv=${outcome.conversationId.slice(0, 8)} ${outcome.from} → ${outcome.provider} by ${who}; ${outcome.queuedRows} queued row(s) re-stamped`);
|
|
316
|
+
} catch {}
|
|
317
|
+
try { emit('conversation_billing', { conversationId: outcome.conversationId, billing: billingState(outcome.conversationId) }); } catch {}
|
|
318
|
+
try { onQueueChanged('claude-billing-switch'); } catch {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// One note per run of switches: one written since the chat's last message
|
|
323
|
+
// is rewritten in place, so a switch there and back leaves one line.
|
|
324
|
+
function writeNote(conversationId, to, by, at) {
|
|
325
|
+
const text = buildClaudeBillingNoteText({ to, by });
|
|
326
|
+
const latest = latestMessage?.get(conversationId) || null;
|
|
327
|
+
if (latest && latest.role === 'assistant' && latest.kind === CLAUDE_BILLING_NOTE_KIND) {
|
|
328
|
+
rewriteNote.run(text, at, latest.id);
|
|
329
|
+
return { messageId: latest.id, text, timestamp: at, created: false };
|
|
330
|
+
}
|
|
331
|
+
const messageId = randomUUID();
|
|
332
|
+
stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
|
|
333
|
+
stmts.setMessageKind?.run?.(CLAUDE_BILLING_NOTE_KIND, messageId);
|
|
334
|
+
stmts.updateConvTime?.run?.(at, conversationId);
|
|
335
|
+
return { messageId, text, timestamp: at, created: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
checkSwitch,
|
|
340
|
+
billingState,
|
|
341
|
+
switchBilling,
|
|
342
|
+
};
|
|
343
|
+
}
|