@oar-sh/oar 0.9.17 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  2. package/CHANGELOG.md +192 -0
  3. package/README.md +13 -12
  4. package/package.json +1 -1
  5. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  6. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  7. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  9. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  10. package/server/claude-worker/claude-session-process.mjs +166 -21
  11. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  12. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  13. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  14. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  15. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  16. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  19. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  20. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  21. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  22. package/server/db-schema.mjs +33 -1
  23. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  24. package/server/grok-worker/acp-client.mjs +1 -1
  25. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  26. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  27. package/server/mcp/oar-mcp-server.mjs +11 -5
  28. package/server/migrations/0031-queue-provider.mjs +143 -0
  29. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  30. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  31. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  32. package/server/public/app/activity-replay-state.mjs +4 -3
  33. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  34. package/server/public/app/agent-sessions-settings.mjs +1 -1
  35. package/server/public/app/api-client.js +52 -0
  36. package/server/public/app/bootstrap.js +153 -27
  37. package/server/public/app/claude-api-settings-ui.js +362 -0
  38. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  39. package/server/public/app/claude-billing-toggle.mjs +112 -0
  40. package/server/public/app/context-tier-options.mjs +6 -2
  41. package/server/public/app/context-usage-view.mjs +1 -1
  42. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  43. package/server/public/app/conversation-view.js +29 -3
  44. package/server/public/app/inbox-view.mjs +1 -1
  45. package/server/public/app/journal-view.js +127 -10
  46. package/server/public/app/model-selector-options.mjs +17 -1
  47. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  48. package/server/public/app/provider-readiness.mjs +5 -2
  49. package/server/public/app/relay-error-ctas.mjs +50 -0
  50. package/server/public/app/relay-schedules-ui.js +1 -1
  51. package/server/public/app/settings-modal.js +11 -0
  52. package/server/public/app/settings-tabs.js +1 -1
  53. package/server/public/app/socket-handlers.js +18 -1
  54. package/server/public/app/tool-activity-emoji.mjs +4 -3
  55. package/server/public/app/usage-limit-ui.js +136 -5
  56. package/server/public/index.html +189 -3
  57. package/server/relay-tools.md +4 -4
  58. package/server/repositories/conversation-group-repository.mjs +1 -1
  59. package/server/repositories/message-repository.mjs +7 -0
  60. package/server/repositories/session-repository.mjs +8 -1
  61. package/server/routes/ask-user-routes.mjs +1 -1
  62. package/server/routes/conversation-group-routes.mjs +1 -1
  63. package/server/routes/messages-routes.mjs +645 -27
  64. package/server/routes/relay-ask-routes.mjs +3 -3
  65. package/server/routes/relay-event-routes.mjs +2 -2
  66. package/server/routes/relay-schedule-routes.mjs +1 -1
  67. package/server/routes/remote-relay-routes.mjs +3 -3
  68. package/server/routes/sessions-routes.mjs +327 -29
  69. package/server/server-runtime.mjs +363 -26
  70. package/server/services/about-service.mjs +1 -0
  71. package/server/services/background-work-view.mjs +2 -1
  72. package/server/services/claude-api-settings-service.mjs +575 -0
  73. package/server/services/claude-api-usage-service.mjs +361 -0
  74. package/server/services/claude-billing-swap-service.mjs +274 -0
  75. package/server/services/claude-billing-switch-service.mjs +343 -0
  76. package/server/services/plan-usage-claude-api.mjs +445 -0
  77. package/server/services/plan-usage-service.mjs +5 -0
  78. package/server/services/provider-model-selection.mjs +3 -2
  79. package/server/services/provider-reasoning-effort.mjs +9 -0
  80. package/server/services/relay-ask-service.mjs +1 -1
  81. package/server/services/relay-event-service.mjs +3 -2
  82. package/server/services/relay-schedule-service.mjs +1 -1
  83. package/server/services/remote-relay-caller-context.mjs +7 -0
  84. package/server/services/remote-relay-dispatcher.mjs +83 -14
  85. package/server/services/remote-relay-registry-service.mjs +1 -1
  86. package/server/services/session-worker-launch-service.mjs +61 -2
  87. package/server/services/session-worker-registry-service.mjs +28 -1
  88. package/server/services/session-worker-websocket-service.mjs +17 -0
  89. package/server/services/usage-limit-pause-service.mjs +55 -4
  90. package/server/services/worker-secret-redaction.mjs +2 -0
  91. package/shared/anthropic-api.mjs +202 -0
  92. package/shared/provider-routing.mjs +5 -3
  93. package/shared/relay-asks.mjs +11 -5
  94. package/shared/relay-events.mjs +2 -2
  95. package/shared/relay-schedules.mjs +2 -2
  96. package/shared/remote-relay-contract.mjs +29 -9
  97. package/shared/remote-relay-tool-core.mjs +23 -6
  98. package/shared/steer-settle-failure.mjs +1 -0
  99. package/shared/worker-runtime/api-client.mjs +2 -2
  100. package/shared/worker-runtime/heartbeat.mjs +5 -0
  101. 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,
@@ -855,6 +860,82 @@ export function buildModelCatalogWithDeepSeekProvider(modelState = {}, deepseekS
855
860
  };
856
861
  }
857
862
 
