@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
@@ -80,6 +80,7 @@ import {
80
80
  updateCursorSettings,
81
81
  updateGrokSettings,
82
82
  updateDeepSeekSettings,
83
+ updateClaudeApiSettings,
83
84
  loadConversation,
84
85
  refreshConversationHistory,
85
86
  updateConversationTitle,
@@ -181,6 +182,7 @@ import {
181
182
  composerPlaceholderFor,
182
183
  contextWindowSuffix,
183
184
  humanizeModelLabel,
185
+ distinctModelLabels,
184
186
  modelMetadataFor,
185
187
  modelSelectorOptionsEqual,
186
188
  splitModelVariantId,
@@ -330,6 +332,13 @@ import {
330
332
  importDeepSeekUsageExport,
331
333
  applyDeepSeekSettingsState,
332
334
  refreshDeepSeekSettingsState,
335
+ saveClaudeApiSettings,
336
+ removeClaudeApiSettings,
337
+ toggleClaudeApiProvider,
338
+ refreshClaudeApiModels,
339
+ openClaudeTabFromClaudeApi,
340
+ applyClaudeApiSettingsState,
341
+ refreshClaudeApiSettingsState,
333
342
  saveCursorSettings,
334
343
  removeCursorSettings,
335
344
  saveCursorAllowanceSettings,
@@ -373,6 +382,7 @@ import {
373
382
  } from './action-confirmations.js';
374
383
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
375
384
  import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
385
+ import { initClaudeBillingToggle, renderClaudeBillingToggle } from './claude-billing-toggle-ui.js';
376
386
  import {
377
387
  startClaudeRelogin,
378
388
  submitClaudeLoginCodeFromInput,
@@ -449,6 +459,7 @@ const PROVIDER_LABELS = {
449
459
  openai: 'OpenAI',
450
460
  'openai-byok': 'OpenAI (BYOK)',
451
461
  claude: 'Claude SDK',
462
+ 'claude-api': 'Claude API',
452
463
  'claude-cloud': 'Claude Cloud',
453
464
  cursor: 'Cursor SDK',
454
465
  grok: 'Grok',
@@ -522,6 +533,7 @@ let modelCatalogState = {
522
533
  reasoningEfforts: [],
523
534
  modelMetadataByModel: {},
524
535
  claudeContextTiersByModel: {},
536
+ claudeApiContextTiersByModel: {},
525
537
  stale: true,
526
538
  metadataValid: false,
527
539
  reasoningMetadataValid: false,
@@ -1444,6 +1456,8 @@ function syncSessionLockNote({ pinnedModel = '' } = {}) {
1444
1456
  } else {
1445
1457
  delete note.dataset.provider;
1446
1458
  }
1459
+ // The billing toggle sits under the note and follows the same chat.
1460
+ renderClaudeBillingToggle();
1447
1461
  }
1448
1462
 
1449
1463
  function applyModelMetadataHardFail(message = '') {
@@ -1510,6 +1524,7 @@ function normalizeModelSelectorProviderType(providerType = '') {
1510
1524
  const normalized = String(providerType || '').trim().toLowerCase();
1511
1525
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1512
1526
  if (normalized === 'claude') return 'claude';
1527
+ if (normalized === 'claude-api') return 'claude-api';
1513
1528
  if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1514
1529
  if (normalized === 'cursor') return 'cursor';
1515
1530
  if (normalized === 'grok') return 'grok';
@@ -1542,6 +1557,9 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1542
1557
  const hasCursor = providers.includes('cursor');
1543
1558
  const hasGrok = providers.includes('grok');
1544
1559
  const hasDeepSeek = providers.includes('deepseek');
1560
+ // Claude API rows are the Claude provider's ids on an API key: as
1561
+ // exclusive as the other runtimes' rows.
1562
+ const hasClaudeApi = providers.includes('claude-api');
1545
1563
  // Only present if a relay tags catalog rows for Claude Cloud; such a row is
1546
1564
  // then as exclusive as the other runtimes' rows.
1547
1565
  const hasClaudeCloud = providers.includes(CLAUDE_CLOUD_PROVIDER);
@@ -1551,15 +1569,17 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1551
1569
  && provider !== 'cursor'
1552
1570
  && provider !== 'grok'
1553
1571
  && provider !== 'deepseek'
1572
+ && provider !== 'claude-api'
1554
1573
  && provider !== CLAUDE_CLOUD_PROVIDER
1555
1574
  ));
1556
1575
  const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
1557
1576
  if (activeProvider === 'openai') return hasOpenAIByok;
1558
1577
  if (activeProvider === 'claude') return hasClaude;
1578
+ if (activeProvider === 'claude-api') return hasClaudeApi;
1559
1579
  if (activeProvider === 'cursor') return hasCursor;
1560
1580
  if (activeProvider === 'grok') return hasGrok;
1561
1581
  if (activeProvider === 'deepseek') return hasDeepSeek;
1562
- return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok || hasDeepSeek || hasClaudeCloud) && !hasNonExclusiveProvider);
1582
+ return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok || hasDeepSeek || hasClaudeApi || hasClaudeCloud) && !hasNonExclusiveProvider);
1563
1583
  }
