@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.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -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-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -2,14 +2,15 @@
2
2
  // banner above the composer. Before the limit it warns ("usage at 96 %"); at
3
3
  // the limit it says when the window resets; and when a turn of this
4
4
  // conversation was paused there, it says when the turn carries on, with
5
- // Resume now and Cancel.
5
+ // Resume now and Cancel, and Switch this chat to API key when the chat may
6
+ // move to the Claude API key (its `billing` says so).
6
7
  //
7
8
  // State arrives over the socket (`usage_limit_pause` per conversation,
8
9
  // `claude_usage_limit` for the account), with the conversation payload, and
9
10
  // with GET /api/status polls, so a client that missed an event catches up.
10
11
 
11
12
  import { showTransientRelayNotice } from './store.js';
12
- import { cancelUsageLimitPause, resumeUsageLimitPause } from './api-client.js';
13
+ import { cancelUsageLimitPause, resumeUsageLimitPause, switchConversationBilling } from './api-client.js';
13
14
 
14
15
  const BANNER_ID = 'usage-limit-banner';
15
16
  const DISMISSED_KEY = 'oar.usageLimitWarningDismissed';
@@ -18,11 +19,25 @@ const WARNING_FROM = 0.9;
18
19
 
19
20
  let account = null;
20
21
  const pauses = new Map();
22
+ // Each conversation's billing as the relay last said it (payload `billing`,
23
+ // socket `conversation_billing`): the subscription or the API key, and
24
+ // whether it may switch now.
25
+ const billings = new Map();
26
+ // Told of every change to a chat's billing (the composer's billing toggle).
27
+ const billingListeners = new Set();
21
28
  let currentConversationId = '';
22
29
  let currentProviderType = '';
23
30
  let actionInFlight = false;
24
31
  let wakeTimer = null;
25
32
 