863
+ /**
864
+ * Claude API (provider `claude-api`) serves the Claude provider's model ids
865
+ * on an API key. Its layer tags them `claude-api` beside whatever else serves
866
+ * them and keeps its own efforts and context tiers: `[1m]` variants fold into
867
+ * their base model as a long_context tier, as on the Claude layer. An id is
868
+ * never enough to pick this provider; only an explicit `claude-api` does.
869
+ */
870
+ export function buildModelCatalogWithClaudeApiProvider(modelState = {}, claudeApiSettings = {}) {
871
+ const configured = claudeApiSettings?.enabled === true;
872
+ const model = String(claudeApiSettings?.model || '').trim();
873
+ if (!configured || !model) return { ...modelState };
874
+ const listed = Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [];
875
+ const longContextBases = new Set();
876
+ const defaultContextBases = new Set();
877
+ const baseModels = [];
878
+ const seenBases = new Set();
879
+ for (const candidate of [model, ...listed]) {
880
+ const modelId = String(candidate || '').trim();
881
+ if (!modelId) continue;
882
+ const isLongContextVariant = CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN.test(modelId);
883
+ const baseId = modelId.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '');
884
+ if (!baseId) continue;
885
+ if (isLongContextVariant) longContextBases.add(baseId.toLowerCase());
886
+ else defaultContextBases.add(baseId.toLowerCase());
887
+ if (seenBases.has(baseId.toLowerCase())) continue;
888
+ seenBases.add(baseId.toLowerCase());
889
+ baseModels.push(baseId);
890
+ }
891
+ const models = Array.from(new Set([...(Array.isArray(modelState?.models) ? modelState.models : []), ...baseModels]));
892
+ const reasoningByModel = { ...(modelState?.reasoningByModel || {}) };
893
+ const claudeApiReasoningByModel = {};
894
+ const modelMetadataByModel = { ...(modelState?.modelMetadataByModel || {}) };
895
+ const providersByModel = { ...(modelState?.providersByModel || {}) };
896
+ const claudeApiContextTiersByModel = {};
897
+ const effortsByModel = claudeApiSettings?.effortsByModel && typeof claudeApiSettings.effortsByModel === 'object'
898
+ ? claudeApiSettings.effortsByModel
899
+ : {};
900
+ for (const baseId of baseModels) {
901
+ const lowerKey = baseId.toLowerCase();
902
+ claudeApiContextTiersByModel[lowerKey] = [
903
+ ...(defaultContextBases.has(lowerKey) ? [{ value: 'default' }] : []),
904
+ ...(longContextBases.has(lowerKey) ? [{ value: 'long_context' }] : []),
905
+ ];
906
+ const efforts = Array.isArray(effortsByModel[lowerKey]) && effortsByModel[lowerKey].length
907
+ ? effortsByModel[lowerKey]
908
+ : effortsByModel[`${lowerKey}[1m]`];
909
+ const claudeApiEfforts = Array.isArray(efforts) && efforts.length
910
+ ? withClaudeUltracodeTier(efforts)
911
+ : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
912
+ claudeApiReasoningByModel[lowerKey] = [...claudeApiEfforts];
913
+ if (!Array.isArray(reasoningByModel[lowerKey]) || reasoningByModel[lowerKey].length === 0) {
914
+ reasoningByModel[lowerKey] = [...claudeApiEfforts];
915
+ }
916
+ const existingProviders = Array.isArray(providersByModel[baseId])
917
+ ? providersByModel[baseId]
918
+ : (Array.isArray(providersByModel[lowerKey]) ? providersByModel[lowerKey] : []);
919
+ providersByModel[baseId] = Array.from(new Set([...existingProviders, CLAUDE_API_PROVIDER_TYPE]));
920
+ modelMetadataByModel[baseId] = {
921
+ ...(modelMetadataByModel[baseId] || {}),
922
+ provider: modelMetadataByModel[baseId]?.provider || CLAUDE_API_PROVIDER_TYPE,
923
+ };
924
+ }
925
+ return {
926
+ ...modelState,
927
+ models,
928
+ reasoningByModel,
929
+ reasoningByProvider: {
930
+ ...(modelState?.reasoningByProvider || {}),
931
+ [CLAUDE_API_PROVIDER_TYPE]: claudeApiReasoningByModel,
932
+ },
933
+ modelMetadataByModel,
934
+ providersByModel,
935
+ claudeApiContextTiersByModel,
936
+ };
937
+ }
938
+
858
939
  function normalizeRequestedProviderType(value = '') {
859
940
  const normalized = String(value || '').trim().toLowerCase();
860
941
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -865,6 +946,8 @@ function normalizeRequestedProviderType(value = '') {
865
946
  if (normalized === 'cursor') return 'cursor';
866
947
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
867
948
  if (normalized === 'deepseek') return 'deepseek';
949
+ // Its own provider, never read as `claude`.
950
+ if (normalized === 'claude-api') return 'claude-api';
868
951
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
869
952
  return '';
870
953
  }
@@ -1506,9 +1589,10 @@ export function buildConversationSessionRootPayload({
1506
1589
  }
1507
1590
  // Claude sessions have no ~/.copilot/session-state entry — only the Copilot
1508
1591
  // CLI creates those — so they resolve against the Agent SDK's own layout and
1509
- // never fall through to the branch below.
1592
+ // never fall through to the branch below. A Claude API session is a Claude
1593
+ // session on an API key, in the same place.
1510
1594
  const claudeCodeProvider = String(providerType || '').trim().toLowerCase();
1511
- if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek') {
1595
+ if (claudeCodeProvider === 'claude' || claudeCodeProvider === 'deepseek' || claudeCodeProvider === 'claude-api') {
1512
1596
  const nativeSessionId = String(claudeNativeSessionId || '').trim();
1513
1597
  const resolveRoot = claudeCodeProvider === 'deepseek' ? resolveDeepSeekSessionRoot : resolveClaudeSessionRoot;
1514
1598
  if (!nativeSessionId || typeof resolveRoot !== 'function') return null;
@@ -2138,6 +2222,20 @@ export function registerSessionsRoutes(app, deps) {
2138
2222
  // attribution (services/deepseek-settings-service.mjs). Absent in older
2139
2223
  // wiring and most tests, where the provider is simply off.
2140
2224
  deepseekSettingsService = null,
2225
+ // Claude API (provider `claude-api`): its key, models and monthly cap
2226
+ // (services/claude-api-settings-service.mjs). Absent in older wiring and
2227
+ // most tests, where the provider is simply off.
2228
+ claudeApiSettingsService = null,
2229
+ // The Claude API card and monthly cap (services/plan-usage-claude-api.mjs),
2230
+ // and the cap's one seam (`{ reached, error? }`; a throw counts as
2231
+ // reached). Absent in older wiring and most tests: no card, no cap.
2232
+ claudeApiPlanUsage = null,
2233
+ getClaudeApiCapState = () => ({ reached: false }),
2234
+ // Asks whether the cap flipped after the Claude API settings changed.
2235
+ checkClaudeApiCap = () => {},
2236
+ // The once-per-episode notes for stopped self-started turns on the key
2237
+ // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2238
+ claudeApiStopLedger = null,
2141
2239
  reconcileUnstartedConversationProviders = async () => ({
2142
2240
  updatedUnstartedConversations: 0,
2143
2241
  skippedStartedConversations: 0,
@@ -2248,6 +2346,7 @@ export function registerSessionsRoutes(app, deps) {
2248
2346
  hostSuspendService = null,
2249
2347
  collectHostActivity = null,
2250
2348
  usageLimitPauseService = null,
2349
+ claudeBillingSwitchService = null,
2251
2350
  hostSuspendPlatform = process.platform,
2252
2351
  // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2253
2352
  // conversations another relay's agent created, and the inbound switch.
@@ -2504,20 +2603,28 @@ export function registerSessionsRoutes(app, deps) {
2504
2603
  || { configured: false, enabled: false, model: DEFAULT_DEEPSEEK_MODEL, models: [], availableModels: [] };
2505
2604
  }
2506
2605
 
2606
+ function getClaudeApiProviderSettings() {
2607
+ return claudeApiSettingsService?.getSettings?.()
2608
+ || { configured: false, enabled: false, model: '', models: [], availableModels: [], effortsByModel: {} };
2609
+ }
2610
+
2507
2611
  // Layer every enabled provider's models onto the base Copilot catalog.
2508
2612
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2509
- return buildModelCatalogWithDeepSeekProvider(
2510
- buildModelCatalogWithGrokProvider(
2511
- buildModelCatalogWithCursorProvider(
2512
- buildModelCatalogWithClaudeProvider(
2513
- buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2514
- getClaudeProviderSettings(),
2613
+ return buildModelCatalogWithClaudeApiProvider(
2614
+ buildModelCatalogWithDeepSeekProvider(
2615
+ buildModelCatalogWithGrokProvider(
2616
+ buildModelCatalogWithCursorProvider(
2617
+ buildModelCatalogWithClaudeProvider(
2618
+ buildModelCatalogWithOpenAIProvider(modelState, openAISettings),
2619
+ getClaudeProviderSettings(),
2620
+ ),
2621
+ getCursorProviderSettings(),
2515
2622
  ),
2516
- getCursorProviderSettings(),
2623
+ getGrokProviderSettings(),
2517
2624
  ),
2518
- getGrokProviderSettings(),
2625
+ getDeepSeekProviderSettings(),
2519
2626
  ),
2520
- getDeepSeekProviderSettings(),
2627
+ getClaudeApiProviderSettings(),
2521
2628
  );
2522
2629
  }
2523
2630
 
@@ -2708,7 +2815,7 @@ export function registerSessionsRoutes(app, deps) {
2708
2815
  async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
2709
2816
  const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2710
2817
  try {
2711
- if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2818
+ if (provider === 'claude' || provider === 'claude-api') return deleteClaudeCliSession({ conversation, runtimeSession });
2712
2819
  if (provider === 'deepseek') return deleteDeepSeekCliSession({ conversation, runtimeSession });
2713
2820
  if (!sdkSessionId) return { deleted: null };
2714
2821
  if (!provider || provider === 'github' || provider === 'openai') {
@@ -2782,7 +2889,7 @@ export function registerSessionsRoutes(app, deps) {
2782
2889
  /**
2783
2890
  * The `group` field of a single conversation payload: the summary and the
2784
2891
  * 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)
2892
+ * when asked for (`groupMembers=1`, the relay_tool's read_session)
2786
2893
  * and never for a paired relay's agent: the browser does not read them,
2787
2894
  * and archived members' titles stay on this relay.
2788
2895
  */
@@ -3644,9 +3751,11 @@ export function registerSessionsRoutes(app, deps) {
3644
3751
  // The cursor and grok workers post the same Claude-shaped usage blob, and
3645
3752
  // so does the Claude Cloud worker (the cloud session's context and token
3646
3753
  // usage), and so does the DeepSeek worker (the Claude one, pointed at
3647
- // DeepSeek), so all five providers share the stored reader.
3754
+ // DeepSeek), and so does the Claude API one (the Claude worker on an API
3755
+ // key), so all six providers share the stored reader.
3648
3756
  const usesStoredContextUsage = providerType === 'claude' || providerType === 'cursor'
3649
- || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek';
3757
+ || providerType === 'grok' || providerType === 'claude-cloud' || providerType === 'deepseek'
3758
+ || providerType === 'claude-api';
3650
3759
 
3651
3760
  const parsed = usesStoredContextUsage
3652
3761
  ? (() => {
@@ -3675,12 +3784,12 @@ export function registerSessionsRoutes(app, deps) {
3675
3784
  String(runtimeSession?.conversation_id || '').trim() || lookupId,
3676
3785
  );
3677
3786
  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.
3787
+ // Only the local Claude worker (and the DeepSeek and Claude API ones, the
3788
+ // same runner) runs a CLI whose window the slider sets; it reports that
3789
+ // CLI on its heartbeat's steering snapshot.
3681
3790
  const windowStatus = resolveAutoCompactWindowStatus({
3682
3791
  storedWindow: autoCompactWindow,
3683
- steering: (providerType === 'claude' || providerType === 'deepseek') && copilotSessionId
3792
+ steering: (providerType === 'claude' || providerType === 'deepseek' || providerType === 'claude-api') && copilotSessionId
3684
3793
  ? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
3685
3794
  : null,
3686
3795
  });
@@ -3700,7 +3809,7 @@ export function registerSessionsRoutes(app, deps) {
3700
3809
  // What this folder's Claude commits say (the 🧠 modal's override row).
3701
3810
  // A Claude Cloud chat has the row when it was started from a folder,
3702
3811
  // with its own model in the example.
3703
- ...(providerType === 'claude'
3812
+ ...(providerType === 'claude' || providerType === 'claude-api'
3704
3813
  ? { attribution: workspaceRootAttributionFor(conversationRow) }
3705
3814
  : {}),
3706
3815
  ...(providerType === 'claude-cloud' && cloudConversationFolder(conversationRow)
@@ -4728,6 +4837,9 @@ export function registerSessionsRoutes(app, deps) {
4728
4837
  try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
4729
4838
  })(),
4730
4839
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4840
+ // A Claude chat's billing (subscription or API key) and whether the
4841
+ // composer's toggle may switch it now, with the reason when not.
4842
+ billing: claudeBillingSwitchService?.billingState?.(resolvedConversationId) || null,
4731
4843
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4732
4844
  // Every pin of the conversation, also those outside this history page.
4733
4845
  pins: listConversationPins(resolvedConversationId),
@@ -5014,7 +5126,7 @@ export function registerSessionsRoutes(app, deps) {
5014
5126
  return res.json({ ok: true, ...persisted });
5015
5127
  });
5016
5128
 
5017
- // ─── PATCH /api/conversation/:id/settings — the remote_relay tool's update_session ───
5129
+ // ─── PATCH /api/conversation/:id/settings — the relay_tool's update_session ───
5018
5130
  //
5019
5131
  // `{ settings: { model, effort, mode, auto_compact_window, title, group,
5020
5132
  // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
@@ -5135,7 +5247,7 @@ export function registerSessionsRoutes(app, deps) {
5135
5247
  plan.preferences.thinkingDisplay = wanted.thinking_display;
5136
5248
  change('thinking_display');
5137
5249
  }
5138
- if (provider !== 'claude' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5250
+ if (provider !== 'claude' && provider !== 'claude-api' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5139
5251
  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
5252
  }
5141
5253
  if (wanted.title !== undefined) {
@@ -6267,6 +6379,35 @@ export function registerSessionsRoutes(app, deps) {
6267
6379
  environmentId: cloudEnvironmentId,
6268
6380
  });
6269
6381
  }
6382
+ // Claude API is asked for by name only, like Claude Cloud: its model ids
6383
+ // are the Claude provider's own, so a model never selects it. Refused
6384
+ // first while it has no key or is switched off.
6385
+ const useClaudeApiProvider = requestedProviderType === CLAUDE_API_PROVIDER_TYPE;
6386
+ const claudeApiSettings = useClaudeApiProvider ? getClaudeApiProviderSettings() : null;
6387
+ if (useClaudeApiProvider && claudeApiSettings?.enabled !== true) {
6388
+ return res.status(400).json({
6389
+ ok: false,
6390
+ error: claudeApiSettings?.configured === true
6391
+ ? 'Claude API is switched off for new conversations.'
6392
+ : 'Claude API key is not configured.',
6393
+ code: 'CLAUDE_API_NOT_CONFIGURED',
6394
+ });
6395
+ }
6396
+ // This month's cap (plan decision 12): no new Claude API chat while it is
6397
+ // reached, for New Chat and an agent's create_session alike. A cap that
6398
+ // cannot be read counts as reached.
6399
+ if (useClaudeApiProvider) {
6400
+ let cap = null;
6401
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
6402
+ if (cap?.reached === true) {
6403
+ return res.status(409).json({
6404
+ ok: false,
6405
+ // The remedy names the place, so the reason does not.
6406
+ 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.`,
6407
+ code: 'CLAUDE_API_CAP_REACHED',
6408
+ });
6409
+ }
6410
+ }
6270
6411
  const modelState = buildModelCatalogWithProviders(
6271
6412
  getModelCatalogState(),
6272
6413
  openAISettings,
@@ -6312,6 +6453,13 @@ export function registerSessionsRoutes(app, deps) {
6312
6453
  ...(Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : []),
6313
6454
  ].map((value) => String(value || '').trim()).filter(Boolean);
6314
6455
  const availableDeepSeekModels = new Set(deepseekModelCatalog.map((value) => value.toLowerCase()));
6456
+ // Base ids of "[1m]" variants count too, as for the Claude provider.
6457
+ const availableClaudeApiModels = new Set((useClaudeApiProvider ? [
6458
+ String(claudeApiSettings?.model || '').trim(),
6459
+ ...(Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : []),
6460
+ ] : []).map((value) => String(value || '').trim()).filter(Boolean)
6461
+ .flatMap((value) => [value, value.replace(CLAUDE_LONG_CONTEXT_SUFFIX_PATTERN, '')])
6462
+ .filter(Boolean));
6315
6463
  const useOpenAIProvider = requestedProviderType === 'openai'
6316
6464
  || requestedProviderType === 'openai-image'
6317
6465
  || (
@@ -6376,6 +6524,12 @@ export function registerSessionsRoutes(app, deps) {
6376
6524
  configuredModel: claudeCloudSettings.defaultModel,
6377
6525
  availableModels: Array.isArray(claudeCloudSettings.models) ? claudeCloudSettings.models : [],
6378
6526
  })
6527
+ : useClaudeApiProvider
6528
+ ? resolveProviderModelSelection({
6529
+ requestedModel: providerRequestedModel(availableClaudeApiModels),
6530
+ configuredModel: claudeApiSettings.model,
6531
+ availableModels: availableClaudeApiModels,
6532
+ })
6379
6533
  : useOpenAIProvider
6380
6534
  ? resolveProviderModelSelection({
6381
6535
  requestedModel: providerRequestedModel(availableOpenAIModels),
@@ -6414,11 +6568,15 @@ export function registerSessionsRoutes(app, deps) {
6414
6568
  if (providerModelSelection && !providerModelSelection.ok) {
6415
6569
  const providerLabel = useClaudeCloudProvider
6416
6570
  ? 'Claude Cloud'
6571
+ : useClaudeApiProvider
6572
+ ? CLAUDE_API_PROVIDER_LABEL
6417
6573
  : (useOpenAIProvider
6418
6574
  ? 'OpenAI'
6419
6575
  : (useClaudeProvider ? 'Claude' : (useCursorProvider ? 'Cursor' : (useGrokProvider ? 'Grok' : 'DeepSeek'))));
6420
6576
  const providerCode = useClaudeCloudProvider
6421
6577
  ? 'CLAUDE_CLOUD_MODEL_UNAVAILABLE'
6578
+ : useClaudeApiProvider
6579
+ ? 'CLAUDE_API_MODEL_UNAVAILABLE'
6422
6580
  : (useOpenAIProvider
6423
6581
  ? 'OPENAI_MODEL_UNAVAILABLE'
6424
6582
  : (useClaudeProvider
@@ -6445,7 +6603,7 @@ export function registerSessionsRoutes(app, deps) {
6445
6603
  if (
6446
6604
  remoteRequest.remote
6447
6605
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6448
- && !useDeepSeekProvider
6606
+ && !useDeepSeekProvider && !useClaudeApiProvider
6449
6607
  ) {
6450
6608
  const mergedModels = Array.isArray(modelState?.models) ? modelState.models : [];
6451
6609
  const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
@@ -6574,7 +6732,10 @@ export function registerSessionsRoutes(app, deps) {
6574
6732
  code: 'OPENAI_IMAGE_MODEL_REQUIRED',
6575
6733
  });
6576
6734
  }
6577
- if (!useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider && !useDeepSeekProvider) {
6735
+ if (
6736
+ !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
6737
+ && !useDeepSeekProvider && !useClaudeApiProvider
6738
+ ) {
6578
6739
  const selectedProviders = Array.isArray(modelState?.providersByModel?.[String(selectedModel || '').trim().toLowerCase()])
6579
6740
  ? modelState.providersByModel[String(selectedModel || '').trim().toLowerCase()]
6580
6741
  : [];
@@ -6587,8 +6748,12 @@ export function registerSessionsRoutes(app, deps) {
6587
6748
  code: 'OPENAI_PROVIDER_REQUIRED',
6588
6749
  });
6589
6750
  }
6590
- const claudeOnlySelection = selectedProviders.length > 0
6591
- && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'claude');
6751
+ // Claude API serves the Claude provider's ids: a model both list is
6752
+ // still Claude's, and one only Claude API lists names that provider.
6753
+ const selectedProviderIds = selectedProviders.map((provider) => String(provider || '').trim().toLowerCase());
6754
+ const claudeOnlySelection = selectedProviderIds.length > 0
6755
+ && selectedProviderIds.includes('claude')
6756
+ && selectedProviderIds.every((provider) => provider === 'claude' || provider === CLAUDE_API_PROVIDER_TYPE);
6592
6757
  if (claudeOnlySelection) {
6593
6758
  return res.status(400).json({
6594
6759
  ok: false,
@@ -6596,6 +6761,15 @@ export function registerSessionsRoutes(app, deps) {
6596
6761
  code: 'CLAUDE_PROVIDER_REQUIRED',
6597
6762
  });
6598
6763
  }
6764
+ const claudeApiOnlySelection = selectedProviderIds.length > 0
6765
+ && selectedProviderIds.every((provider) => provider === CLAUDE_API_PROVIDER_TYPE);
6766
+ if (claudeApiOnlySelection) {
6767
+ return res.status(400).json({
6768
+ ok: false,
6769
+ error: `Model "${selectedModel}" requires the Claude API provider`,
6770
+ code: 'CLAUDE_API_PROVIDER_REQUIRED',
6771
+ });
6772
+ }
6599
6773
  const cursorOnlySelection = selectedProviders.length > 0
6600
6774
  && selectedProviders.every((provider) => String(provider || '').trim().toLowerCase() === 'cursor');
6601
6775
  if (cursorOnlySelection) {
@@ -6627,6 +6801,8 @@ export function registerSessionsRoutes(app, deps) {
6627
6801
 
6628
6802
  const bootstrapProviderType = useClaudeCloudProvider
6629
6803
  ? 'claude-cloud'
6804
+ : useClaudeApiProvider
6805
+ ? CLAUDE_API_PROVIDER_TYPE
6630
6806
  : (useOpenAIProvider
6631
6807
  ? 'openai'
6632
6808
  : (useClaudeProvider
@@ -6657,6 +6833,7 @@ export function registerSessionsRoutes(app, deps) {
6657
6833
  model: selectedModel,
6658
6834
  cursorSettings,
6659
6835
  claudeSettings,
6836
+ claudeApiSettings,
6660
6837
  grokSettings,
6661
6838
  reasoningByModel: modelState?.reasoningByModel || null,
6662
6839
  }),
@@ -6701,7 +6878,10 @@ export function registerSessionsRoutes(app, deps) {
6701
6878
  {
6702
6879
  assignConfiguredProvider: true,
6703
6880
  providerType: bootstrapProviderType,
6704
- providerModel: (useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider || useDeepSeekProvider) ? selectedModel : null,
6881
+ providerModel: (
6882
+ useClaudeCloudProvider || useOpenAIProvider || useClaudeProvider || useCursorProvider || useGrokProvider
6883
+ || useDeepSeekProvider || useClaudeApiProvider
6884
+ ) ? selectedModel : null,
6705
6885
  },
6706
6886
  );
6707
6887
  // What the cloud session will clone, stored with the conversation so
@@ -7680,6 +7860,11 @@ export function registerSessionsRoutes(app, deps) {
7680
7860
  // workers of the provider keep the old key and fail each turn after the
7681
7861
  // rotation. Stops them; the primer or the next delivery respawns them with
7682
7862
  // the fresh key from settings.
7863
+ //
7864
+ // The workers are picked by what they run: a worker that states its kind
7865
+ // is taken at its word (a Claude chat switched to the API key may still
7866
+ // have its subscription worker finishing a turn, and one switched back
7867
+ // its API worker); one that does not is taken at its chat's provider.
7683
7868
  async function stopLiveProviderWorkersForKeyRotation(providerType) {
7684
7869
  const rows = db.prepare(`
7685
7870
  SELECT DISTINCT NULLIF(sdk_session_id, '') AS sdk_session_id
@@ -7687,8 +7872,16 @@ export function registerSessionsRoutes(app, deps) {
7687
7872
  WHERE LOWER(COALESCE(provider_type, '')) = ?
7688
7873
  AND NULLIF(sdk_session_id, '') IS NOT NULL
7689
7874
  `).all(providerType);
7875
+ const sids = new Set();
7876
+ for (const worker of sessionWorkerRegistry?.listWorkers?.() || []) {
7877
+ if (worker?.kind === providerType && worker.sdkSessionId) sids.add(worker.sdkSessionId);
7878
+ }
7690
7879
  for (const row of rows) {
7691
7880
  const sid = String(row.sdk_session_id || '').trim();
7881
+ const kind = sid ? (sessionWorkerRegistry?.getWorker?.(sid)?.kind || null) : null;
7882
+ if (sid && (!kind || kind === providerType)) sids.add(sid);
7883
+ }
7884
+ for (const sid of sids) {
7692
7885
  if (!sid) continue;
7693
7886
  const workerState = sessionWorkerSupervisor?.getWorkerState?.(sid) || null;
7694
7887
  if (!workerState) continue;
@@ -7889,6 +8082,74 @@ export function registerSessionsRoutes(app, deps) {
7889
8082
  });
7890
8083
  });
7891
8084
 
8085
+ // --- Claude API ----------------------------------------------------------
8086
+ // The key is written here and never read back: GET and every answer carry
8087
+ // `configured` only. A pasted Claude login token or Admin key is refused by
8088
+ // its prefix. A rotated key reaches a worker only at spawn, so the live
8089
+ // Claude API workers are stopped; the next message starts them again.
8090
+ app.get('/api/settings/claude-api', auth, (_req, res) => {
8091
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8092
+ return res.json(claudeApiSettingsService.describe());
8093
+ });
8094
+
8095
+ app.post('/api/settings/claude-api', auth, async (req, res) => {
8096
+ if (!claudeApiSettingsService) return res.status(501).json({ error: 'Claude API settings are unavailable' });
8097
+ const parsed = parseClaudeApiSettingsUpdateRequest(req.body);
8098
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error, code: parsed.code || null });
8099
+ const result = claudeApiSettingsService.update(parsed);
8100
+ if (!result?.ok) {
8101
+ return res.status(result?.code === 'claude-api-key-removal-blocked' ? 409 : 400).json({
8102
+ error: result?.error || 'Failed to update Claude API settings',
8103
+ code: result?.code || null,
8104
+ activeConversationCount: Number(result?.activeConversationCount || 0),
8105
+ startedConversationCount: Number(result?.startedConversationCount || 0),
8106
+ activeQueueConversationCount: Number(result?.activeQueueConversationCount || 0),
8107
+ });
8108
+ }
8109
+ if (result.keyChanged) await stopLiveProviderWorkersForKeyRotation(CLAUDE_API_PROVIDER_TYPE);
8110
+ const discovery = result.discover
8111
+ ? await claudeApiSettingsService.refreshModels()
8112
+ : { ok: true, models: [], error: null };
8113
+ // Removing the key or switching the provider off moves unstarted Claude
8114
+ // API conversations back to the default provider, as for Claude itself.
8115
+ const reconciliationResult = result.removed === true || result.disabled === true
8116
+ ? await reconcileUnstartedConversationProviders({ enabled: false, model: '', provider: CLAUDE_API_PROVIDER_TYPE })
8117
+ : { updatedUnstartedConversations: 0, skippedStartedConversations: 0, skippedActiveQueueConversations: 0, failedConversations: [] };
8118
+ const failedConversations = Array.isArray(reconciliationResult?.failedConversations) ? reconciliationResult.failedConversations : [];
8119
+ const settingsPayload = {
8120
+ ...claudeApiSettingsService.describe(),
8121
+ reconciliation: {
8122
+ updatedUnstartedConversations: Number(reconciliationResult?.updatedUnstartedConversations || 0),
8123
+ skippedStartedConversations: Number(reconciliationResult?.skippedStartedConversations || 0),
8124
+ skippedActiveQueueConversations: Number(reconciliationResult?.skippedActiveQueueConversations || 0),
8125
+ failedConversations,
8126
+ },
8127
+ };
8128
+ io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8129
+ io.emit('claude_api_settings_updated', settingsPayload);
8130
+ try { claudeApiStopLedger?.clearAll?.(); } catch {}
8131
+ try { checkClaudeApiCap('settings'); } catch {}
8132
+ // The key is saved whatever discovery said; Anthropic's refusal (a 401,
8133
+ // a key that needs a workspace) is the tab's status until a discovery
8134
+ // succeeds. A list dropped because the key changed meanwhile is no verdict.
8135
+ // A default the fallback list could not keep is named with the refusal.
8136
+ const previousModel = String(result.previousModel || '').trim();
8137
+ const defaultModelChanged = previousModel && settingsPayload.model && previousModel !== settingsPayload.model
8138
+ ? { from: previousModel, to: settingsPayload.model }
8139
+ : null;
8140
+ const discoveryError = result.discover && discovery?.ok === false && discovery?.stale !== true
8141
+ ? { error: discovery.error || 'Claude API model discovery failed', code: discovery.code || null, defaultModelChanged }
8142
+ : null;
8143
+ return res.json({
8144
+ ok: true,
8145
+ ...settingsPayload,
8146
+ discoveryError,
8147
+ warning: failedConversations.length
8148
+ ? `${failedConversations.length} unstarted conversation(s) could not switch provider.`
8149
+ : (discovery?.ok ? null : (discovery?.error || 'Claude API model discovery failed')),
8150
+ });
8151
+ });
8152
+
7892
8153
  app.get('/api/settings/turn-ceiling', auth, (_req, res) => {
7893
8154
  res.json({
7894
8155
  ceilingMinutes: getTurnCeilingMinutes(),
@@ -8457,6 +8718,7 @@ export function registerSessionsRoutes(app, deps) {
8457
8718
  const cursorSettings = getCursorProviderSettings();
8458
8719
  const grokSettings = getGrokProviderSettings();
8459
8720
  const deepseekSettings = getDeepSeekProviderSettings();
8721
+ const claudeApiSettings = getClaudeApiProviderSettings();
8460
8722
  return {
8461
8723
  ...buildModelVariantCatalogPayload({
8462
8724
  rows,
@@ -8493,6 +8755,16 @@ export function registerSessionsRoutes(app, deps) {
8493
8755
  enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
8494
8756
  }
8495
8757
  : null,
8758
+ // The Select Models → Claude API tab: its own list, "[1m]" ids included
8759
+ // (the tab shows them as on the Claude tab).
8760
+ claudeApiModels: claudeApiSettings?.enabled === true
8761
+ ? {
8762
+ defaultModel: claudeApiSettings.model,
8763
+ availableModels: Array.isArray(claudeApiSettings.availableModels) ? claudeApiSettings.availableModels : [],
8764
+ enabledModels: Array.isArray(claudeApiSettings.models) ? claudeApiSettings.models : [],
8765
+ discovered: claudeApiSettings.discovered === true,
8766
+ }
8767
+ : null,
8496
8768
  };
8497
8769
  }
8498
8770
 
@@ -8512,6 +8784,7 @@ export function registerSessionsRoutes(app, deps) {
8512
8784
  const cursorSettings = getCursorProviderSettings();
8513
8785
  const grokSettings = getGrokProviderSettings();
8514
8786
  const deepseekSettings = getDeepSeekProviderSettings();
8787
+ const claudeApiSettings = getClaudeApiProviderSettings();
8515
8788
  const refreshTasks = [
8516
8789
  refreshModelVariantCatalogFromCli(),
8517
8790
  openAISettings?.enabled === true
@@ -8535,8 +8808,13 @@ export function registerSessionsRoutes(app, deps) {
8535
8808
  deepseekSettings?.enabled === true && deepseekSettingsService
8536
8809
  ? deepseekSettingsService.refreshModels()
8537
8810
  : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8811
+ claudeApiSettings?.enabled === true && claudeApiSettingsService
8812
+ ? claudeApiSettingsService.refreshModels()
8813
+ : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8538
8814
  ];
8539
- const [cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh] = await Promise.allSettled(refreshTasks);
8815
+ const [
8816
+ cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh, claudeApiRefresh,
8817
+ ] = await Promise.allSettled(refreshTasks);
8540
8818
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
8541
8819
  const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
8542
8820
  ? openAIRefresh.value
@@ -8573,6 +8851,13 @@ export function registerSessionsRoutes(app, deps) {
8573
8851
  models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
8574
8852
  error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
8575
8853
  };
8854
+ const claudeApiModelDiscovery = claudeApiRefresh.status === 'fulfilled'
8855
+ ? claudeApiRefresh.value
8856
+ : {
8857
+ ok: false,
8858
+ models: Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [],
8859
+ error: claudeApiRefresh.reason?.message || 'Claude API model discovery failed',
8860
+ };
8576
8861
  const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
8577
8862
  ? copilotDiscoveryRefresh.value
8578
8863
  : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
@@ -8587,6 +8872,7 @@ export function registerSessionsRoutes(app, deps) {
8587
8872
  cursorModelDiscovery,
8588
8873
  grokModelDiscovery,
8589
8874
  deepseekModelDiscovery,
8875
+ claudeApiModelDiscovery,
8590
8876
  copilotModelDiscovery,
8591
8877
  });
8592
8878
  } catch (error) {
@@ -8634,6 +8920,7 @@ export function registerSessionsRoutes(app, deps) {
8634
8920
  // stay in models[] (a selection must not vanish) but pickers grey them.
8635
8921
  unavailableModels: Array.isArray(modelState.unavailableModels) ? modelState.unavailableModels : [],
8636
8922
  claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
8923
+ claudeApiContextTiersByModel: modelState.claudeApiContextTiersByModel || {},
8637
8924
  providersByModel: modelState.providersByModel || {},
8638
8925
  stale: modelState.stale,
8639
8926
  metadataValid: modelState.metadataValid === true,
@@ -8684,7 +8971,8 @@ export function registerSessionsRoutes(app, deps) {
8684
8971
  cursorBilling: null,
8685
8972
  deepseekBalance: null,
8686
8973
  };
8687
- const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek'];
8974
+ // `claude-api`'s card is read from the relay's own books on every answer.
8975
+ const USAGE_PROVIDER_IDS = ['github', 'claude', 'claude-cloud', 'cursor', 'grok', 'deepseek', 'claude-api'];
8688
8976
 
8689
8977
  function parseUsageProviders(raw) {
8690
8978
  const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '');
@@ -8803,6 +9091,14 @@ export function registerSessionsRoutes(app, deps) {
8803
9091
  }
8804
9092
  }
8805
9093
 
9094
+ // Built from the database on every answer, so it is always live.
9095
+ let claudeApiCard = null;
9096
+ try {
9097
+ claudeApiCard = claudeApiPlanUsage?.buildCard?.() || null;
9098
+ } catch {
9099
+ claudeApiCard = null;
9100
+ }
9101
+
8806
9102
  const report = planUsageService.buildReport({
8807
9103
  copilotSummary: copilot.summary,
8808
9104
  copilotError: copilot.error,
@@ -8829,6 +9125,7 @@ export function registerSessionsRoutes(app, deps) {
8829
9125
  ? { amount: deepseekSettings.startingBalance, setAt: deepseekSettings.startingBalanceSetAt }
8830
9126
  : null,
8831
9127
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9128
+ claudeApiCard,
8832
9129
  });
8833
9130
 
8834
9131
  // Legacy top-level fields stay alongside `providers` so an older cached
@@ -8844,6 +9141,7 @@ export function registerSessionsRoutes(app, deps) {
8844
9141
  cursor: cursorEntry.at,
8845
9142
  grok: grokEntry.at,
8846
9143
  deepseek: deepseekEntry.value?.fetchedAt || deepseekEntry.at,
9144
+ 'claude-api': nowIso(),
8847
9145
  },
8848
9146
  });
8849
9147
  });