1564
1584
 
1565
1585
  // A Claude Cloud conversation does not pick from the shared catalog: its
@@ -1595,10 +1615,16 @@ function buildModelSelectorOptions(
1595
1615
  if (normalizeModelSelectorProviderType(activeProviderType) === CLAUDE_CLOUD_PROVIDER) {
1596
1616
  return buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel);
1597
1617
  }
1598
- const normalizedOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1618
+ // Only this provider's models are labelled: two ids that read the same
1619
+ // fall back to their raw ids, and an id another provider serves
1620
+ // ("claude-haiku-4-5" beside the key's "claude-haiku-4-5-20251001") must
1621
+ // not do that to a list it is not in.
1622
+ const visibleModels = (models.length ? models : [FALLBACK_MODEL])
1623
+ .filter((modelId) => modelVisibleForActiveProvider(modelId, activeProviderType, providersByModel));
1624
+ const normalizedOptions = buildCatalogModelOptions(visibleModels, {
1599
1625
  autoValue: AUTO_MODEL_OPTION,
1600
1626
  metadataByModel,
1601
- annotateContextWindow: normalizeModelSelectorProviderType(activeProviderType) !== 'claude',
1627
+ annotateContextWindow: !['claude', 'claude-api'].includes(normalizeModelSelectorProviderType(activeProviderType)),
1602
1628
  });
1603
1629
  return normalizedOptions.filter((option) => modelVisibleForActiveProvider(
1604
1630
  option.value,
@@ -1804,6 +1830,12 @@ function updateModelCatalogState(payload) {
1804
1830
  Array.isArray(tiers) ? tiers : [],
1805
1831
  ]))
1806
1832
  : {},
1833
+ claudeApiContextTiersByModel: payload?.claudeApiContextTiersByModel && typeof payload.claudeApiContextTiersByModel === 'object'
1834
+ ? Object.fromEntries(Object.entries(payload.claudeApiContextTiersByModel).map(([modelId, tiers]) => [
1835
+ String(modelId || '').trim().toLowerCase(),
1836
+ Array.isArray(tiers) ? tiers : [],
1837
+ ]))
1838
+ : {},
1807
1839
  stale: !!payload?.stale,
1808
1840
  metadataValid: payload?.metadataValid === true,
1809
1841
  reasoningMetadataValid: payload?.reasoningMetadataValid === true,
@@ -1935,7 +1967,9 @@ function updateContextTierSelector(modelId) {
1935
1967
  modelId,
1936
1968
  providerType: activeComposerProviderType(),
1937
1969
  metadata: modelMetadataFor(modelId, modelCatalogState.modelMetadataByModel) || {},
1938
- claudeTiers: modelCatalogState.claudeContextTiersByModel,
1970
+ claudeTiers: activeComposerProviderType() === 'claude-api'
1971
+ ? modelCatalogState.claudeApiContextTiersByModel
1972
+ : modelCatalogState.claudeContextTiersByModel,
1939
1973
  });
1940
1974
  const current = select.value;
1941
1975
  select.innerHTML = '';