33
+ function notifyBilling(conversationId) {
34
+ const id = String(conversationId || '').trim();
35
+ if (!id) return;
36
+ for (const listener of billingListeners) {
37
+ try { listener(id, billings.get(id) || null); } catch {}
38
+ }
39
+ }
40
+
26
41
  function isFuture(iso, nowMs) {
27
42
  const at = Date.parse(String(iso || ''));
28
43
  return Number.isFinite(at) && at > nowMs;
@@ -73,6 +88,33 @@ export function usageLimitAccountText(state, nowMs = Date.now()) {
73
88
  return `Claude usage at ${Math.round(utilization * 100)} % of the ${label} — resets ${resets}`;
74
89
  }
75
90
 
91
+ /**
92
+ * Whether the usage-limit note offers to move the chat to the API key: a
93
+ * subscription chat (never a cloud one) whose relay says the switch is
94
+ * allowed now (a key saved, Claude API on, the cap not reached, the model on
95
+ * the key's list).
96
+ */
97
+ export function usageLimitSwitchOffered(conversationId, providerType = currentProviderType) {
98
+ if (String(providerType || '').trim().toLowerCase() !== 'claude') return false;
99
+ const billing = billings.get(String(conversationId || '')) || null;
100
+ return billing?.provider === 'claude'
101
+ && billing?.switchable?.to === 'claude-api'
102
+ && billing.switchable.allowed === true;
103
+ }
104
+
105
+ /**
106
+ * Why the usage-limit note does not offer the API key, when the reason is
107
+ * worth a line: this month's Claude API cap. '' otherwise (Claude API off or
108
+ * without a key is no news at the limit).
109
+ */
110
+ export function usageLimitSwitchBlockedText(conversationId, providerType = currentProviderType) {
111
+ if (String(providerType || '').trim().toLowerCase() !== 'claude') return '';
112
+ const billing = billings.get(String(conversationId || '')) || null;
113
+ if (billing?.provider !== 'claude' || billing?.switchable?.to !== 'claude-api') return '';
114
+ if (billing.switchable.allowed === true || billing.switchable.code !== 'CLAUDE_API_CAP_REACHED') return '';
115
+ return "No switch to the API key: this month's Claude API cap is reached.";
116
+ }
117
+
76
118
  function dismissedKey(state) {
77
119
  return state ? `${state.rateLimitType || ''}|${state.resetsAt || ''}|${state.status || ''}` : '';
78
120
  }
@@ -101,6 +143,21 @@ async function runPauseAction(action) {
101
143
  actionInFlight = true;
102
144
  renderUsageLimitBanner();
103
145
  try {
146
+ if (action === 'switch') {
147
+ const switched = await switchConversationBilling(conversationId, { to: 'claude-api', by: 'usage-limit' });
148
+ if (!switched?.ok) {
149
+ // Refused (the cap, the model, the key): the pause stays, and says why.
150
+ showTransientRelayNotice(`Not switched: ${switched?.error || 'the relay refused it.'}`, 7000);
151
+ return;
152
+ }
153
+ pauses.delete(conversationId);
154
+ if (switched.billing) {
155
+ billings.set(conversationId, switched.billing);
156
+ notifyBilling(conversationId);
157
+ }
158
+ showTransientRelayNotice('This chat now runs on the Claude API key; the paused turn carries on there.', 5000);
159
+ return;
160
+ }
104
161
  const result = action === 'resume'
105
162
  ? await resumeUsageLimitPause(conversationId)
106
163
  : await cancelUsageLimitPause(conversationId);
@@ -149,7 +206,19 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
149
206
  const text = document.createElement('span');
150
207
  text.className = 'usage-limit-text';
151
208
  text.textContent = usageLimitPauseText(pause, nowMs);
152
- children.push(text, button('Resume now', 'usage-limit-resume', () => runPauseAction('resume')), button('Cancel', 'usage-limit-cancel', () => runPauseAction('cancel')));
209
+ children.push(text, button('Resume now', 'usage-limit-resume', () => runPauseAction('resume')));
210
+ if (usageLimitSwitchOffered(currentConversationId)) {
211
+ children.push(button('Switch this chat to API key', 'usage-limit-switch', () => runPauseAction('switch')));
212
+ } else {
213
+ const blocked = usageLimitSwitchBlockedText(currentConversationId);
214
+ if (blocked) {
215
+ const note = document.createElement('span');
216
+ note.className = 'usage-limit-text usage-limit-switch-blocked';
217
+ note.textContent = blocked;
218
+ children.push(note);
219
+ }
220
+ }
221
+ children.push(button('Cancel', 'usage-limit-cancel', () => runPauseAction('cancel')));
153
222
  banner.dataset.state = 'paused';
154
223
  } else {
155
224
  const line = currentConversationId && currentProviderType === 'claude' && !isDismissed(account)
@@ -198,15 +267,75 @@ export function applyUsageLimitFromStatus(status) {
198
267
  renderUsageLimitBanner();
199
268
  }
200
269
 
201
- /** The conversation on screen, with what its payload said about a pause. */
202
- export function setUsageLimitConversation(conversationId, { pause = undefined, providerType = '' } = {}) {
270
+ /** The conversation on screen, with what its payload said about a pause and its billing. */
271
+ export function setUsageLimitConversation(conversationId, { pause = undefined, providerType = '', billing = undefined } = {}) {
203
272
  currentConversationId = String(conversationId || '').trim();
204
273
  currentProviderType = String(providerType || '').trim().toLowerCase();
205
274
  if (currentConversationId && pause !== undefined) {
206
275
  if (pause && typeof pause === 'object') pauses.set(currentConversationId, pause);
207
276
  else pauses.delete(currentConversationId);
208
277
  }
278
+ if (currentConversationId && billing !== undefined) {
279
+ if (billing && typeof billing === 'object') billings.set(currentConversationId, billing);
280
+ else billings.delete(currentConversationId);
281
+ }
209
282
  renderUsageLimitBanner();
283
+ notifyBilling(currentConversationId);
284
+ }
285
+
286
+ /** `conversation_billing`: a chat's billing changed, or what it may switch to. */
287
+ export function applyConversationBilling(payload) {
288
+ const conversationId = String(payload?.conversationId || '').trim();
289
+ if (!conversationId) return;
290
+ if (payload.billing && typeof payload.billing === 'object') {
291
+ billings.set(conversationId, payload.billing);
292
+ if (conversationId === currentConversationId && payload.billing.provider) currentProviderType = payload.billing.provider;
293
+ } else {
294
+ billings.delete(conversationId);
295
+ }
296
+ renderUsageLimitBanner();
297
+ notifyBilling(conversationId);
298
+ }
299
+
300
+ /** A switch answered with the chat's billing (the composer's toggle). */
301
+ export function setConversationBilling(conversationId, billing) {
302
+ const id = String(conversationId || '').trim();
303
+ if (!id || !billing || typeof billing !== 'object') return;
304
+ billings.set(id, billing);
305
+ if (id === currentConversationId && billing.provider) currentProviderType = billing.provider;
306
+ renderUsageLimitBanner();
307
+ notifyBilling(id);
308
+ }
309
+
310
+ /** What the relay last said of a chat's billing, or null. */
311
+ export function conversationBillingState(conversationId) {
312
+ return billings.get(String(conversationId || '').trim()) || null;
313
+ }
314
+
315
+ /** `listener(conversationId, billing)` on every change; returns the unsubscribe. */
316
+ export function onConversationBillingChange(listener) {
317
+ if (typeof listener !== 'function') return () => {};
318
+ billingListeners.add(listener);
319
+ return () => billingListeners.delete(listener);
320
+ }
321
+
322
+ /** A send was refused because the chat's billing is now `provider`: remember it. */
323
+ export function noteConversationBilling(conversationId, provider) {
324
+ const id = String(conversationId || '').trim();
325
+ const billing = String(provider || '').trim().toLowerCase();
326
+ if (!id || (billing !== 'claude' && billing !== 'claude-api')) return;
327
+ const known = billings.get(id) || {};
328
+ // What it may switch to is the relay's to say again; until then, nothing.
329
+ billings.set(id, { ...known, provider: billing, switchable: { to: billing === 'claude' ? 'claude-api' : 'claude', allowed: false, code: null, reason: null } });
330
+ if (id === currentConversationId) currentProviderType = billing;
331
+ renderUsageLimitBanner();
332
+ notifyBilling(id);
333
+ }
334
+
335
+ /** The billing the page last heard for a chat: 'claude', 'claude-api', or ''. */
336
+ export function knownConversationBilling(conversationId) {
337
+ const billing = billings.get(String(conversationId || '').trim()) || null;
338
+ return billing?.provider === 'claude' || billing?.provider === 'claude-api' ? billing.provider : '';
210
339
  }
211
340
 
212
341
  export function initUsageLimitUi() {
@@ -216,6 +345,8 @@ export function initUsageLimitUi() {
216
345
  export function __resetUsageLimitUiForTests() {
217
346
  account = null;
218
347
  pauses.clear();
348
+ billings.clear();
349
+ billingListeners.clear();
219
350
  currentConversationId = '';
220
351
  currentProviderType = '';
221
352
  actionInFlight = false;