@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
@@ -47,6 +47,11 @@ import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
47
47
  import { createCliInstallService } from '../services/cli-install-service.mjs';
48
48
  import { createGrokAuthService } from '../services/grok-auth-service.mjs';
49
49
  import { parseDeepSeekSettingsUpdateRequest } from '../services/deepseek-settings-service.mjs';
50
+ import {
51
+ CLAUDE_API_PROVIDER_LABEL,
52
+ CLAUDE_API_PROVIDER_TYPE,
53
+ parseClaudeApiSettingsUpdateRequest,
54
+ } from '../services/claude-api-settings-service.mjs';
50
55
  import {
51
56
  DEEPSEEK_PROVIDER_TYPE,
52
57
  DEEPSEEK_REASONING_EFFORTS,
@@ -496,15 +501,34 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
496
501
  };
497
502
  }
498
503
 
499
- export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
500
- const configured = claudeSettings?.enabled === true;
501
- const model = String(claudeSettings?.model || '').trim();
502
- if (!configured || !model) return { ...modelState };
503
- const baseModels = new Set(
504
+ /**
505
+ * The lower-cased ids Copilot itself lists in `modelState`: those an earlier
506
+ * provider layer did not add. A layer tags every id it adds, so an id with
507
+ * tags but none of them Copilot's is another provider's ("claude-opus-5-5"
508
+ * from the Claude layer), not one Copilot serves too.
509
+ */
510
+ function copilotListedModelIds(modelState = {}) {
511
+ const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
512
+ ? modelState.providersByModel
513
+ : {};
514
+ return new Set(
504
515
  (Array.isArray(modelState?.models) ? modelState.models : [])
516
+ .filter((value) => {
517
+ const id = String(value || '').trim();
518
+ const providers = providersByModel[id] || providersByModel[id.toLowerCase()];
519
+ return !Array.isArray(providers) || providers.length === 0
520
+ || providers.some((provider) => String(provider || '').trim().toLowerCase() === 'github-copilot');
521
+ })
505
522
  .map((value) => String(value || '').trim().toLowerCase())
506
523
  .filter((value) => value && value.toLowerCase() !== 'auto'),
507
524
  );
525
+ }
526
+
527
+ export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
528
+ const configured = claudeSettings?.enabled === true;
529
+ const model = String(claudeSettings?.model || '').trim();
530
+ if (!configured || !model) return { ...modelState };
531
+ const baseModels = copilotListedModelIds(modelState);
508
532
  const claudeModels = Array.isArray(claudeSettings?.models)
509
533
  ? claudeSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
510
534
  : [];
@@ -600,11 +624,7 @@ export function buildModelCatalogWithOpenAIProvider(modelState = {}, openAISetti
600
624
  const configured = openAISettings?.enabled === true;
601
625
  const model = String(openAISettings?.model || '').trim();
602
626
  if (!configured || !model) return { ...modelState };
603
- const baseModels = new Set(
604
- (Array.isArray(modelState?.models) ? modelState.models : [])
605
- .map((value) => String(value || '').trim().toLowerCase())
606
- .filter((value) => value && value.toLowerCase() !== 'auto'),
607
- );
627
+ const baseModels = copilotListedModelIds(modelState);
608
628
  const openAIModels = Array.isArray(openAISettings?.models)
609
629
  ? openAISettings.models.map((value) => String(value || '').trim()).filter(Boolean)
610
630
  : [];
@@ -654,11 +674,7 @@ export function buildModelCatalogWithCursorProvider(modelState = {}, cursorSetti
654
674
  const configured = cursorSettings?.enabled === true;
655
675
  const model = String(cursorSettings?.model || '').trim();
656
676
  if (!configured || !model) return { ...modelState };
657
- const baseModels = new Set(
658
- (Array.isArray(modelState?.models) ? modelState.models : [])
659
- .map((value) => String(value || '').trim().toLowerCase())
660
- .filter((value) => value && value.toLowerCase() !== 'auto'),
661
- );
677
+ const baseModels = copilotListedModelIds(modelState);
662
678
  const cursorModelList = Array.isArray(cursorSettings?.models)
663
679
  ? cursorSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
664
680
  : [];
@@ -743,11 +759,7 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
743
759
  const configured = grokSettings?.enabled === true;
744
760
  const model = String(grokSettings?.model || '').trim();
745
761
  if (!configured || !model) return { ...modelState };
746
- const baseModels = new Set(
747
- (Array.isArray(modelState?.models) ? modelState.models : [])
748
- .map((value) => String(value || '').trim().toLowerCase())
749
- .filter((value) => value && value.toLowerCase() !== 'auto'),
750
- );
762
+ const baseModels = copilotListedModelIds(modelState);
751
763
  const grokModelList = Array.isArray(grokSettings?.models)
752
764
  ? grokSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
753
765
  : [];
@@ -855,6 +867,82 @@ export function buildModelCatalogWithDeepSeekProvider(modelState = {}, deepseekS
855
867
  };
856
868
  }
857
869
 
870
+ /**
871
+ * Claude API (provider `claude-api`) serves the Claude provider's model ids
872
+ * on an API key. Its layer tags them `claude-api` beside whatever else serves
873
+ * them and keeps its own efforts and context tiers: `[1m]` variants fold into
874
+ * their base model as a long_context tier, as on the Claude layer. An id is
875
+ * never enough to pick this provider; only an explicit `claude-api` does.
876
+ */
877
+ export function buildModelCatalogWithClaudeApiProvider(modelState = {}, claudeApiSettings = {}) {
878
+ const configured = claudeApiSettings?.enabled === true;
879
+ const model = String(claudeApiSettings?.model || '').trim();
880
+ if (!configured || !model) return { ...modelState };
881
+ const listed = Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [];
882
+ const longContextBases = new Set();
883
+ const defaultContextBases = new Set();
884
+ const baseModels = [];
885
+ const seenBases = new Set();
886
+ for (const candidate of [model, ...listed]) {
887
+ const modelId = String(candidate || '').trim();
888
+ if (!modelId) continue;
889
+ const isLongContextVariant = CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN.test(modelId);
890
+ const baseId = modelId.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '');
891
+ if (!baseId) continue;
892
+ if (isLongContextVariant) longContextBases.add(baseId.toLowerCase());
893
+ else defaultContextBases.add(baseId.toLowerCase());
894
+ if (seenBases.has(baseId.toLowerCase())) continue;
895
+ seenBases.add(baseId.toLowerCase());
896
+ baseModels.push(baseId);
897
+ }
898
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...baseModels]));
899
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
900
+ const claudeApiReasoningByModel = {};
901
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
902
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
903
+ const claudeApiContextTiersByModel = {};
904
+ const effortsByModel = claudeApiSettings?.effortsByModel && typeof claudeApiSettings.effortsByModel === 'object'
905
+ ? claudeApiSettings.effortsByModel
906
+ : {};
907
+ for (const baseId of baseModels) {
908
+ const lowerKey = baseId.toLowerCase();
909
+ claudeApiContextTiersByModel[lowerKey] = [
910
+ ...(defaultContextBases.has(lowerKey) ? [{ value: 'default' }] : []),
911
+ ...(longContextBases.has(lowerKey) ? [{ value: 'long_context' }] : []),
912
+ ];
913
+ const efforts = Array.isArray(effortsByModel[lowerKey]) && effortsByModel[lowerKey].length
914
+ ? effortsByModel[lowerKey]
915
+ : effortsByModel[`${lowerKey}[1m]`];
916
+ const claudeApiEfforts = Array.isArray(efforts) && efforts.length
917
+ ? withClaudeUltracodeTier(efforts)
918
+ : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
919
+ claudeApiReasoningByModel[lowerKey] = [...claudeApiEfforts];
920
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
921
+ reasoningByModel[lowerKey] = [...claudeApiEfforts];
922
+ }
923
+ const existingProviders = Array.isArray(providersByModel[baseId])
924
+ ? providersByModel[baseId]
925
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
926
+ providersByModel[baseId] = Array.from(new Set([...existingProviders, CLAUDE_API_PROVIDER_TYPE]));
927
+ modelMetadataByModel[baseId] = {
928
+ ...(modelMetadataByModel[baseId] || {}),
929
+ provider: modelMetadataByModel[baseId]?.provider || CLAUDE_API_PROVIDER_TYPE,
930
+ };
931
+ }
932
+ return {
933
+ ...modelState,
934
+ models,
935
+ reasoningByModel,
936
+ reasoningByProvider: {
937
+ ...(modelState?.reasoningByProvider || {}),
938
+ [CLAUDE_API_PROVIDER_TYPE]: claudeApiReasoningByModel,
939
+ },
940
+ modelMetadataByModel,
941
+ providersByModel,
942
+ claudeApiContextTiersByModel,
943
+ };
944
+ }
945
+
858
946
  function normalizeRequestedProviderType(value = '') {
859
947
  const normalized = String(value || '').trim().toLowerCase();
860
948
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -865,6 +953,8 @@ function normalizeRequestedProviderType(value = '') {
865
953
  if (normalized === 'cursor') return 'cursor';
866
954
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
867
955
  if (normalized === 'deepseek') return 'deepseek';
956
+ // Its own provider, never read as `claude`.
957
+ if (normalized === 'claude-api') return 'claude-api';
868
958
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
869
959
  return '';
870
960
  }
@@ -1506,9 +1596,10 @@ export function buildConversationSessionRootPayload({
1506
1596
  }
1507
1597
  // Claude sessions have no ~/.copilot/session-state entry — only the Copilot
1508
1598
  // CLI creates those — so they resolve against the Agent SDK's own layout and
1509
- // never fall through to the branch below.
1599
+ // never fall through to the branch below. A Claude API session is a Claude
1600
+ // session on an API key, in the same place.
1510
1601
  const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
1511
- if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek') {
1602
+ if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek' || claudeCodeProvider === 'claude-api') {
1512
1603
  const nativeSessionId = String(claudeNativeSessionId || '').trim();
1513
1604
  const resolveRoot = claudeCodeProvider === 'deepseek' ? resolveDeepSeekSessionRoot : resolveClaudeSessionRoot;
1514
1605
  if (!nativeSessionId || typeof resolveRoot !== 'function') return null;
@@ -2138,6 +2229,20 @@ export function registerSessionsRoutes(app, deps) {
2138
2229
  // attribution (services/deepseek-settings-service.mjs). Absent in older
2139
2230
  // wiring and most tests, where the provider is simply off.
2140
2231
  deepseekSettingsService = null,
2232
+ // Claude API (provider `claude-api`): its key, models and monthly cap
2233
+ // (services/claude-api-settings-service.mjs). Absent in older wiring and
2234
+ // most tests, where the provider is simply off.
2235
+ claudeApiSettingsService = null,
2236
+ // The Claude API card and monthly cap (services/plan-usage-claude-api.mjs),
2237
+ // and the cap's one seam (`{ reached, error? }`; a throw counts as
2238
+ // reached). Absent in older wiring and most tests: no card, no cap.
2239
+ claudeApiPlanUsage = null,
2240
+ getClaudeApiCapState = () => ({ reached: false }),
2241
+ // Asks whether the cap flipped after the Claude API settings changed.
2242
+ checkClaudeApiCap = () => {},
2243
+ // The once-per-episode notes for stopped self-started turns on the key
2244
+ // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2245
+ claudeApiStopLedger = null,
2141
2246
  reconcileUnstartedConversationProviders = async () => ({
2142
2247
  updatedUnstartedConversations: 0,
2143
2248
  skippedStartedConversations: 0,
@@ -2248,6 +2353,7 @@ export function registerSessionsRoutes(app, deps) {
2248
2353
  hostSuspendService = null,
2249
2354
  collectHostActivity = null,
2250
2355
  usageLimitPauseService = null,
2356
+ claudeBillingSwitchService = null,
2251
2357
  hostSuspendPlatform = process.platform,
2252
2358
  // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2253
2359
  // conversations another relay's agent created, and the inbound switch.
@@ -2504,20 +2610,28 @@ export function registerSessionsRoutes(app, deps) {
2504
2610
  || { configured: false, enabled: false, model: DEFAULT_DEEPSEEK_MODEL, models: [], availableModels: [] };
2505
2611
  }
2506
2612
 
2613
+ function getClaudeApiProviderSettings() {
2614
+ return claudeApiSettingsService?.getSettings?.()
2615
+ || { configured: false, enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} };
2616
+ }
2617
+
2507
2618
  // Layer every enabled provider's models onto the base Copilot catalog.
2508
2619
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2509
- return buildModelCatalogWithDeepSeekProvider(
2510
- buildModelCatalogWithGrokProvider(
2511
- buildModelCatalogWithCursorProvider(
2512
- buildModelCatalogWithClaudeProvider(
2513
- buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2514
- getClaudeProviderSettings(),
2620
+ return buildModelCatalogWithClaudeApiProvider(
2621
+ buildModelCatalogWithDeepSeekProvider(
2622
+ buildModelCatalogWithGrokProvider(
2623
+ buildModelCatalogWithCursorProvider(
2624
+ buildModelCatalogWithClaudeProvider(
2625
+ buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2626
+ getClaudeProviderSettings(),
2627
+ ),
2628
+ getCursorProviderSettings(),
2515
2629
  ),
2516
- getCursorProviderSettings(),
2630
+ getGrokProviderSettings(),
2517
2631
  ),
2518
- getGrokProviderSettings(),
2632
+ getDeepSeekProviderSettings(),
2519
2633
  ),
2520
- getDeepSeekProviderSettings(),
2634
+ getClaudeApiProviderSettings(),
2521
2635
  );
2522
2636
  }
2523
2637
 
@@ -2708,7 +2822,7 @@ export function registerSessionsRoutes(app, deps) {
2708
2822
  async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
2709
2823
  const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2710
2824
  try {
2711
- if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2825
+ if (provider === 'claude' || provider === 'claude-api') return deleteClaudeCliSession({ conversation, runtimeSession });
2712
2826
  if (provider === 'deepseek') return deleteDeepSeekCliSession({ conversation, runtimeSession });
2713
2827
  if (!sdkSessionId) return { deleted: null };
2714
2828
  if (!provider || provider === 'github' || provider === 'openai') {
@@ -2782,7 +2896,7 @@ export function registerSessionsRoutes(app, deps) {
2782
2896
  /**
2783
2897
  * The `group` field of a single conversation payload: the summary and the
2784
2898
  * conversation's role. The live members, with what each is doing, only
2785
- * when asked for (`groupMembers=1`, the remote_relay tool's read_session)
2899
+ * when asked for (`groupMembers=1`, the relay_tool's read_session)
2786
2900
  * and never for a paired relay's agent: the browser does not read them,
2787
2901
  * and archived members' titles stay on this relay.
2788
2902
  */
@@ -3644,9 +3758,11 @@ export function registerSessionsRoutes(app, deps) {
3644
3758
  // The cursor and grok workers post the same Claude-shaped usage blob, and
3645
3759
  // so does the Claude Cloud worker (the cloud session's context and token
3646
3760
  // usage), and so does the DeepSeek worker (the Claude one, pointed at
3647
- // DeepSeek), so all five providers share the stored reader.
3761
+ // DeepSeek), and so does the Claude API one (the Claude worker on an API
3762
+ // key), so all six providers share the stored reader.
3648
3763
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3649
- || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek';
3764
+ || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek'
3765
+ || providerType === 'claude-api';
3650
3766
 
3651
3767
  const parsed = usesStoredContextUsage
3652
3768
  ? (() => {
@@ -3675,12 +3791,12 @@ export function registerSessionsRoutes(app, deps) {
3675
3791
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3676
3792
  );
3677
3793
  const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
3678
- // Only the local Claude worker (and the DeepSeek one, the same runner)
3679
- // runs a CLI whose window the slider sets; it reports that CLI on its
3680
- // heartbeat's steering snapshot.
3794
+ // Only the local Claude worker (and the DeepSeek and Claude API ones, the
3795
+ // same runner) runs a CLI whose window the slider sets; it reports that
3796
+ // CLI on its heartbeat's steering snapshot.
3681
3797
  const windowStatus = resolveAutoCompactWindowStatus({
3682
3798
  storedWindow: autoCompactWindow,
3683
- steering: (providerType === 'claude' || providerType === 'deepseek') && copilotSessionId
3799
+ steering: (providerType === 'claude' || providerType === 'deepseek' || providerType === 'claude-api') && copilotSessionId
3684
3800
  ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3685
3801
  : null,
3686
3802
  });
@@ -3700,7 +3816,7 @@ export function registerSessionsRoutes(app, deps) {
3700
3816
  // What this folder's Claude commits say (the 🧠 modal's override row).
3701
3817
  // A Claude Cloud chat has the row when it was started from a folder,
3702
3818
  // with its own model in the example.
3703
- ...(providerType === 'claude'
3819
+ ...(providerType === 'claude' || providerType === 'claude-api'
3704
3820
  ? { attribution: workspaceRootAttributionFor(conversationRow) }
3705
3821
  : {}),
3706
3822
  ...(providerType === 'claude-cloud' && cloudConversationFolder(conversationRow)
@@ -4728,6 +4844,9 @@ export function registerSessionsRoutes(app, deps) {
4728
4844
  try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
4729
4845
  })(),
4730
4846
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4847
+ // A Claude chat's billing (subscription or API key) and whether the
4848
+ // composer's toggle may switch it now, with the reason when not.
4849
+ billing: claudeBillingSwitchService?.billingState?.(resolvedConversationId) || null,
4731
4850
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4732
4851
  // Every pin of the conversation, also those outside this history page.
4733
4852
  pins: listConversationPins(resolvedConversationId),
@@ -5014,7 +5133,7 @@ export function registerSessionsRoutes(app, deps) {
5014
5133
  return res.json({ ok: true, ...persisted });
5015
5134
  });
5016
5135
 
5017
- // ─── PATCH /api/conversation/:id/settings — the remote_relay tool's update_session ───
5136
+ // ─── PATCH /api/conversation/:id/settings — the relay_tool's update_session ───
5018
5137
  //
5019
5138
  // `{ settings: { model, effort, mode, auto_compact_window, title, group,
5020
5139
  // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
@@ -5135,7 +5254,7 @@ export function registerSessionsRoutes(app, deps) {
5135
5254
  plan.preferences.thinkingDisplay = wanted.thinking_display;
5136
5255
  change('thinking_display');
5137
5256
  }
5138
- if (provider !== 'claude' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5257
+ if (provider !== 'claude' && provider !== 'claude-api' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5139
5258
  plan.notes.push(`auto_compact_window and thinking apply to Claude sessions only; this one runs on ${provider}, so they are stored but change nothing there.`);
5140
5259
  }
5141
5260
  if (wanted.title !== undefined) {
@@ -6267,6 +6386,35 @@ export function registerSessionsRoutes(app, deps) {
6267
6386
  environmentId: cloudEnvironmentId,
6268
6387
  });
6269
6388
  }
6389
+ // Claude API is asked for by name only, like Claude Cloud: its model ids
6390
+ // are the Claude provider's own, so a model never selects it. Refused
6391
+ // first while it has no key or is switched off.
6392
+ const useClaudeApiProvider = requestedProviderType === CLAUDE_API_PROVIDER_TYPE;
6393
+ const claudeApiSettings = useClaudeApiProvider ? getClaudeApiProviderSettings() : null;
6394
+ if (useClaudeApiProvider && claudeApiSettings?.enabled !== true) {
6395
+ return res.status(400).json({
6396
+ ok: false,
6397
+ error: claudeApiSettings?.configured === true
6398
+ ? 'Claude API is switched off for new conversations.'
6399
+ : 'Claude API key is not configured.',
6400
+ code: 'CLAUDE_API_NOT_CONFIGURED',
6401
+ });
6402
+ }
6403
+ // This month's cap (plan decision 12): no new Claude API chat while it is
6404
+ // reached, for New Chat and an agent's create_session alike. A cap that
6405
+ // cannot be read counts as reached.
6406
+ if (useClaudeApiProvider) {
6407
+ let cap = null;
6408
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
6409
+ if (cap?.reached === true) {
6410
+ return res.status(409).json({
6411
+ ok: false,
6412
+ // The remedy names the place, so the reason does not.
6413
+ error: `${String(cap.reason || cap.error || '').trim() || 'This month\'s Claude API spend has reached its cap.'} No Claude API chat was started; start it on the Claude subscription, or raise the cap in Settings → Providers → Claude API.`,
6414
+ code: 'CLAUDE_API_CAP_REACHED',
6415
+ });
6416
+ }
6417
+ }
6270
6418
  const modelState = buildModelCatalogWithProviders(
6271
6419
  getModelCatalogState(),
6272
6420
  openAISettings,
@@ -6312,6 +6460,13 @@ export function registerSessionsRoutes(app, deps) {
6312
6460
  ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : []),
6313
6461
  ].map((value) => String(value || '').trim()).filter(Boolean);
6314
6462
  const availableDeepSeekModels = new Set(deepseekModelCatalog.map((value) => value.toLowerCase()));
6463
+ // Base ids of "[1m]" variants count too, as for the Claude provider.
6464
+ const availableClaudeApiModels = new Set((useClaudeApiProvider ? [
6465
+ String(claudeApiSettings?.model || '').trim(),
6466
+ ...(Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : []),
6467
+ ] : []).map((value) => String(value || '').trim()).filter(Boolean)
6468
+ .flatMap((value) => [value, value.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '')])
6469
+ .filter(Boolean));
6315
6470
  const useOpenAIProvider = requestedProviderType === 'openai'
6316
6471
  || requestedProviderType === 'openai-image'
6317
6472
  || (
@@ -6376,6 +6531,12 @@ export function registerSessionsRoutes(app, deps) {
6376
6531
  configuredModel: claudeCloudSettings.defaultModel,
6377
6532
  availableModels: Array.isArray(claudeCloudSettings.models) ? claudeCloudSettings.models : [],
6378
6533
  })
6534
+ : useClaudeApiProvider
6535
+ ? resolveProviderModelSelection({
6536
+ requestedModel: providerRequestedModel(availableClaudeApiModels),
6537
+ configuredModel: claudeApiSettings.model,
6538
+ availableModels: availableClaudeApiModels,
6539
+ })
6379
6540
  : useOpenAIProvider
6380
6541
  ? resolveProviderModelSelection({
6381
6542
  requestedModel: providerRequestedModel(availableOpenAIModels),
@@ -6414,11 +6575,15 @@ export function registerSessionsRoutes(app, deps) {
6414
6575
  if (providerModelSelection && !providerModelSelection.ok) {
6415
6576
  const providerLabel = useClaudeCloudProvider
6416
6577
  ? 'Claude Cloud'
6578
+ : useClaudeApiProvider
6579
+ ? CLAUDE_API_PROVIDER_LABEL
6417
6580
  : (useOpenAIProvider
6418
6581
  ? 'OpenAI'
6419
6582
  : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : (useGrokProvider ? 'Grok' : 'DeepSeek'))));
6420
6583
  const providerCode = useClaudeCloudProvider
6421
6584
  ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
6585
+ : useClaudeApiProvider
6586
+ ? 'CLAUDE_API_MODEL_UNAVAILABLE'
6422
6587
  : (useOpenAIProvider
6423
6588
  ? 'OPENAI_MODEL_UNAVAILABLE'
6424
6589
  : (useClaudeProvider
@@ -6442,10 +6607,11 @@ export function registerSessionsRoutes(app, deps) {
6442
6607
  // lookup below knows exact base ids only and would fall back too.
6443
6608
  let copilotBoundModel = '';
6444
6609
  let copilotVariantEffort = '';
6610
+ let copilotRefusal = null;
6445
6611
  if (
6446
6612
  remoteRequest.remote
6447
6613
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6448
- && !useDeepSeekProvider
6614
+ && !useDeepSeekProvider && !useClaudeApiProvider
6449
6615
  ) {
6450
6616
  const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
6451
6617
  const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
@@ -6467,20 +6633,23 @@ export function registerSessionsRoutes(app, deps) {
6467
6633
  effortsFor: (base) => reasoningByModel[base] || reasoningByModel[String(base).toLowerCase()] || null,
6468
6634
  });
6469
6635
  // Another provider's model is answered by the checks below ("requires
6470
- // the Claude provider"), not as an unknown one.
6636
+ // the Claude provider"), not as an unknown one. One that several other
6637
+ // providers list ("claude-opus-5-5": Claude and Cursor) passes those
6638
+ // checks and is refused as unknown after them.
6471
6639
  const otherProviders = mergedModels.some((modelId) => String(modelId || '').trim().toLowerCase() === requestedBootstrapModel.toLowerCase());
6472
6640
  if (copilotCheck.status === 'known' && copilotCheck.model && !isAutoModelId(copilotCheck.model)) {
6473
6641
  copilotBoundModel = copilotCheck.base || copilotCheck.model;
6474
6642
  copilotVariantEffort = copilotCheck.effort || '';
6475
6643
  }
6476
- if (copilotCheck.status === 'unknown' && !otherProviders) {
6477
- return res.status(400).json({
6644
+ if (copilotCheck.status === 'unknown') {
6645
+ copilotRefusal = {
6478
6646
  ok: false,
6479
6647
  error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
6480
6648
  code: 'COPILOT_MODEL_UNAVAILABLE',
6481
6649
  supportedModels: copilotCheck.models,
6482
6650
  ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
6483
- });
6651
+ };
6652
+ if (!otherProviders) return res.status(400).json(copilotRefusal);
6484
6653
  }
6485
6654
  }
6486
6655
  if (useOpenAIProvider && openAISettings?.configured !== true) {
@@ -6574,7 +6743,10 @@ export function registerSessionsRoutes(app, deps) {
6574
6743
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
6575
6744
  });
6576
6745
  }
6577
- if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && !useDeepSeekProvider) {
6746
+ if (
6747
+ !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6748
+ && !useDeepSeekProvider && !useClaudeApiProvider
6749
+ ) {
6578
6750
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
6579
6751
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
6580
6752
  : [];
@@ -6587,8 +6759,12 @@ export function registerSessionsRoutes(app, deps) {
6587
6759
  code: 'OPENAI_PROVIDER_REQUIRED',
6588
6760
  });
6589
6761
  }
6590
- const claudeOnlySelection = selectedProviders.length > 0
6591
- && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'claude');
6762
+ // Claude API serves the Claude provider's ids: a model both list is
6763
+ // still Claude's, and one only Claude API lists names that provider.
6764
+ const selectedProviderIds = selectedProviders.map((provider) => String(provider || '').trim().toLowerCase());
6765
+ const claudeOnlySelection = selectedProviderIds.length > 0
6766
+ && selectedProviderIds.includes('claude')
6767
+ && selectedProviderIds.every((provider) => provider === 'claude' || provider === CLAUDE_API_PROVIDER_TYPE);
6592
6768
  if (claudeOnlySelection) {
6593
6769
  return res.status(400).json({
6594
6770
  ok: false,
@@ -6596,6 +6772,15 @@ export function registerSessionsRoutes(app, deps) {
6596
6772
  code: 'CLAUDE_PROVIDER_REQUIRED',
6597
6773
  });
6598
6774
  }
6775
+ const claudeApiOnlySelection = selectedProviderIds.length > 0
6776
+ && selectedProviderIds.every((provider) => provider === CLAUDE_API_PROVIDER_TYPE);
6777
+ if (claudeApiOnlySelection) {
6778
+ return res.status(400).json({
6779
+ ok: false,
6780
+ error: `Model "${selectedModel}" requires the Claude API provider`,
6781
+ code: 'CLAUDE_API_PROVIDER_REQUIRED',
6782
+ });
6783
+ }
6599
6784
  const cursorOnlySelection = selectedProviders.length > 0
6600
6785
  && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'cursor');
6601
6786
  if (cursorOnlySelection) {
@@ -6624,9 +6809,14 @@ export function registerSessionsRoutes(app, deps) {
6624
6809
  });
6625
6810
  }
6626
6811
  }
6812
+ // No single other provider claims it: a Copilot session with a model
6813
+ // Copilot does not list would be refused at its first send.
6814
+ if (copilotRefusal) return res.status(400).json(copilotRefusal);
6627
6815
 
6628
6816
  const bootstrapProviderType = useClaudeCloudProvider
6629
6817
  ? 'claude-cloud'
6818
+ : useClaudeApiProvider
6819
+ ? CLAUDE_API_PROVIDER_TYPE
6630
6820
  : (useOpenAIProvider
6631
6821
  ? 'openai'
6632
6822
  : (useClaudeProvider
@@ -6657,6 +6847,7 @@ export function registerSessionsRoutes(app, deps) {
6657
6847
  model: selectedModel,
6658
6848
  cursorSettings,
6659
6849
  claudeSettings,
6850
+ claudeApiSettings,
6660
6851
  grokSettings,
6661
6852
  reasoningByModel: modelState?.reasoningByModel || null,
6662
6853
  }),
@@ -6701,7 +6892,10 @@ export function registerSessionsRoutes(app, deps) {
6701
6892
  {
6702
6893
  assignConfiguredProvider: true,
6703
6894
  providerType: bootstrapProviderType,
6704
- providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider || useDeepSeekProvider) ? selectedModel : null,
6895
+ providerModel: (
6896
+ useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider
6897
+ || useDeepSeekProvider || useClaudeApiProvider
6898
+ ) ? selectedModel : null,
6705
6899
  },
6706
6900
  );
6707
6901
  // What the cloud session will clone, stored with the conversation so
@@ -7680,6 +7874,11 @@ export function registerSessionsRoutes(app, deps) {
7680
7874
  // workers of the provider keep the old key and fail each turn after the
7681
7875
  // rotation. Stops them; the primer or the next delivery respawns them with
7682
7876
  // the fresh key from settings.
7877
+ //
7878
+ // The workers are picked by what they run: a worker that states its kind
7879
+ // is taken at its word (a Claude chat switched to the API key may still
7880
+ // have its subscription worker finishing a turn, and one switched back
7881
+ // its API worker); one that does not is taken at its chat's provider.
7683
7882
  async function stopLiveProviderWorkersForKeyRotation(providerType) {
7684
7883
  const rows = db.prepare(`
7685
7884
  SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
@@ -7687,8 +7886,16 @@ export function registerSessionsRoutes(app, deps) {
7687
7886
  WHERE LOWER(COALESCE(provider_type, '')) = ?
7688
7887
  AND NULLIF(sdk_session_id, '') IS NOT NULL
7689
7888
  `).all(providerType);
7889
+ const sids = new Set();
7890
+ for (const worker of sessionWorkerRegistry?.listWorkers?.() || []) {
7891
+ if (worker?.kind === providerType && worker.sdkSessionId) sids.add(worker.sdkSessionId);
7892
+ }
7690
7893
  for (const row of rows) {
7691
7894
  const sid = String(row.sdk_session_id || '').trim();
7895
+ const kind = sid ? (sessionWorkerRegistry?.getWorker?.(sid)?.kind || null) : null;
7896
+ if (sid && (!kind || kind === providerType)) sids.add(sid);
7897
+ }
7898
+ for (const sid of sids) {
7692
7899
  if (!sid) continue;
7693
7900
  const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7694
7901
  if (!workerState) continue;
@@ -7889,6 +8096,74 @@ export function registerSessionsRoutes(app, deps) {
7889
8096
  });
7890
8097
  });
7891
8098
 
8099
+ // --- Claude API ----------------------------------------------------------
8100
+ // The key is written here and never read back: GET and every answer carry
8101
+ // `configured` only. A pasted Claude login token or Admin key is refused by
8102
+ // its prefix. A rotated key reaches a worker only at spawn, so the live
8103
+ // Claude API workers are stopped; the next message starts them again.
8104
+ app.get('/api/settings/claude-api', auth, (_req, res) => {
8105
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8106
+ return res.json(claudeApiSettingsService.describe());
8107
+ });
8108
+
8109
+ app.post('/api/settings/claude-api', auth, async (req, res) => {
8110
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8111
+ const parsed = parseClaudeApiSettingsUpdateRequest(req.body);
8112
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error, code: parsed.code || null });
8113
+ const result = claudeApiSettingsService.update(parsed);
8114
+ if (!result?.ok) {
8115
+ return res.status(result?.code === 'claude-api-key-removal-blocked' ? 409 : 400).json({
8116
+ error: result?.error || 'Failed to update Claude API settings',
8117
+ code: result?.code || null,
8118
+ activeConversationCount: Number(result?.activeConversationCount || 0),
8119
+ startedConversationCount: Number(result?.startedConversationCount || 0),
8120
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
8121
+ });
8122
+ }
8123
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(CLAUDE_API_PROVIDER_TYPE);
8124
+ const discovery = result.discover
8125
+ ? await claudeApiSettingsService.refreshModels()
8126
+ : { ok: true, models: [], error: null };
8127
+ // Removing the key or switching the provider off moves unstarted Claude
8128
+ // API conversations back to the default provider, as for Claude itself.
8129
+ const reconciliationResult = result.removed === true || result.disabled === true
8130
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: '', provider: CLAUDE_API_PROVIDER_TYPE })
8131
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
8132
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
8133
+ const settingsPayload = {
8134
+ ...claudeApiSettingsService.describe(),
8135
+ reconciliation: {
8136
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
8137
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
8138
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
8139
+ failedConversations,
8140
+ },
8141
+ };
8142
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8143
+ io.emit('claude_api_settings_updated', settingsPayload);
8144
+ try { claudeApiStopLedger?.clearAll?.(); } catch {}
8145
+ try { checkClaudeApiCap('settings'); } catch {}
8146
+ // The key is saved whatever discovery said; Anthropic's refusal (a 401,
8147
+ // a key that needs a workspace) is the tab's status until a discovery
8148
+ // succeeds. A list dropped because the key changed meanwhile is no verdict.
8149
+ // A default the fallback list could not keep is named with the refusal.
8150
+ const previousModel = String(result.previousModel || '').trim();
8151
+ const defaultModelChanged = previousModel && settingsPayload.model && previousModel !== settingsPayload.model
8152
+ ? { from: previousModel, to: settingsPayload.model }
8153
+ : null;
8154
+ const discoveryError = result.discover && discovery?.ok === false && discovery?.stale !== true
8155
+ ? { error: discovery.error || 'Claude API model discovery failed', code: discovery.code || null, defaultModelChanged }
8156
+ : null;
8157
+ return res.json({
8158
+ ok: true,
8159
+ ...settingsPayload,
8160
+ discoveryError,
8161
+ warning: failedConversations.length
8162
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
8163
+ : (discovery?.ok ? null : (discovery?.error || 'Claude API model discovery failed')),
8164
+ });
8165
+ });
8166
+
7892
8167
  app.get('/api/settings/turn-ceiling', auth, (_req, res) => {
7893
8168
  res.json({
7894
8169
  ceilingMinutes: getTurnCeilingMinutes(),
@@ -8457,6 +8732,7 @@ export function registerSessionsRoutes(app, deps) {
8457
8732
  const cursorSettings = getCursorProviderSettings();
8458
8733
  const grokSettings = getGrokProviderSettings();
8459
8734
  const deepseekSettings = getDeepSeekProviderSettings();
8735
+ const claudeApiSettings = getClaudeApiProviderSettings();
8460
8736
  return {
8461
8737
  ...buildModelVariantCatalogPayload({
8462
8738
  rows,
@@ -8493,6 +8769,16 @@ export function registerSessionsRoutes(app, deps) {
8493
8769
  enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
8494
8770
  }
8495
8771
  : null,
8772
+ // The Select Models → Claude API tab: its own list, "[1m]" ids included
8773
+ // (the tab shows them as on the Claude tab).
8774
+ claudeApiModels: claudeApiSettings?.enabled === true
8775
+ ? {
8776
+ defaultModel: claudeApiSettings.model,
8777
+ availableModels: Array.isArray(claudeApiSettings.availableModels) ? claudeApiSettings.availableModels : [],
8778
+ enabledModels: Array.isArray(claudeApiSettings.models) ? claudeApiSettings.models : [],
8779
+ discovered: claudeApiSettings.discovered === true,
8780
+ }
8781
+ : null,
8496
8782
  };
8497
8783
  }
8498
8784
 
@@ -8512,6 +8798,7 @@ export function registerSessionsRoutes(app, deps) {
8512
8798
  const cursorSettings = getCursorProviderSettings();
8513
8799
  const grokSettings = getGrokProviderSettings();
8514
8800
  const deepseekSettings = getDeepSeekProviderSettings();
8801
+ const claudeApiSettings = getClaudeApiProviderSettings();
8515
8802
  const refreshTasks = [
8516
8803
  refreshModelVariantCatalogFromCli(),
8517
8804
  openAISettings?.enabled === true
@@ -8535,8 +8822,13 @@ export function registerSessionsRoutes(app, deps) {
8535
8822
  deepseekSettings?.enabled === true && deepseekSettingsService
8536
8823
  ? deepseekSettingsService.refreshModels()
8537
8824
  : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8825
+ claudeApiSettings?.enabled === true && claudeApiSettingsService
8826
+ ? claudeApiSettingsService.refreshModels()
8827
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8538
8828
  ];
8539
- const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh] = await Promise.allSettled(refreshTasks);
8829
+ const [
8830
+ cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh, claudeApiRefresh,
8831
+ ] = await Promise.allSettled(refreshTasks);
8540
8832
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
8541
8833
  const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
8542
8834
  ? openAIRefresh.value
@@ -8573,6 +8865,13 @@ export function registerSessionsRoutes(app, deps) {
8573
8865
  models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
8574
8866
  error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
8575
8867
  };
8868
+ const claudeApiModelDiscovery = claudeApiRefresh.status === 'fulfilled'
8869
+ ? claudeApiRefresh.value
8870
+ : {
8871
+ ok: false,
8872
+ models: Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [],
8873
+ error: claudeApiRefresh.reason?.message || 'Claude API model discovery failed',
8874
+ };
8576
8875
  const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
8577
8876
  ? copilotDiscoveryRefresh.value
8578
8877
  : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
@@ -8587,6 +8886,7 @@ export function registerSessionsRoutes(app, deps) {
8587
8886
  cursorModelDiscovery,
8588
8887
  grokModelDiscovery,
8589
8888
  deepseekModelDiscovery,
8889
+ claudeApiModelDiscovery,
8590
8890
  copilotModelDiscovery,
8591
8891
  });
8592
8892
  } catch (error) {
@@ -8634,6 +8934,7 @@ export function registerSessionsRoutes(app, deps) {
8634
8934
  // stay in models[] (a selection must not vanish) but pickers grey them.
8635
8935
  unavailableModels: Array.isArray(modelState.unavailableModels) ? modelState.unavailableModels : [],
8636
8936
  claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
8937
+ claudeApiContextTiersByModel: modelState.claudeApiContextTiersByModel || {},
8637
8938
  providersByModel: modelState.providersByModel || {},
8638
8939
  stale: modelState.stale,
8639
8940
  metadataValid: modelState.metadataValid === true,
@@ -8684,7 +8985,8 @@ export function registerSessionsRoutes(app, deps) {
8684
8985
  cursorBilling: null,
8685
8986
  deepseekBalance: null,
8686
8987
  };
8687
- const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek'];
8988
+ // `claude-api`'s card is read from the relay's own books on every answer.
8989
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek', 'claude-api'];
8688
8990
 
8689
8991
  function parseUsageProviders(raw) {
8690
8992
  const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
@@ -8803,6 +9105,14 @@ export function registerSessionsRoutes(app, deps) {
8803
9105
  }
8804
9106
  }
8805
9107
 
9108
+ // Built from the database on every answer, so it is always live.
9109
+ let claudeApiCard = null;
9110
+ try {
9111
+ claudeApiCard = claudeApiPlanUsage?.buildCard?.() || null;
9112
+ } catch {
9113
+ claudeApiCard = null;
9114
+ }
9115
+
8806
9116
  const report = planUsageService.buildReport({
8807
9117
  copilotSummary: copilot.summary,
8808
9118
  copilotError: copilot.error,
@@ -8829,6 +9139,7 @@ export function registerSessionsRoutes(app, deps) {
8829
9139
  ? { amount: deepseekSettings.startingBalance, setAt: deepseekSettings.startingBalanceSetAt }
8830
9140
  : null,
8831
9141
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9142
+ claudeApiCard,
8832
9143
  });
8833
9144
 
8834
9145
  // Legacy top-level fields stay alongside `providers` so an older cached
@@ -8844,6 +9155,7 @@ export function registerSessionsRoutes(app, deps) {
8844
9155
  cursor: cursorEntry.at,
8845
9156
  grok: grokEntry.at,
8846
9157
  deepseek: deepseekEntry.value?.fetchedAt || deepseekEntry.at,
9158
+ 'claude-api': nowIso(),
8847
9159
  },
8848
9160
  });
8849
9161
  });