@@ -1957,8 +1991,9 @@ function updateModelPricingDetails(modelId, tier) {
1957
1991
  if (!details || !summary || !grid) return;
1958
1992
  const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
1959
1993
  // The catalog's prices are Copilot credits; a cloud session is billed to the
1960
- // Claude account, so they would be the wrong numbers for it.
1961
- const pricing = activeComposerProviderType() === CLAUDE_CLOUD_PROVIDER
1994
+ // Claude account and a Claude API chat to the Console in dollars, so they
1995
+ // would be the wrong numbers for them.
1996
+ const pricing = activeComposerProviderType() === CLAUDE_CLOUD_PROVIDER || activeComposerProviderType() === 'claude-api'
1962
1997
  ? null
1963
1998
  : metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1964
1999
  if (!pricing || typeof pricing !== 'object') {
@@ -2000,6 +2035,7 @@ const RELAY_MODES_BY_PROVIDER = {
2000
2035
  github: ['agent', 'ask', 'plan', 'autopilot'],
2001
2036
  openai: ['agent', 'ask', 'plan', 'autopilot'],
2002
2037
  claude: ['agent', 'ask', 'plan', 'autopilot'],
2038
+ 'claude-api': ['agent', 'ask', 'plan', 'autopilot'],
2003
2039
  cursor: ['agent', 'ask', 'plan', 'autopilot'],
2004
2040
  grok: ['agent', 'ask', 'plan', 'autopilot'],
2005
2041
  deepseek: ['agent', 'ask', 'plan', 'autopilot'],
@@ -2066,7 +2102,7 @@ async function persistCurrentConversationPreferences() {
2066
2102
  const contextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim().toLowerCase();
2067
2103
  const preferredModelWithTier = model
2068
2104
  && contextTier === 'long_context'
2069
- && activeComposerProviderType() === 'claude'
2105
+ && (activeComposerProviderType() === 'claude' || activeComposerProviderType() === 'claude-api')
2070
2106
  && !CLAUDE_LONG_CONTEXT_PATTERN.test(model)
2071
2107
  ? `${model}[1m]`
2072
2108
  : model;
@@ -2356,6 +2392,13 @@ function reportOpenAIModelDiscoveryFailure(payload) {
2356
2392
  8000,
2357
2393
  );
2358
2394
  }
2395
+ const claudeApiDiscovery = payload?.claudeApiModelDiscovery;
2396
+ if (claudeApiDiscovery && claudeApiDiscovery.ok === false && !claudeApiDiscovery.skipped) {
2397
+ showTransientRelayNotice(
2398
+ `Claude API model discovery failed. Cached Claude API models were kept: ${claudeApiDiscovery.error || 'unknown error'}`,
2399
+ 8000,
2400
+ );
2401
+ }
2359
2402
  }
2360
2403
 
2361
2404
  async function retryModelMetadataRefresh() {
@@ -2504,6 +2547,17 @@ function applyModelVariantCatalogState(payload) {
2504
2547
  : [],
2505
2548
  }
2506
2549
  : null,
2550
+ claudeApiModels: payload?.claudeApiModels && typeof payload.claudeApiModels === 'object'
2551
+ ? {
2552
+ defaultModel: String(payload.claudeApiModels.defaultModel || '').trim(),
2553
+ availableModels: Array.isArray(payload.claudeApiModels.availableModels)
2554
+ ? payload.claudeApiModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
2555
+ : [],
2556
+ enabledModels: Array.isArray(payload.claudeApiModels.enabledModels)
2557
+ ? payload.claudeApiModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2558
+ : [],
2559
+ }
2560
+ : null,
2507
2561
  };
2508
2562
  }
2509
2563
 
@@ -2572,6 +2626,11 @@ function renderModelVariantCatalogBody() {
2572
2626
  return entryProvider === 'claude';
2573
2627
  }
2574
2628
  if (entryProvider === 'claude') return false;
2629
+ // Claude API lists the same Claude ids as the Claude SDK tab, its own rows.
2630
+ if (activeTab === 'claude-api') {
2631
+ return entryProvider === 'claude-api';
2632
+ }
2633
+ if (entryProvider === 'claude-api') return false;
2575
2634
  if (activeTab === 'cursor') {
2576
2635
  return entryProvider === 'cursor';
2577
2636
  }
@@ -2596,6 +2655,7 @@ function renderModelVariantCatalogBody() {
2596
2655
  && provider !== 'cursor'
2597
2656
  && provider !== 'grok'
2598
2657
  && provider !== 'deepseek'
2658
+ && provider !== 'claude-api'
2599
2659
  && provider !== CLAUDE_CLOUD_PROVIDER
2600
2660
  ));
