@oar-sh/oar 0.9.17 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +234 -0
  4. package/README.md +13 -12
  5. package/package.json +1 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -5
  10. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  11. package/server/claude-worker/claude-session-process.mjs +166 -21
  12. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  13. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  14. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  15. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  16. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  17. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  18. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  20. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  21. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  22. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  23. package/server/db-schema.mjs +33 -1
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  25. package/server/grok-worker/acp-client.mjs +1 -1
  26. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  27. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  28. package/server/mcp/oar-mcp-server.mjs +11 -5
  29. package/server/migrations/0031-queue-provider.mjs +143 -0
  30. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  31. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  32. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  33. package/server/public/app/activity-replay-state.mjs +4 -3
  34. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  35. package/server/public/app/agent-sessions-settings.mjs +1 -1
  36. package/server/public/app/api-client.js +52 -0
  37. package/server/public/app/bootstrap.js +153 -27
  38. package/server/public/app/claude-api-settings-ui.js +362 -0
  39. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  40. package/server/public/app/claude-billing-toggle.mjs +112 -0
  41. package/server/public/app/context-tier-options.mjs +6 -2
  42. package/server/public/app/context-usage-view.mjs +1 -1
  43. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  44. package/server/public/app/conversation-view.js +29 -3
  45. package/server/public/app/inbox-view.mjs +1 -1
  46. package/server/public/app/journal-view.js +127 -10
  47. package/server/public/app/model-selector-options.mjs +17 -1
  48. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  49. package/server/public/app/provider-readiness.mjs +5 -2
  50. package/server/public/app/relay-error-ctas.mjs +50 -0
  51. package/server/public/app/relay-schedules-ui.js +1 -1
  52. package/server/public/app/settings-modal.js +11 -0
  53. package/server/public/app/settings-tabs.js +1 -1
  54. package/server/public/app/socket-handlers.js +18 -1
  55. package/server/public/app/tool-activity-emoji.mjs +4 -3
  56. package/server/public/app/usage-limit-ui.js +136 -5
  57. package/server/public/index.html +189 -3
  58. package/server/relay-heap-guard.mjs +338 -0
  59. package/server/relay-self-restart.mjs +33 -7
  60. package/server/relay-tools.md +6 -6
  61. package/server/repositories/conversation-group-repository.mjs +1 -1
  62. package/server/repositories/message-repository.mjs +7 -0
  63. package/server/repositories/session-repository.mjs +8 -1
  64. package/server/routes/ask-user-routes.mjs +1 -1
  65. package/server/routes/conversation-group-routes.mjs +1 -1
  66. package/server/routes/messages-routes.mjs +678 -31
  67. package/server/routes/relay-ask-routes.mjs +3 -3
  68. package/server/routes/relay-event-routes.mjs +2 -2
  69. package/server/routes/relay-schedule-routes.mjs +1 -1
  70. package/server/routes/remote-relay-routes.mjs +3 -3
  71. package/server/routes/sessions-routes.mjs +365 -53
  72. package/server/server-runtime.mjs +373 -26
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/background-work-view.mjs +2 -1
  75. package/server/services/claude-api-settings-service.mjs +575 -0
  76. package/server/services/claude-api-usage-service.mjs +361 -0
  77. package/server/services/claude-billing-swap-service.mjs +274 -0
  78. package/server/services/claude-billing-switch-service.mjs +343 -0
  79. package/server/services/plan-usage-claude-api.mjs +445 -0
  80. package/server/services/plan-usage-service.mjs +5 -0
  81. package/server/services/provider-model-selection.mjs +3 -2
  82. package/server/services/provider-reasoning-effort.mjs +9 -0
  83. package/server/services/relay-ask-service.mjs +1 -1
  84. package/server/services/relay-event-service.mjs +3 -2
  85. package/server/services/relay-schedule-service.mjs +1 -1
  86. package/server/services/remote-relay-caller-context.mjs +7 -0
  87. package/server/services/remote-relay-dispatcher.mjs +111 -17
  88. package/server/services/remote-relay-registry-service.mjs +1 -1
  89. package/server/services/session-worker-launch-service.mjs +61 -2
  90. package/server/services/session-worker-registry-service.mjs +28 -1
  91. package/server/services/session-worker-websocket-service.mjs +17 -0
  92. package/server/services/usage-limit-pause-service.mjs +55 -4
  93. package/server/services/worker-secret-redaction.mjs +2 -0
  94. package/shared/anthropic-api.mjs +202 -0
  95. package/shared/provider-routing.mjs +5 -3
  96. package/shared/relay-asks.mjs +11 -5
  97. package/shared/relay-events.mjs +2 -2
  98. package/shared/relay-schedules.mjs +2 -2
  99. package/shared/remote-relay-contract.mjs +54 -12
  100. package/shared/remote-relay-tool-core.mjs +23 -6
  101. package/shared/steer-settle-failure.mjs +1 -0
  102. package/shared/worker-runtime/api-client.mjs +2 -2
  103. package/shared/worker-runtime/heartbeat.mjs +5 -0
  104. 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
+ }