@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,112 @@
1
+ // The composer's billing toggle for a Claude chat: Subscription | API key.
2
+ // Pure and DOM-free: what the toggle shows, from the chat's provider, what
3
+ // the relay said of its billing (`billing`, GET /api/conversation and the
4
+ // `conversation_billing` socket event) and the Claude API tab's state.
5
+ //
6
+ // A switch applies from the chat's next turn; the worker is swapped between
7
+ // turns, and while it waits the relay's `billing.pending` says for what.
8
+
9
+ export const CLAUDE_BILLING_TOGGLE_OPTIONS = Object.freeze([
10
+ Object.freeze({ value: 'claude', label: 'Subscription' }),
11
+ Object.freeze({ value: 'claude-api', label: 'API key' }),
12
+ ]);
13
+
14
+ export const CLAUDE_BILLING_CACHE_WARNING = 'The first turn on the API key has no prompt cache and costs more.';
15
+ // A subscription chat while Claude API's monthly cap is reached: said on the
16
+ // toggle itself, not only when the unavailable side is pressed.
17
+ export const CLAUDE_BILLING_CAP_TEXT = "This month's Claude API cap is reached: the chat stays on the subscription.";
18
+
19
+ function normalizeBilling(value) {
20
+ const billing = String(value || '').trim().toLowerCase();
21
+ return billing === 'claude' || billing === 'claude-api' ? billing : '';
22
+ }
23
+
24
+ /** "the API key" / "the subscription", for running text. */
25
+ export function claudeBillingPlace(billing) {
26
+ return normalizeBilling(billing) === 'claude-api' ? 'the API key' : 'the subscription';
27
+ }
28
+
29
+ /** What the toggle says while the worker swap waits (`billing.pending`), or ''. */
30
+ export function claudeBillingPendingText(pending) {
31
+ if (!pending || typeof pending !== 'object') return '';
32
+ const place = claudeBillingPlace(pending.to);
33
+ if (pending.swapping === true) return `Restarting on ${place}…`;
34
+ const waitingFor = String(pending.waitingFor || '').trim();
35
+ if (waitingFor === 'turn') return `Moves to ${place} after the running turn.`;
36
+ if (waitingFor === 'question') return `Moves to ${place} once the open question is answered.`;
37
+ if (waitingFor === 'compaction') return `Moves to ${place} after the compaction.`;
38
+ if (waitingFor === 'background') {
39
+ const count = Number(pending.backgroundTaskCount) || 0;
40
+ return count > 0
41
+ ? `Moves to ${place} once ${count} background task${count === 1 ? '' : 's'} finish${count === 1 ? 'es' : ''}.`
42
+ : `Moves to ${place} once the background tasks finish.`;
43
+ }
44
+ if (waitingFor === 'stop') return `Moving to ${place}: the old Claude process has not stopped yet, retrying.`;
45
+ return `Moving to ${place}…`;
46
+ }
47
+
48
+ /**
49
+ * The toggle for one chat:
50
+ * `{ visible, current, options: [{ value, label, pressed, available, reason }],
51
+ * status, statusKind }`.
52
+ *
53
+ * Shown in a Claude SDK chat (either billing) whose relay said its billing,
54
+ * when Claude API is on with a saved key; a chat on the key keeps it after
55
+ * Claude API is switched off, so it can move back. `inFlight` is a switch
56
+ * this page sent and has no answer for yet; `warnCache` the chat this page
57
+ * just moved to the key.
58
+ */
59
+ export function claudeBillingToggleModel({
60
+ providerType = '',
61
+ billing = null,
62
+ claudeApi = null,
63
+ inFlight = false,
64
+ warnCache = false,
65
+ } = {}) {
66
+ const hidden = { visible: false, current: '', options: [], status: '', statusKind: '' };
67
+ const chatProvider = normalizeBilling(providerType);
68
+ if (!chatProvider || !billing || typeof billing !== 'object') return hidden;
69
+ const current = normalizeBilling(billing.provider) || chatProvider;
70
+ const claudeApiReady = claudeApi?.configured === true && claudeApi?.enabled === true;
71
+ if (current === 'claude' && !claudeApiReady) return hidden;
72
+ const switchable = billing.switchable && typeof billing.switchable === 'object' ? billing.switchable : {};
73
+ const target = normalizeBilling(switchable.to);
74
+ // The chat's billing was read while Claude API was off; the tab has since
75
+ // switched it on. The relay checks the switch again when it is pressed;
76
+ // a refusal it gave this page since (`refused`) stands.
77
+ const allowed = switchable.allowed === true
78
+ || (target === 'claude-api' && claudeApiReady && switchable.code === 'CLAUDE_API_NOT_CONFIGURED'
79
+ && switchable.refused !== true);
80
+ const options = CLAUDE_BILLING_TOGGLE_OPTIONS.map((option) => {
81
+ const pressed = option.value === current;
82
+ const available = pressed || (option.value === target && allowed && !inFlight);
83
+ const reason = pressed || available
84
+ ? ''
85
+ : (inFlight ? 'A switch is on its way.' : (String(switchable.reason || '').trim() || 'The relay does not allow this switch now.'));
86
+ return { value: option.value, label: option.label, pressed, available, reason };
87
+ });
88
+ let status = '';
89
+ let statusKind = '';
90
+ const pendingText = claudeBillingPendingText(billing.pending);
91
+ if (inFlight) {
92
+ status = 'Switching…';
93
+ statusKind = 'pending';
94
+ } else if (pendingText) {
95
+ status = pendingText;
96
+ statusKind = 'pending';
97
+ } else if (warnCache && current === 'claude-api') {
98
+ status = CLAUDE_BILLING_CACHE_WARNING;
99
+ statusKind = 'warning';
100
+ } else if (current === 'claude' && target === 'claude-api' && !allowed && switchable.code === 'CLAUDE_API_CAP_REACHED') {
101
+ status = CLAUDE_BILLING_CAP_TEXT;
102
+ statusKind = 'warning';
103
+ }
104
+ return { visible: true, current, options, status, statusKind };
105
+ }
106
+
107
+ /** The notice after a switch the relay accepted. */
108
+ export function claudeBillingSwitchedText(to) {
109
+ return normalizeBilling(to) === 'claude-api'
110
+ ? `This chat runs on the API key from the next turn. ${CLAUDE_BILLING_CACHE_WARNING}`
111
+ : 'This chat runs on the subscription from the next turn.';
112
+ }
@@ -46,7 +46,9 @@ function claudeTiersForModel(modelId, claudeTiers) {
46
46
  * windows on a Claude conversation. For the Claude provider the offered tiers
47
47
  * are exactly the enabled catalog ids the server folded into
48
48
  * claudeContextTiersByModel: base id → default, "[1m]" id → long_context, and a
49
- * model may legitimately have only one of the two.
49
+ * model may legitimately have only one of the two. Claude API (`claude-api`)
50
+ * serves the same ids on an API key, with its own folded list
51
+ * (claudeApiContextTiersByModel), which the caller passes as `claudeTiers`.
50
52
  */
51
53
  export function buildContextTierOptions({
52
54
  modelId = '',
@@ -63,7 +65,9 @@ export function buildContextTierOptions({
63
65
  if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
64
66
  // DeepSeek's models have one window, 1M: no tier to choose.
65
67
  if (provider === 'deepseek') return [{ value: 'default', label: tokenLabel(DEEPSEEK_CONTEXT_WINDOW_TOKENS) }];
66
- const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
68
+ const claudeTierList = provider === 'claude' || provider === 'claude-api'
69
+ ? claudeTiersForModel(modelId, claudeTiers)
70
+ : null;
67
71
  if (claudeTierList) {
68
72
  return claudeTierList
69
73
  .map((tier) => String(tier?.value || '').trim().toLowerCase())
@@ -352,7 +352,7 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
352
352
 
353
353
  /**
354
354
  * The owner's switch for a session an agent started: may its agents start
355
- * sessions themselves (the remote_relay tool's one-level rule lifted for it)?
355
+ * sessions themselves (the relay_tool's one-level rule lifted for it)?
356
356
  * Only the owner sets it; no agent can.
357
357
  */
358
358
  export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
@@ -27,6 +27,12 @@ export function isConversationUsingDeepSeekProvider(conversation = null) {
27
27
  return resolveConversationProviderType(conversation) === 'deepseek';
28
28
  }
29
29
 
30
+ // A Claude chat on the saved Claude Console key: the same Claude worker as
31
+ // `claude`, on another bill. A chat can switch between the two.
32
+ export function isConversationUsingClaudeApiProvider(conversation = null) {
33
+ return resolveConversationProviderType(conversation) === 'claude-api';
34
+ }
35
+
30
36
  // Its own provider, never a flavour of `claude`: the session runs in a sandbox
31
37
  // at Anthropic, so nothing that is true of a local Claude worker (modes,
32
38
  // effort, steering, a working directory) may be assumed for it.
@@ -40,6 +46,7 @@ const CLAUDE_CLOUD_MARKER = '☁';
40
46
  const PROVIDER_NAMES = Object.freeze({
41
47
  openai: 'OpenAI',
42
48
  claude: 'Claude',
49
+ 'claude-api': 'Claude API',
43
50
  'claude-cloud': 'Claude Cloud',
44
51
  cursor: 'Cursor',
45
52
  grok: 'Grok',
@@ -79,6 +86,9 @@ const SESSION_LOCK_LABELS = {
79
86
  openai: 'OpenAI',
80
87
  'openai-image': 'OpenAI Image',
81
88
  claude: 'Claude SDK',
89
+ // The same Claude SDK models as `claude`, named for the bill, as the pill
90
+ // and New Chat name it.
91
+ 'claude-api': 'Claude API',
82
92
  cursor: 'Cursor SDK',
83
93
  grok: 'Grok',
84
94
  deepseek: 'DeepSeek',
@@ -99,6 +109,7 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
99
109
  }
100
110
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
101
111
  if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
112
+ if (normalized === 'claude-api') return 'claude-api';
102
113
  if (normalized === 'claude-cloud') return 'claude-cloud';
103
114
  if (normalized === 'cursor') return 'cursor';
104
115
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
@@ -81,6 +81,9 @@ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
81
81
  import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
82
82
  import { attachCodeCopyButtons } from './code-copy.mjs';
83
83
  import { relayErrorCtaActions } from './relay-error-ctas.mjs';
84
+ import { switchClaudeBilling } from './claude-billing-toggle-ui.js';
85
+ import { CLAUDE_API_BILLING_URL, CLAUDE_API_LIMITS_URL, focusClaudeApiCapInput } from './claude-api-settings-ui.js';
86
+ import { knownConversationBilling, noteConversationBilling } from './usage-limit-ui.js';
84
87
  import { confirmCliInstall } from './cli-install-ui.js';
85
88
  import { startGrokSignIn } from './grok-auth-ui.js';
86
89
  import { openSettingsModal } from './settings-modal.js';
@@ -3508,8 +3511,9 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3508
3511
 
3509
3512
  // Each CTA lands the user where the state actually lives: the provider panel
3510
3513
  // shows the install log / the device code, so the settings modal is opened
3511
- // first and the action then runs on top of it.
3512
- function runRelayErrorCta(cta) {
3514
+ // first and the action then runs on top of it. New Chat runs them too, for a
3515
+ // refusal of its own (journal-view.js).
3516
+ export function runRelayErrorCta(cta) {
3513
3517
  if (cta === 'install-grok-cli') {
3514
3518
  openSettingsModal('providers', 'grok');
3515
3519
  // The confirm sheet — never a bare curl | bash from a chat bubble.
@@ -3525,6 +3529,15 @@ function runRelayErrorCta(cta) {
3525
3529
  if (cta === 'open-claude-settings') openSettingsModal('providers', 'claude');
3526
3530
  if (cta === 'open-deepseek-settings') openSettingsModal('providers', 'deepseek');
3527
3531
  if (cta === 'open-deepseek-top-up') window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener');
3532
+ if (cta === 'open-claude-api-settings') openSettingsModal('providers', 'claude-api');
3533
+ if (cta === 'open-claude-api-cap') {
3534
+ openSettingsModal('providers', 'claude-api');
3535
+ focusClaudeApiCapInput();
3536
+ }
3537
+ if (cta === 'open-claude-api-billing') window.open(CLAUDE_API_BILLING_URL, '_blank', 'noopener');
3538
+ if (cta === 'open-claude-api-limits') window.open(CLAUDE_API_LIMITS_URL, '_blank', 'noopener');
3539
+ // The chat this note is in, not a chat opened since: the bubble is on screen.
3540
+ if (cta === 'switch-claude-billing-to-subscription' && currentConvId) void switchClaudeBilling(currentConvId, 'claude');
3528
3541
  }
3529
3542
 
3530
3543
  function handleBubbleActionClick(event) {
@@ -4615,6 +4628,10 @@ export async function sendMessage() {
4615
4628
  relayMode: selectedMode,
4616
4629
  conversationId: targetConversationId || undefined,
4617
4630
  newConversation: isNew || undefined,
4631
+ // The billing this page shows for a Claude chat: the relay refuses the
4632
+ // send when the chat has moved to the other one since, so nobody's
4633
+ // turn runs on a bill they did not see.
4634
+ billing: (!isNew && targetConversationId && knownConversationBilling(targetConversationId)) || undefined,
4618
4635
  attachments,
4619
4636
  imageTarget: draftImageEditTarget
4620
4637
  ? {
@@ -4628,7 +4645,16 @@ export async function sendMessage() {
4628
4645
  // A refusal the user can act on (the conversation's model left the
4629
4646
  // catalog) is shown as the relay words it.
4630
4647
  let refusal = '';
4631
- const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
4648
+ const r = await sendMessageApi(body, {
4649
+ onRefused: (reason, payload) => {
4650
+ refusal = reason;
4651
+ // The chat's billing changed under this page: it learns the new one,
4652
+ // so sending again goes through.
4653
+ if (payload?.code === 'CLAUDE_BILLING_CHANGED' && targetConversationId) {
4654
+ noteConversationBilling(targetConversationId, payload.billing);
4655
+ }
4656
+ },
4657
+ });
4632
4658
  if (!r) {
4633
4659
  // Offline: park the send in the durable outbox instead of bouncing it
4634
4660
  // back into the composer. The client-generated messageId makes a replay
@@ -1,7 +1,7 @@
1
1
  // The Inbox, as DOM: every pending question across this relay and its paired
2
2
  // relays (GET /api/inbox), grouped by relay, newest first; what was answered
3
3
  // or dismissed in the last day under a divider. And the card a question that
4
- // does not block (remote_relay `ask`) shows in its own session.
4
+ // does not block (relay_tool `ask`) shows in its own session.
5
5
  //
6
6
  // Every text here is written by an agent, some on other relays: it goes into
7
7
  // the page through textContent only, never as HTML or a link. Buttons carry
@@ -73,13 +73,15 @@ import {
73
73
  loadClaudeCloudRepos,
74
74
  loadGrokSettings,
75
75
  loadDeepSeekSettings,
76
+ loadClaudeApiSettings,
76
77
  loadOpenAISettings,
77
78
  } from './api-client.js';
78
79
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
79
80
  import { SETUP_TEXT } from './no-conversation-state.mjs';
80
81
  import { openSettingsModal } from './settings-modal.js';
82
+ import { START_ON_SUBSCRIPTION_ACTION, newChatClaudeApiRefusalActions } from './relay-error-ctas.mjs';
81
83
  import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
82
- import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
84
+ import { runRelayErrorCta, renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
83
85
  import { mergeTailPage } from './transcript-reading-anchor.mjs';
84
86
  import { readWindowRetention } from './conversation-window-retention.mjs';
85
87
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
@@ -105,6 +107,7 @@ import { createInfiniteLoader } from './infinite-loader.js';
105
107
  import {
106
108
  buildNewConversationModelChoices,
107
109
  newConversationContextTierState,
110
+ preselectNewConversationModel,
108
111
  reasoningChoicesForProviderModel,
109
112
  resolvePreferredReasoningEffort,
110
113
  } from './new-conversation-model-choice.mjs';
@@ -191,6 +194,7 @@ let newConversationClaudeSettingsCache = null;
191
194
  let newConversationCursorSettingsCache = null;
192
195
  let newConversationGrokSettingsCache = null;
193
196
  let newConversationDeepSeekSettingsCache = null;
197
+ let newConversationClaudeApiSettingsCache = null;
194
198
  let newConversationClaudeCloudSettingsCache = null;
195
199
  // Claude Cloud fields of the New Chat modal: what the selected folder's git
196
200
  // remote said, and what was written into Repository/Branch from it (so a later
@@ -700,7 +704,7 @@ function paintConvList() {
700
704
  : renderConversationOriginMarkerHtml(c, { conversations });
701
705
  // "watched": a session another conversation watches by id (relay events).
702
706
  const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
703
- // "scheduled": a schedule fires into this session (remote_relay schedule).
707
+ // "scheduled": a schedule fires into this session (relay_tool schedule).
704
708
  const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
705
709
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
706
710
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
@@ -926,6 +930,8 @@ export function applyLoadedConversationState(id, response, {
926
930
  // Absent from an older relay's payload: keep what the socket said.
927
931
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
928
932
  providerType: response.runtimeSession?.providerType,
933
+ // A Claude chat's billing and whether it may switch (an older relay: absent).
934
+ billing: 'billing' in response ? response.billing : undefined,
929
935
  });
930
936
  // Merge, not replace: this payload only carries one conversation's previews.
931
937
  mergeConversationPreviews(id, response.previews || []);
@@ -1236,6 +1242,7 @@ function normalizeNewConversationProviderType(value = '') {
1236
1242
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1237
1243
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
1238
1244
  if (normalized === 'claude') return 'claude';
1245
+ if (normalized === 'claude-api') return 'claude-api';
1239
1246
  if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1240
1247
  if (normalized === 'cursor') return 'cursor';
1241
1248
  if (normalized === 'grok') return 'grok';
@@ -1278,6 +1285,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1278
1285
  const hasCursor = providers.includes('cursor');
1279
1286
  const hasGrok = providers.includes('grok');
1280
1287
  const hasDeepSeek = providers.includes('deepseek');
1288
+ const hasClaudeApi = providers.includes('claude-api');
1281
1289
  if (wantsOpenAI) {
1282
1290
  if (hasOpenAIByok) return true;
1283
1291
  const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
@@ -1321,7 +1329,16 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1321
1329
  : [];
1322
1330
  return normalizedModelId === deepseekModel || deepseekModels.includes(normalizedModelId);
1323
1331
  }
1324
- const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude');
1332
+ if (normalizedProvider === 'claude-api') {
1333
+ if (hasClaudeApi) return true;
1334
+ const claudeApiModel = String(newConversationClaudeApiSettingsCache?.model || '').trim();
1335
+ const claudeApiModels = Array.isArray(newConversationClaudeApiSettingsCache?.models)
1336
+ ? newConversationClaudeApiSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
1337
+ : [];
1338
+ return normalizedModelId === claudeApiModel || claudeApiModels.includes(normalizedModelId);
1339
+ }
1340
+ // Claude API serves the Claude provider's ids: still none of Copilot's.
1341
+ const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude' || provider === 'claude-api');
1325
1342
  if (claudeOnly) return false;
1326
1343
  const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
1327
1344
  if (cursorOnly) return false;
@@ -1332,7 +1349,8 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1332
1349
  // Should a relay ever tag catalog rows for Claude Cloud, they are no more
1333
1350
  // Copilot's than the other runtimes' rows are.
1334
1351
  const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
1335
- provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek' || provider === CLAUDE_CLOUD_PROVIDER
1352
+ provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek'
1353
+ || provider === 'claude-api' || provider === CLAUDE_CLOUD_PROVIDER
1336
1354
  ));
1337
1355
  if (exclusiveOnly) return false;
1338
1356
  return providers.some((provider) => (
@@ -1341,6 +1359,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1341
1359
  && provider !== 'cursor'
1342
1360
  && provider !== 'grok'
1343
1361
  && provider !== 'deepseek'
1362
+ && provider !== 'claude-api'
1344
1363
  && provider !== CLAUDE_CLOUD_PROVIDER
1345
1364
  )) || !hasOpenAIByok;
1346
1365
  }
@@ -1492,6 +1511,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
1492
1511
  help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
1493
1512
  return;
1494
1513
  }
1514
+ if (normalizedProvider === 'claude-api') {
1515
+ help.textContent = 'Claude API chats run through the Claude Agent SDK on the API key saved in Settings, billed to its Claude Console organisation. The chat can switch to the subscription later.';
1516
+ return;
1517
+ }
1495
1518
  if (normalizedProvider === CLAUDE_CLOUD_PROVIDER) {
1496
1519
  help.textContent = "Claude Cloud chats run in a sandbox at Anthropic on a clone of a GitHub repository, billed to the relay host's Claude account.";
1497
1520
  return;
@@ -1537,7 +1560,7 @@ async function populateNewConversationModelSelect(providerType = 'github') {
1537
1560
  buildCatalogModelOptions(eligibleModels, {
1538
1561
  metadataByModel: catalog.modelMetadataByModel || {},
1539
1562
  // The merged metadata knows Copilot's windows, not Claude's or the cloud's.
1540
- annotateContextWindow: normalizedProvider !== 'claude' && !isCloud,
1563
+ annotateContextWindow: normalizedProvider !== 'claude' && normalizedProvider !== 'claude-api' && !isCloud,
1541
1564
  }).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
1542
1565
  );
1543
1566
  for (const choice of choices) {
@@ -1555,10 +1578,13 @@ async function populateNewConversationModelSelect(providerType = 'github') {
1555
1578
  // The tab's default, not the last model another provider's chat used.
1556
1579
  const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
1557
1580
  if (cloudDefault) target.value = cloudDefault;
1558
- } else if (storedModel && Array.from(target.options).some((option) => option.value === storedModel)) {
1559
- target.value = storedModel;
1560
- } else if (normalizedProvider !== 'openai-image' && Array.from(target.options).some((option) => option.value === 'auto')) {
1561
- target.value = 'auto';
1581
+ } else {
1582
+ const preselected = preselectNewConversationModel(Array.from(target.options, (option) => option.value), {
1583
+ storedModel,
1584
+ providerDefault: normalizedProvider === 'claude-api' ? newConversationClaudeApiSettingsCache?.model : '',
1585
+ allowAuto: normalizedProvider !== 'openai-image',
1586
+ });
1587
+ if (preselected) target.value = preselected;
1562
1588
  }
1563
1589
  updateNewConversationProviderHelp(normalizedProvider);
1564
1590
  syncNewConversationReasoningLabel(normalizedProvider);
@@ -2115,7 +2141,7 @@ function applyNewConversationSetupState(setupRequired) {
2115
2141
  }
2116
2142
 
2117
2143
  async function openNewConversationModelModal() {
2118
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings] = await Promise.all([
2144
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings, claudeApiSettings] = await Promise.all([
2119
2145
  loadModelCatalog(),
2120
2146
  loadOpenAISettings(),
2121
2147
  loadClaudeSettings(),
@@ -2125,6 +2151,8 @@ async function openNewConversationModelModal() {
2125
2151
  loadClaudeCloudSettings(),
2126
2152
  // The same for a relay without DeepSeek.
2127
2153
  loadDeepSeekSettings(),
2154
+ // And without Claude API.
2155
+ loadClaudeApiSettings(),
2128
2156
  ]);
2129
2157
  newConversationCatalogCache = catalog || null;
2130
2158
  newConversationOpenAISettingsCache = settings || null;
@@ -2133,6 +2161,7 @@ async function openNewConversationModelModal() {
2133
2161
  newConversationGrokSettingsCache = grokSettings || null;
2134
2162
  newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
2135
2163
  newConversationDeepSeekSettingsCache = deepseekSettings || null;
2164
+ newConversationClaudeApiSettingsCache = claudeApiSettings || null;
2136
2165
  // Fresh answers for the readiness question, shared with the empty screen
2137
2166
  // and the composer. With no provider known to be ready the modal opens in
2138
2167
  // its setup state: Settings is the main button, Start stays as "anyway".
@@ -2143,11 +2172,13 @@ async function openNewConversationModelModal() {
2143
2172
  cursor: cursorSettings || null,
2144
2173
  grok: grokSettings || null,
2145
2174
  deepseek: deepseekSettings || null,
2175
+ claudeApi: claudeApiSettings || null,
2146
2176
  catalog: catalog || null,
2147
2177
  });
2148
2178
  applyNewConversationSetupState(isSetupRequired(readiness));
2149
2179
  // Every open starts from empty cloud fields; the folder lookup refills them.
2150
2180
  resetNewConversationCloudState();
2181
+ clearNewConversationClaudeApiRefusal();
2151
2182
  bindNewConversationCloudInputs();
2152
2183
  const providerSelect = document.getElementById('new-conversation-provider-select');
2153
2184
  if (providerSelect) {
@@ -2159,6 +2190,9 @@ async function openNewConversationModelModal() {
2159
2190
  if (claudeSettings?.enabled === true) {
2160
2191
  options.push({ value: 'claude', label: 'Claude SDK' });
2161
2192
  }
2193
+ if (claudeApiSettings?.enabled === true) {
2194
+ options.push({ value: 'claude-api', label: 'Claude API' });
2195
+ }
2162
2196
  if (claudeCloudSettings?.enabled === true) {
2163
2197
  options.push({ value: CLAUDE_CLOUD_PROVIDER, label: 'Claude Cloud' });
2164
2198
  }
@@ -2188,6 +2222,7 @@ async function openNewConversationModelModal() {
2188
2222
  if (providerSelect.dataset.modelsBound !== '1') {
2189
2223
  providerSelect.dataset.modelsBound = '1';
2190
2224
  providerSelect.addEventListener('change', () => {
2225
+ clearNewConversationClaudeApiRefusal();
2191
2226
  void populateNewConversationModelSelect(providerSelect.value);
2192
2227
  });
2193
2228
  }
@@ -2376,6 +2411,18 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
2376
2411
  showNewConversationCloudError(cloudError.text, cloudError.field ? [cloudError.field] : []);
2377
2412
  return;
2378
2413
  }
2414
+ // Claude API refused to start (this month's cap, no key, switched off):
2415
+ // nothing was bound. The relay's words stay in the modal, with the way
2416
+ // to fix it and the same chat on the subscription.
2417
+ const claudeApiActions = !recovered && selectedProvider === 'claude-api'
2418
+ ? newChatClaudeApiRefusalActions(error?.payload?.code, {
2419
+ subscriptionOffered: newConversationProviderOffered('claude'),
2420
+ })
2421
+ : [];
2422
+ if (claudeApiActions.length) {
2423
+ showNewConversationClaudeApiRefusal(error?.message || 'Claude API could not start this chat.', claudeApiActions, selectedModel);
2424
+ return;
2425
+ }
2379
2426
  showTransientRelayNotice(recovered
2380
2427
  // The chat is usable, but its worker did not start, so say what broke.
2381
2428
  ? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
@@ -2387,8 +2434,78 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
2387
2434
  }
2388
2435
  }
2389
2436
 
2437
+ function newConversationProviderOffered(provider) {
2438
+ return Array.from(document.getElementById('new-conversation-provider-select')?.options || [])
2439
+ .some((option) => option.value === provider);
2440
+ }
2441
+
2442
+ function clearNewConversationClaudeApiRefusal() {
2443
+ const note = document.getElementById('new-conversation-refusal');
2444
+ if (!note) return;
2445
+ note.replaceChildren();
2446
+ note.hidden = true;
2447
+ }
2448
+
2449
+ // The same chat, started on the Claude provider: its model when the Claude
2450
+ // list has it, the choices the modal holds otherwise.
2451
+ async function startNewConversationOnSubscription(model) {
2452
+ const providerSelect = document.getElementById('new-conversation-provider-select');
2453
+ if (!providerSelect || newConversationInFlight) return;
2454
+ clearNewConversationClaudeApiRefusal();
2455
+ // The effort chosen for the refused chat: rebuilding the lists for Claude
2456
+ // would fall back to the stored one.
2457
+ const effort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
2458
+ providerSelect.value = 'claude';
2459
+ await populateNewConversationModelSelect('claude');
2460
+ const modelSelect = document.getElementById('new-conversation-model-select');
2461
+ if (modelSelect && Array.from(modelSelect.options).some((option) => option.value === model)) {
2462
+ modelSelect.value = model;
2463
+ await populateNewConversationReasoningSelect(model);
2464
+ }
2465
+ const reasoningSelect = document.getElementById('new-conversation-reasoning-select');
2466
+ if (effort && reasoningSelect && Array.from(reasoningSelect.options).some((option) => option.value === effort)) {
2467
+ reasoningSelect.value = effort;
2468
+ }
2469
+ await confirmNewConversationModel();
2470
+ }
2471
+
2472
+ function showNewConversationClaudeApiRefusal(message, actions, model) {
2473
+ const note = document.getElementById('new-conversation-refusal');
2474
+ if (!note) {
2475
+ showTransientRelayNotice(message, 12000);
2476
+ return;
2477
+ }
2478
+ const text = document.createElement('div');
2479
+ text.className = 'new-conversation-refusal-text';
2480
+ text.textContent = message;
2481
+ const buttons = document.createElement('div');
2482
+ buttons.className = 'new-conversation-refusal-actions';
2483
+ for (const item of actions) {
2484
+ const button = document.createElement('button');
2485
+ button.type = 'button';
2486
+ button.className = 'summary-btn';
2487
+ button.dataset.action = item.action;
2488
+ button.textContent = item.label;
2489
+ button.addEventListener('click', (event) => {
2490
+ event.preventDefault();
2491
+ if (item.action === START_ON_SUBSCRIPTION_ACTION) {
2492
+ void startNewConversationOnSubscription(model);
2493
+ return;
2494
+ }
2495
+ closeNewConversationModelModal();
2496
+ runRelayErrorCta(item.action);
2497
+ });
2498
+ buttons.appendChild(button);
2499
+ }
2500
+ note.replaceChildren(text, buttons);
2501
+ note.hidden = false;
2502
+ // The modal's body scrolls: on a phone the note lands below the fold.
2503
+ note.scrollIntoView?.({ block: 'nearest' });
2504
+ }
2505
+
2390
2506
  export async function confirmNewConversationModel() {
2391
2507
  if (newConversationInFlight) return;
2508
+ clearNewConversationClaudeApiRefusal();
2392
2509
  const selectedModel = String(document.getElementById('new-conversation-model-select')?.value || '').trim();
2393
2510
  const selectedReasoningEffort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
2394
2511
  if (!selectedModel) return;
@@ -12,7 +12,7 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
12
12
  const provider = String(providerType || '').trim().toLowerCase();
13
13
  const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
14
14
  const providerFallback = () => {
15
- if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
15
+ if (provider === 'claude' || provider === 'claude-api' || provider === 'claude-cloud') return 'Message Claude…';
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
@@ -195,6 +195,22 @@ export function normalizeModelSelectorOptions(models = [], {
195
195
  });
196
196
  }
197
197
 
198
+ /**
199
+ * `humanizeModelLabel` for a list of ids, where two ids that would read the
200
+ * same (two dated snapshots of one model) keep their ids instead: a Map of
201
+ * id → label.
202
+ */
203
+ export function distinctModelLabels(modelIds = []) {
204
+ const ids = (Array.isArray(modelIds) ? modelIds : []).map((id) => String(id || '').trim()).filter(Boolean);
205
+ const labels = new Map(ids.map((id) => [id, humanizeModelLabel(id) || id]));
206
+ const counts = new Map();
207
+ for (const label of labels.values()) counts.set(label, (counts.get(label) || 0) + 1);
208
+ for (const [id, label] of labels) {
209
+ if (counts.get(label) > 1) labels.set(id, id);
210
+ }
211
+ return labels;
212
+ }
213
+
198
214
  /**
199
215
  * The one option list both pickers render (composer #model-select and the
200
216
  * New Chat modal), so they cannot disagree on order or wording. Callers filter
@@ -25,6 +25,27 @@ export function buildNewConversationModelChoices(options = []) {
25
25
  return choices;
26
26
  }
27
27
 
28
+ /**
29
+ * The model New Chat starts on, from the values its list offers: the
30
+ * composer's last model when the list has it, else the provider's own
31
+ * default (Claude API's settings model; the list is sorted by name, so its
32
+ * first entry is no default), else Auto when allowed, else ''. '' leaves the
33
+ * list's first entry.
34
+ */
35
+ export function preselectNewConversationModel(values = [], {
36
+ storedModel = '',
37
+ providerDefault = '',
38
+ allowAuto = true,
39
+ } = {}) {
40
+ const offered = new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean));
41
+ const stored = String(storedModel || '').trim();
42
+ if (stored && offered.has(stored)) return stored;
43
+ const fallback = String(providerDefault || '').trim();
44
+ if (fallback && offered.has(fallback)) return fallback;
45
+ if (allowAuto && offered.has('auto')) return 'auto';
46
+ return '';
47
+ }
48
+
28
49
  function normalizeProviderKey(provider = '') {
29
50
  const key = String(provider || '').trim().toLowerCase();
30
51
  if (key === 'openai-byok') return 'openai';
@@ -1,8 +1,8 @@
1
1
  // Which providers are known to be ready to run a chat, from what the client
2
2
  // already loads: the provider settings payloads and the model catalog. No DOM.
3
3
  //
4
- // OpenAI, Claude, Claude Cloud, Cursor, Grok and DeepSeek say so themselves: their
5
- // settings carry `enabled`. Copilot has no such flag and the relay never asks
4
+ // OpenAI, Claude, Claude API, Claude Cloud, Cursor, Grok and DeepSeek say so
5
+ // themselves: their settings carry `enabled`. Copilot has no such flag and the relay never asks
6
6
  // whether the Copilot CLI is signed in, so the best signal is indirect: has a
7
7
  // real Copilot model list ever reached the catalog? A fresh database is seeded
8
8
  // with a curated list under the source `bootstrap-seed`; a server discovery,
@@ -20,6 +20,7 @@ export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
20
20
  const SETTINGS_PROVIDERS = Object.freeze([
21
21
  ['openai', 'openai'],
22
22
  ['claude', 'claude'],
23
+ ['claudeApi', 'claude-api'],
23
24
  ['claudeCloud', 'claude-cloud'],
24
25
  ['cursor', 'cursor'],
25
26
  ['grok', 'grok'],
@@ -33,6 +34,7 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
33
34
  * @param {object} input the settings payloads New Chat loads, plus GET /api/models
34
35
  * @param {object|null} [input.openai]
35
36
  * @param {object|null} [input.claude]
37
+ * @param {object|null} [input.claudeApi] null on a relay without Claude API: not offered, not unknown
36
38
  * @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
37
39
  * @param {object|null} [input.cursor]
38
40
  * @param {object|null} [input.grok]
@@ -68,6 +70,7 @@ export function isSetupRequired(readiness) {
68
70
  const inputs = {
69
71
  openai: undefined,
70
72
  claude: undefined,
73
+ claudeApi: undefined,
71
74
  claudeCloud: undefined,
72
75
  cursor: undefined,
73
76
  grok: undefined,