2601
2661
  }
@@ -2729,6 +2789,31 @@ function renderModelVariantCatalogBody() {
2729
2789
  }
2730
2790
  grouped.set('deepseek', providerRows);
2731
2791
  }
2792
+ const claudeApiCatalog = modelVariantCatalogState.claudeApiModels;
2793
+ if (claudeApiCatalog?.availableModels?.length) {
2794
+ const claudeApiEnabledSet = new Set(claudeApiCatalog.enabledModels || []);
2795
+ const claudeApiDefaultModel = String(claudeApiCatalog.defaultModel || '').trim();
2796
+ // Named by model; two snapshots that would read the same keep their ids.
2797
+ const claudeApiLabels = distinctModelLabels(claudeApiCatalog.availableModels);
2798
+ const providerRows = [];
2799
+ for (const [index, claudeApiModelId] of claudeApiCatalog.availableModels.entries()) {
2800
+ const isDefaultModel = claudeApiModelId === claudeApiDefaultModel;
2801
+ providerRows.push({
2802
+ variantId: `${claudeApiModelId}--provider-claude-api`,
2803
+ baseModelId: claudeApiModelId,
2804
+ provider: 'claude-api',
2805
+ label: claudeApiLabels.get(claudeApiModelId) || claudeApiModelId,
2806
+ releaseStatus: null,
2807
+ reasoningEffort: null,
2808
+ // The default model always stays enabled, as for the other providers.
2809
+ selectable: !isDefaultModel,
2810
+ enabled: claudeApiEnabledSet.has(claudeApiModelId) || isDefaultModel,
2811
+ claudeApiModelId,
2812
+ sortOrder: index,
2813
+ });
2814
+ }
2815
+ grouped.set('claude-api', providerRows);
2816
+ }
2732
2817
  const selected = new Set(modelVariantCatalogState.enabledVariantIds);
2733
2818
  const selectedOrder = new Map(
2734
2819
  modelVariantCatalogState.enabledVariantIds.map((variantId, index) => [variantId, index]),
@@ -2765,24 +2850,28 @@ function renderModelVariantCatalogBody() {
2765
2850
  const hasCursorTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'cursor'));
2766
2851
  const hasGrokTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'grok'));
2767
2852
  const hasDeepSeekTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'deepseek'));
2853
+ const hasClaudeApiTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'claude-api'));
2768
2854
  if (modelVariantCatalogProviderTab === 'openai' && !hasOpenAITab) modelVariantCatalogProviderTab = 'copilot';
2769
2855
  if (modelVariantCatalogProviderTab === 'anthropic' && !hasAnthropicTab) modelVariantCatalogProviderTab = 'copilot';
2770
2856
  if (modelVariantCatalogProviderTab === 'cursor' && !hasCursorTab) modelVariantCatalogProviderTab = 'copilot';
2771
2857
  if (modelVariantCatalogProviderTab === 'grok' && !hasGrokTab) modelVariantCatalogProviderTab = 'copilot';
2772
2858
  if (modelVariantCatalogProviderTab === 'deepseek' && !hasDeepSeekTab) modelVariantCatalogProviderTab = 'copilot';
2859
+ if (modelVariantCatalogProviderTab === 'claude-api' && !hasClaudeApiTab) modelVariantCatalogProviderTab = 'copilot';
2773
2860
  if (modelVariantCatalogProviderTab === 'copilot' && !hasCopilotTab) {
2774
2861
  if (hasOpenAITab) modelVariantCatalogProviderTab = 'openai';
2775
2862
  else if (hasAnthropicTab) modelVariantCatalogProviderTab = 'anthropic';
2776
2863
  else if (hasCursorTab) modelVariantCatalogProviderTab = 'cursor';
2777
2864
  else if (hasGrokTab) modelVariantCatalogProviderTab = 'grok';
2778
2865
  else if (hasDeepSeekTab) modelVariantCatalogProviderTab = 'deepseek';
2866
+ else if (hasClaudeApiTab) modelVariantCatalogProviderTab = 'claude-api';
2779
2867
  }
2780
2868
  const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
2781
2869
  const providerTabButtons = `
2782
- <div class="summary-tab-strip" style="display:flex;gap:8px;align-items:center;">
2870
+ <div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
2783
2871
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
2784
2872
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
2785
2873
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
2874
+ <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'claude-api' ? ' active' : ''}" data-model-provider-tab="claude-api" ${hasClaudeApiTab ? '' : 'disabled'}>Claude API</button>
2786
2875
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'cursor' ? ' active' : ''}" data-model-provider-tab="cursor" ${hasCursorTab ? '' : 'disabled'}>Cursor SDK</button>
2787
2876
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'grok' ? ' active' : ''}" data-model-provider-tab="grok" ${hasGrokTab ? '' : 'disabled'}>Grok</button>
2788
2877
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'deepseek' ? ' active' : ''}" data-model-provider-tab="deepseek" ${hasDeepSeekTab ? '' : 'disabled'}>DeepSeek</button>
@@ -2840,7 +2929,8 @@ function renderModelVariantCatalogBody() {
2840
2929
  const selectable = row.selectable !== false;
2841
2930
  // SDK-provider rows (Claude/Cursor/Grok/DeepSeek) carry their own enabled
2842
2931
  // flag; only Copilot variant rows read the enabledVariantIds selection.
2843
- const sdkProviderModelId = row.claudeModelId || row.cursorModelId || row.grokModelId || row.deepseekModelId || '';
2932
+ const sdkProviderModelId = row.claudeModelId || row.cursorModelId || row.grokModelId || row.deepseekModelId
2933
+ || row.claudeApiModelId || '';
2844
2934
  const checked = sdkProviderModelId
2845
2935
  ? row.enabled === true
2846
2936
  : (selectable && selected.has(row.variantId));
@@ -2862,12 +2952,14 @@ function renderModelVariantCatalogBody() {
2862
2952
  ? ` data-cursor-model="${escHtml(row.cursorModelId)}"`
2863
2953
  : (row.grokModelId
2864
2954
  ? ` data-grok-model="${escHtml(row.grokModelId)}"`
2865
- : (row.deepseekModelId ? ` data-deepseek-model="${escHtml(row.deepseekModelId)}"` : '')));
2955
+ : (row.deepseekModelId
2956
+ ? ` data-deepseek-model="${escHtml(row.deepseekModelId)}"`
2957
+ : (row.claudeApiModelId ? ` data-claude-api-model="${escHtml(row.claudeApiModelId)}"` : ''))));
2866
2958
  return `
2867
2959
  <label class="model-variant-row">
2868
2960
  <input class="model-variant-checkbox" type="checkbox" data-selectable="${selectable ? '1' : '0'}" data-variant-id="${escHtml(row.variantId)}"${sdkModelAttr} ${checked ? 'checked' : ''} ${selectable ? '' : 'disabled'}>
2869
2961
  <span class="model-variant-row-copy">
2870
- <span class="model-variant-row-title">${escHtml(row.variantId)}${effortChip}${statusChip}${fixedChip}</span>
2962
+ <span class="model-variant-row-title"${row.claudeApiModelId ? ` title="${escHtml(row.claudeApiModelId)}"` : ''}>${escHtml(row.claudeApiModelId ? row.label : row.variantId)}${effortChip}${statusChip}${fixedChip}</span>
2871
2963
  </span>
2872
2964
  </label>
2873
2965
  `;
@@ -2993,12 +3085,17 @@ async function saveSelectedModelsFromModal() {
2993
3085
  .filter((input) => input.checked)
2994
3086
  .map((input) => String(input.getAttribute('data-deepseek-model') || '').trim())
2995
3087
  .filter(Boolean);
3088
+ const claudeApiCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-claude-api-model]'));
3089
+ const selectedClaudeApiModels = claudeApiCheckboxes
3090
+ .filter((input) => input.checked)
3091
+ .map((input) => String(input.getAttribute('data-claude-api-model') || '').trim())
3092
+ .filter(Boolean);
2996
3093
  // Only the active provider tab's rows are in the DOM, but the PATCH
2997
3094
  // replaces the WHOLE enabled set — so the save must only change rows the
2998
3095
  // user could actually see. Off-screen variants keep their stored state,
2999
3096
  // otherwise saving from e.g. the OpenAI tab silently disables every
3000
3097
  // Copilot-only vendor group (Anthropic, Google, …).
3001
- const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model]):not([data-deepseek-model])'));
3098
+ const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model]):not([data-deepseek-model]):not([data-claude-api-model])'));
3002
3099
  const renderedVariantIds = new Set(renderedVariantCheckboxes
3003
3100
  .map((input) => String(input.getAttribute('data-variant-id') || '').trim())
3004
3101
  .filter(Boolean));
@@ -3044,7 +3141,13 @@ async function saveSelectedModelsFromModal() {
3044
3141
  if (!savedDeepSeek) throw new Error('Failed to save DeepSeek model selection');
3045
3142
  applyDeepSeekSettingsState(savedDeepSeek);
3046
3143
  }
3047
- if (claudeCheckboxes.length || cursorCheckboxes.length || grokCheckboxes.length || deepseekCheckboxes.length) {
3144
+ if (claudeApiCheckboxes.length) {
3145
+ const savedClaudeApi = await updateClaudeApiSettings({ enabledModels: selectedClaudeApiModels });
3146
+ if (!savedClaudeApi) throw new Error('Failed to save Claude API model selection');
3147
+ applyClaudeApiSettingsState(savedClaudeApi);
3148
+ }
3149
+ if (claudeCheckboxes.length || cursorCheckboxes.length || grokCheckboxes.length || deepseekCheckboxes.length
3150
+ || claudeApiCheckboxes.length) {
3048
3151
  const refreshedCatalog = await loadModelVariantCatalog();
3049
3152
  if (refreshedCatalog) applyModelVariantCatalogState(refreshedCatalog);
3050
3153
  }
@@ -3551,13 +3654,15 @@ async function loadContextSummaryAndRender(convId) {
3551
3654
  const refreshLookupId = sessionId || trimmedConvId || null;
3552
3655
  const providerLabel = payload.providerType === 'claude'
3553
3656
  ? 'Claude'
3554
- : (payload.providerType === CLAUDE_CLOUD_PROVIDER
3555
- ? 'Claude Cloud'
3556
- : (payload.providerType === 'cursor'
3557
- ? 'Cursor'
3558
- : (payload.providerType === 'grok'
3559
- ? 'Grok'
3560
- : (payload.providerType === 'deepseek' ? 'DeepSeek' : 'Copilot'))));
3657
+ : (payload.providerType === 'claude-api'
3658
+ ? 'Claude API'
3659
+ : (payload.providerType === CLAUDE_CLOUD_PROVIDER
3660
+ ? 'Claude Cloud'
3661
+ : (payload.providerType === 'cursor'
3662
+ ? 'Cursor'
3663
+ : (payload.providerType === 'grok'
3664
+ ? 'Grok'
3665
+ : (payload.providerType === 'deepseek' ? 'DeepSeek' : 'Copilot')))));
3561
3666
  const subtitle = sessionId
3562
3667
  ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3563
3668
  : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
@@ -3566,9 +3671,10 @@ async function loadContextSummaryAndRender(convId) {
3566
3671
  // (older relays send neither, and the notes fall back to draft vs stored).
3567
3672
  const sessionField = (key) => payload[key] ?? payload.contextUsage?.[key] ?? null;
3568
3673
  const modelContextLimit = sessionField('modelContextLimit');
3569
- // A DeepSeek chat runs the same Claude Code runner, so it has the same
3570
- // compaction window to show and to steer.
3571
- const runsClaudeCode = payload.providerType === 'claude' || payload.providerType === 'deepseek';
3674
+ // A DeepSeek or Claude API chat runs the same Claude Code runner, so it has
3675
+ // the same compaction window to show and to steer.
3676
+ const runsClaudeCode = payload.providerType === 'claude' || payload.providerType === 'deepseek'
3677
+ || payload.providerType === 'claude-api';
3572
3678
  const usageHtml = renderContextUsageHtml(payload.contextUsage, {
3573
3679
  compactionWindow: runsClaudeCode,
3574
3680
  modelContextLimit,
@@ -3614,15 +3720,17 @@ async function loadContextSummaryAndRender(convId) {
3614
3720
  usedTokens: autoCompactControlSession.usedTokens,
3615
3721
  })
3616
3722
  : '';
3617
- const thinkingHtml = payload.providerType === 'claude'
3723
+ const thinkingHtml = payload.providerType === 'claude' || payload.providerType === 'claude-api'
3618
3724
  ? renderThinkingControlHtml({
3619
3725
  thinkingEnabled: payload.thinkingEnabled !== false,
3620
3726
  thinkingDisplay: payload.thinkingDisplay ?? null,
3621
3727
  })
3622
3728
  : '';
3623
- // What this folder's commits say, with the folder override: Claude, and a
3624
- // Claude Cloud chat that was started from a folder (no path, no row).
3625
- const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-cloud'
3729
+ // What this folder's commits say, with the folder override: Claude (on
3730
+ // either billing), and a Claude Cloud chat that was started from a folder
3731
+ // (no path, no row).
3732
+ const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-api'
3733
+ || payload.providerType === 'claude-cloud'
3626
3734
  ? renderAttributionControlHtml(payload.attribution || null)
3627
3735
  : '';
3628
3736
  thinkingControlStored = {
@@ -4652,6 +4760,7 @@ initSocketHandlers({
4652
4760
  applyGrokSettingsState,
4653
4761
  applyCursorSettingsState,
4654
4762
  applyDeepSeekSettingsState,
4763
+ applyClaudeApiSettingsState,
4655
4764
  });
4656
4765
 
4657
4766
  initCwdPicker({
@@ -4736,6 +4845,15 @@ async function initApp() {
4736
4845
  syncWindowRetentionSlider();
4737
4846
  initHostSuspendUi();
4738
4847
  initUsageLimitUi();
4848
+ // After a switch the chat's provider changed: its model list, lock note
4849
+ // and list pill follow.
4850
+ initClaudeBillingToggle({
4851
+ isSharedReaderMode,
4852
+ onConversationProviderChanged: (conversationId) => {
4853
+ if (conversationId === currentConvId) syncAutoModelAvailability();
4854
+ renderConvList();
4855
+ },
4856
+ });
4739
4857
  }
4740
4858
  setupViewportTracking();
4741
4859
  bindChatSelectionGuard();
@@ -5036,12 +5154,15 @@ async function initApp() {
5036
5154
  const claudeSettings = await refreshClaudeSettingsState();
5037
5155
  // null on a relay without DeepSeek: not offered, as for Claude Cloud.
5038
5156
  const deepseekSettings = await refreshDeepSeekSettingsState();
5157
+ // The same for Claude API; its state also decides the composer's billing toggle.
5158
+ const claudeApiSettings = await refreshClaudeApiSettingsState();
5039
5159
  updateProviderReadinessInputs({
5040
5160
  openai: openAISettings || null,
5041
5161
  cursor: cursorSettings || null,
5042
5162
  grok: grokSettings || null,
5043
5163
  claude: claudeSettings || null,
5044
5164
  deepseek: deepseekSettings || null,
5165
+ claudeApi: claudeApiSettings || null,
5045
5166
  });
5046
5167
  // Not awaited: only a Claude Cloud conversation needs it (its model list),
5047
5168
  // and the subscription below rebuilds that list when the answer arrives. A
@@ -5221,6 +5342,11 @@ window.saveDeepSeekSettings = saveDeepSeekSettings;
5221
5342
  window.removeDeepSeekSettings = removeDeepSeekSettings;
5222
5343
  window.toggleDeepSeekProvider = toggleDeepSeekProvider;
5223
5344
  window.importDeepSeekUsageExport = importDeepSeekUsageExport;
5345
+ window.saveClaudeApiSettings = saveClaudeApiSettings;
5346
+ window.removeClaudeApiSettings = removeClaudeApiSettings;
5347
+ window.toggleClaudeApiProvider = toggleClaudeApiProvider;
5348
+ window.refreshClaudeApiModels = refreshClaudeApiModels;
5349
+ window.openClaudeTabFromClaudeApi = openClaudeTabFromClaudeApi;
5224
5350
  window.toggleGrokProvider = toggleGrokProvider;
5225
5351
  window.saveClaudeCloudSettings = saveClaudeCloudSettings;
5226
5352
  window.toggleClaudeCloudProvider = toggleClaudeCloudProvider;