@oar-sh/oar 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -1,7 +1,7 @@
1
1
  // The Inbox, as DOM: every pending question across this relay and its paired
2
2
  // relays (GET /api/inbox), grouped by relay, newest first; what was answered
3
3
  // or dismissed in the last day under a divider. And the card a question that
4
- // does not block (remote_relay `ask`) shows in its own session.
4
+ // does not block (relay_tool `ask`) shows in its own session.
5
5
  //
6
6
  // Every text here is written by an agent, some on other relays: it goes into
7
7
  // the page through textContent only, never as HTML or a link. Buttons carry
@@ -72,13 +72,16 @@ import {
72
72
  loadClaudeCloudBranches,
73
73
  loadClaudeCloudRepos,
74
74
  loadGrokSettings,
75
+ loadDeepSeekSettings,
76
+ loadClaudeApiSettings,
75
77
  loadOpenAISettings,
76
78
  } from './api-client.js';
77
79
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
78
80
  import { SETUP_TEXT } from './no-conversation-state.mjs';
79
81
  import { openSettingsModal } from './settings-modal.js';
82
+ import { START_ON_SUBSCRIPTION_ACTION, newChatClaudeApiRefusalActions } from './relay-error-ctas.mjs';
80
83
  import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
81
- import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
84
+ import { runRelayErrorCta, renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
82
85
  import { mergeTailPage } from './transcript-reading-anchor.mjs';
83
86
  import { readWindowRetention } from './conversation-window-retention.mjs';
84
87
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
@@ -104,6 +107,7 @@ import { createInfiniteLoader } from './infinite-loader.js';
104
107
  import {
105
108
  buildNewConversationModelChoices,
106
109
  newConversationContextTierState,
110
+ preselectNewConversationModel,
107
111
  reasoningChoicesForProviderModel,
108
112
  resolvePreferredReasoningEffort,
109
113
  } from './new-conversation-model-choice.mjs';
@@ -189,6 +193,8 @@ let newConversationOpenAISettingsCache = null;
189
193
  let newConversationClaudeSettingsCache = null;
190
194
  let newConversationCursorSettingsCache = null;
191
195
  let newConversationGrokSettingsCache = null;
196
+ let newConversationDeepSeekSettingsCache = null;
197
+ let newConversationClaudeApiSettingsCache = null;
192
198
  let newConversationClaudeCloudSettingsCache = null;
193
199
  // Claude Cloud fields of the New Chat modal: what the selected folder's git
194
200
  // remote said, and what was written into Repository/Branch from it (so a later
@@ -698,7 +704,7 @@ function paintConvList() {
698
704
  : renderConversationOriginMarkerHtml(c, { conversations });
699
705
  // "watched": a session another conversation watches by id (relay events).
700
706
  const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
701
- // "scheduled": a schedule fires into this session (remote_relay schedule).
707
+ // "scheduled": a schedule fires into this session (relay_tool schedule).
702
708
  const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
703
709
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
704
710
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
@@ -924,6 +930,8 @@ export function applyLoadedConversationState(id, response, {
924
930
  // Absent from an older relay's payload: keep what the socket said.
925
931
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
926
932
  providerType: response.runtimeSession?.providerType,
933
+ // A Claude chat's billing and whether it may switch (an older relay: absent).
934
+ billing: 'billing' in response ? response.billing : undefined,
927
935
  });
928
936
  // Merge, not replace: this payload only carries one conversation's previews.
929
937
  mergeConversationPreviews(id, response.previews || []);
@@ -1234,9 +1242,11 @@ function normalizeNewConversationProviderType(value = '') {
1234
1242
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1235
1243
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
1236
1244
  if (normalized === 'claude') return 'claude';
1245
+ if (normalized === 'claude-api') return 'claude-api';
1237
1246
  if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1238
1247
  if (normalized === 'cursor') return 'cursor';
1239
1248
  if (normalized === 'grok') return 'grok';
1249
+ if (normalized === 'deepseek') return 'deepseek';
1240
1250
  return 'github';
1241
1251
  }
1242
1252
 
@@ -1274,6 +1284,8 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1274
1284
  const hasClaude = providers.includes('claude');
1275
1285
  const hasCursor = providers.includes('cursor');
1276
1286
  const hasGrok = providers.includes('grok');
1287
+ const hasDeepSeek = providers.includes('deepseek');
1288
+ const hasClaudeApi = providers.includes('claude-api');
1277
1289
  if (wantsOpenAI) {
1278
1290
  if (hasOpenAIByok) return true;
1279
1291
  const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
@@ -1309,16 +1321,36 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1309
1321
  : [];
1310
1322
  return normalizedModelId === grokModel || grokModels.includes(normalizedModelId);
1311
1323
  }
1312
- const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude');
1324
+ if (normalizedProvider === 'deepseek') {
1325
+ if (hasDeepSeek) return true;
1326
+ const deepseekModel = String(newConversationDeepSeekSettingsCache?.model || '').trim();
1327
+ const deepseekModels = Array.isArray(newConversationDeepSeekSettingsCache?.models)
1328
+ ? newConversationDeepSeekSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
1329
+ : [];
1330
+ return normalizedModelId === deepseekModel || deepseekModels.includes(normalizedModelId);
1331
+ }
1332
+ if (normalizedProvider === 'claude-api') {
1333
+ if (hasClaudeApi) return true;
1334
+ const claudeApiModel = String(newConversationClaudeApiSettingsCache?.model || '').trim();
1335
+ const claudeApiModels = Array.isArray(newConversationClaudeApiSettingsCache?.models)
1336
+ ? newConversationClaudeApiSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
1337
+ : [];
1338
+ return normalizedModelId === claudeApiModel || claudeApiModels.includes(normalizedModelId);
1339
+ }
1340
+ // Claude API serves the Claude provider's ids: still none of Copilot's.
1341
+ const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude' || provider === 'claude-api');
1313
1342
  if (claudeOnly) return false;
1314
1343
  const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
1315
1344
  if (cursorOnly) return false;
1316
1345
  const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
1317
1346
  if (grokOnly) return false;
1347
+ const deepseekOnly = hasDeepSeek && providers.every((provider) => provider === 'deepseek');
1348
+ if (deepseekOnly) return false;
1318
1349
  // Should a relay ever tag catalog rows for Claude Cloud, they are no more
1319
1350
  // Copilot's than the other runtimes' rows are.
1320
1351
  const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
1321
- provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === CLAUDE_CLOUD_PROVIDER
1352
+ provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek'
1353
+ || provider === 'claude-api' || provider === CLAUDE_CLOUD_PROVIDER
1322
1354
  ));
1323
1355
  if (exclusiveOnly) return false;
1324
1356
  return providers.some((provider) => (
@@ -1326,6 +1358,8 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1326
1358
  && provider !== 'claude'
1327
1359
  && provider !== 'cursor'
1328
1360
  && provider !== 'grok'
1361
+ && provider !== 'deepseek'
1362
+ && provider !== 'claude-api'
1329
1363
  && provider !== CLAUDE_CLOUD_PROVIDER
1330
1364
  )) || !hasOpenAIByok;
1331
1365
  }
@@ -1443,8 +1477,10 @@ async function populateNewConversationReasoningSelect(selectedModel = '') {
1443
1477
  if (optionTitle) option.title = optionTitle;
1444
1478
  select.appendChild(option);
1445
1479
  }
1480
+ // A DeepSeek chat with nothing remembered starts at DeepSeek's own default.
1481
+ const storedEffort = localStorage.getItem(REASONING_STORAGE_KEY);
1446
1482
  const preferred = resolvePreferredReasoningEffort(efforts, [
1447
- localStorage.getItem(REASONING_STORAGE_KEY),
1483
+ storedEffort || (provider === 'deepseek' ? 'high' : ''),
1448
1484
  ]);
1449
1485
  select.value = preferred || efforts[0];
1450
1486
  select.disabled = false;
@@ -1475,6 +1511,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
1475
1511
  help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
1476
1512
  return;
1477
1513
  }
1514
+ if (normalizedProvider === 'claude-api') {
1515
+ help.textContent = 'Claude API chats run through the Claude Agent SDK on the API key saved in Settings, billed to its Claude Console organisation. The chat can switch to the subscription later.';
1516
+ return;
1517
+ }
1478
1518
  if (normalizedProvider === CLAUDE_CLOUD_PROVIDER) {
1479
1519
  help.textContent = "Claude Cloud chats run in a sandbox at Anthropic on a clone of a GitHub repository, billed to the relay host's Claude account.";
1480
1520
  return;
@@ -1487,6 +1527,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
1487
1527
  help.textContent = "Grok chats use the host machine's logged-in Grok credentials (grok login or XAI_API_KEY).";
1488
1528
  return;
1489
1529
  }
1530
+ if (normalizedProvider === 'deepseek') {
1531
+ help.textContent = "DeepSeek chats run Claude Code on the relay host against DeepSeek's API, billed to your saved DeepSeek key.";
1532
+ return;
1533
+ }
1490
1534
  help.textContent = 'Copilot models use your GitHub Copilot runtime.';
1491
1535
  }
1492
1536
 
@@ -1516,7 +1560,7 @@ async function populateNewConversationModelSelect(providerType = 'github') {
1516
1560
  buildCatalogModelOptions(eligibleModels, {
1517
1561
  metadataByModel: catalog.modelMetadataByModel || {},
1518
1562
  // The merged metadata knows Copilot's windows, not Claude's or the cloud's.
1519
- annotateContextWindow: normalizedProvider !== 'claude' && !isCloud,
1563
+ annotateContextWindow: normalizedProvider !== 'claude' && normalizedProvider !== 'claude-api' && !isCloud,
1520
1564
  }).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
1521
1565
  );
1522
1566
  for (const choice of choices) {
@@ -1534,10 +1578,13 @@ async function populateNewConversationModelSelect(providerType = 'github') {
1534
1578
  // The tab's default, not the last model another provider's chat used.
1535
1579
  const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
1536
1580
  if (cloudDefault) target.value = cloudDefault;
1537
- } else if (storedModel && Array.from(target.options).some((option) => option.value === storedModel)) {
1538
- target.value = storedModel;
1539
- } else if (normalizedProvider !== 'openai-image' && Array.from(target.options).some((option) => option.value === 'auto')) {
1540
- target.value = 'auto';
1581
+ } else {
1582
+ const preselected = preselectNewConversationModel(Array.from(target.options, (option) => option.value), {
1583
+ storedModel,
1584
+ providerDefault: normalizedProvider === 'claude-api' ? newConversationClaudeApiSettingsCache?.model : '',
1585
+ allowAuto: normalizedProvider !== 'openai-image',
1586
+ });
1587
+ if (preselected) target.value = preselected;
1541
1588
  }
1542
1589
  updateNewConversationProviderHelp(normalizedProvider);
1543
1590
  syncNewConversationReasoningLabel(normalizedProvider);
@@ -2094,7 +2141,7 @@ function applyNewConversationSetupState(setupRequired) {
2094
2141
  }
2095
2142
 
2096
2143
  async function openNewConversationModelModal() {
2097
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
2144
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings, claudeApiSettings] = await Promise.all([
2098
2145
  loadModelCatalog(),
2099
2146
  loadOpenAISettings(),
2100
2147
  loadClaudeSettings(),
@@ -2102,6 +2149,10 @@ async function openNewConversationModelModal() {
2102
2149
  loadGrokSettings(),
2103
2150
  // null on a relay without Claude Cloud: the option is simply not offered.
2104
2151
  loadClaudeCloudSettings(),
2152
+ // The same for a relay without DeepSeek.
2153
+ loadDeepSeekSettings(),
2154
+ // And without Claude API.
2155
+ loadClaudeApiSettings(),
2105
2156
  ]);
2106
2157
  newConversationCatalogCache = catalog || null;
2107
2158
  newConversationOpenAISettingsCache = settings || null;
@@ -2109,6 +2160,8 @@ async function openNewConversationModelModal() {
2109
2160
  newConversationCursorSettingsCache = cursorSettings || null;
2110
2161
  newConversationGrokSettingsCache = grokSettings || null;
2111
2162
  newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
2163
+ newConversationDeepSeekSettingsCache = deepseekSettings || null;
2164
+ newConversationClaudeApiSettingsCache = claudeApiSettings || null;
2112
2165
  // Fresh answers for the readiness question, shared with the empty screen
2113
2166
  // and the composer. With no provider known to be ready the modal opens in
2114
2167
  // its setup state: Settings is the main button, Start stays as "anyway".
@@ -2118,11 +2171,14 @@ async function openNewConversationModelModal() {
2118
2171
  claudeCloud: claudeCloudSettings || null,
2119
2172
  cursor: cursorSettings || null,
2120
2173
  grok: grokSettings || null,
2174
+ deepseek: deepseekSettings || null,
2175
+ claudeApi: claudeApiSettings || null,
2121
2176
  catalog: catalog || null,
2122
2177
  });
2123
2178
  applyNewConversationSetupState(isSetupRequired(readiness));
2124
2179
  // Every open starts from empty cloud fields; the folder lookup refills them.
2125
2180
  resetNewConversationCloudState();
2181
+ clearNewConversationClaudeApiRefusal();
2126
2182
  bindNewConversationCloudInputs();
2127
2183
  const providerSelect = document.getElementById('new-conversation-provider-select');
2128
2184
  if (providerSelect) {
@@ -2134,6 +2190,9 @@ async function openNewConversationModelModal() {
2134
2190
  if (claudeSettings?.enabled === true) {
2135
2191
  options.push({ value: 'claude', label: 'Claude SDK' });
2136
2192
  }
2193
+ if (claudeApiSettings?.enabled === true) {
2194
+ options.push({ value: 'claude-api', label: 'Claude API' });
2195
+ }
2137
2196
  if (claudeCloudSettings?.enabled === true) {
2138
2197
  options.push({ value: CLAUDE_CLOUD_PROVIDER, label: 'Claude Cloud' });
2139
2198
  }
@@ -2143,6 +2202,9 @@ async function openNewConversationModelModal() {
2143
2202
  if (grokSettings?.enabled === true) {
2144
2203
  options.push({ value: 'grok', label: 'Grok' });
2145
2204
  }
2205
+ if (deepseekSettings?.enabled === true) {
2206
+ options.push({ value: 'deepseek', label: 'DeepSeek' });
2207
+ }
2146
2208
  providerSelect.innerHTML = '';
2147
2209
  for (const option of options) {
2148
2210
  const entry = document.createElement('option');
@@ -2160,6 +2222,7 @@ async function openNewConversationModelModal() {
2160
2222
  if (providerSelect.dataset.modelsBound !== '1') {
2161
2223
  providerSelect.dataset.modelsBound = '1';
2162
2224
  providerSelect.addEventListener('change', () => {
2225
+ clearNewConversationClaudeApiRefusal();
2163
2226
  void populateNewConversationModelSelect(providerSelect.value);
2164
2227
  });
2165
2228
  }
@@ -2348,6 +2411,18 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
2348
2411
  showNewConversationCloudError(cloudError.text, cloudError.field ? [cloudError.field] : []);
2349
2412
  return;
2350
2413
  }
2414
+ // Claude API refused to start (this month's cap, no key, switched off):
2415
+ // nothing was bound. The relay's words stay in the modal, with the way
2416
+ // to fix it and the same chat on the subscription.
2417
+ const claudeApiActions = !recovered && selectedProvider === 'claude-api'
2418
+ ? newChatClaudeApiRefusalActions(error?.payload?.code, {
2419
+ subscriptionOffered: newConversationProviderOffered('claude'),
2420
+ })
2421
+ : [];
2422
+ if (claudeApiActions.length) {
2423
+ showNewConversationClaudeApiRefusal(error?.message || 'Claude API could not start this chat.', claudeApiActions, selectedModel);
2424
+ return;
2425
+ }
2351
2426
  showTransientRelayNotice(recovered
2352
2427
  // The chat is usable, but its worker did not start, so say what broke.
2353
2428
  ? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
@@ -2359,8 +2434,78 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
2359
2434
  }
2360
2435
  }
2361
2436
 
2437
+ function newConversationProviderOffered(provider) {
2438
+ return Array.from(document.getElementById('new-conversation-provider-select')?.options || [])
2439
+ .some((option) => option.value === provider);
2440
+ }
2441
+
2442
+ function clearNewConversationClaudeApiRefusal() {
2443
+ const note = document.getElementById('new-conversation-refusal');
2444
+ if (!note) return;
2445
+ note.replaceChildren();
2446
+ note.hidden = true;
2447
+ }
2448
+
2449
+ // The same chat, started on the Claude provider: its model when the Claude
2450
+ // list has it, the choices the modal holds otherwise.
2451
+ async function startNewConversationOnSubscription(model) {
2452
+ const providerSelect = document.getElementById('new-conversation-provider-select');
2453
+ if (!providerSelect || newConversationInFlight) return;
2454
+ clearNewConversationClaudeApiRefusal();
2455
+ // The effort chosen for the refused chat: rebuilding the lists for Claude
2456
+ // would fall back to the stored one.
2457
+ const effort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
2458
+ providerSelect.value = 'claude';
2459
+ await populateNewConversationModelSelect('claude');
2460
+ const modelSelect = document.getElementById('new-conversation-model-select');
2461
+ if (modelSelect && Array.from(modelSelect.options).some((option) => option.value === model)) {
2462
+ modelSelect.value = model;
2463
+ await populateNewConversationReasoningSelect(model);
2464
+ }
2465
+ const reasoningSelect = document.getElementById('new-conversation-reasoning-select');
2466
+ if (effort && reasoningSelect && Array.from(reasoningSelect.options).some((option) => option.value === effort)) {
2467
+ reasoningSelect.value = effort;
2468
+ }
2469
+ await confirmNewConversationModel();
2470
+ }
2471
+
2472
+ function showNewConversationClaudeApiRefusal(message, actions, model) {
2473
+ const note = document.getElementById('new-conversation-refusal');
2474
+ if (!note) {
2475
+ showTransientRelayNotice(message, 12000);
2476
+ return;
2477
+ }
2478
+ const text = document.createElement('div');
2479
+ text.className = 'new-conversation-refusal-text';
2480
+ text.textContent = message;
2481
+ const buttons = document.createElement('div');
2482
+ buttons.className = 'new-conversation-refusal-actions';
2483
+ for (const item of actions) {
2484
+ const button = document.createElement('button');
2485
+ button.type = 'button';
2486
+ button.className = 'summary-btn';
2487
+ button.dataset.action = item.action;
2488
+ button.textContent = item.label;
2489
+ button.addEventListener('click', (event) => {
2490
+ event.preventDefault();
2491
+ if (item.action === START_ON_SUBSCRIPTION_ACTION) {
2492
+ void startNewConversationOnSubscription(model);
2493
+ return;
2494
+ }
2495
+ closeNewConversationModelModal();
2496
+ runRelayErrorCta(item.action);
2497
+ });
2498
+ buttons.appendChild(button);
2499
+ }
2500
+ note.replaceChildren(text, buttons);
2501
+ note.hidden = false;
2502
+ // The modal's body scrolls: on a phone the note lands below the fold.
2503
+ note.scrollIntoView?.({ block: 'nearest' });
2504
+ }
2505
+
2362
2506
  export async function confirmNewConversationModel() {
2363
2507
  if (newConversationInFlight) return;
2508
+ clearNewConversationClaudeApiRefusal();
2364
2509
  const selectedModel = String(document.getElementById('new-conversation-model-select')?.value || '').trim();
2365
2510
  const selectedReasoningEffort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
2366
2511
  if (!selectedModel) return;
@@ -12,15 +12,17 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
12
12
  const provider = String(providerType || '').trim().toLowerCase();
13
13
  const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
14
14
  const providerFallback = () => {
15
- if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
15
+ if (provider === 'claude' || provider === 'claude-api' || provider === 'claude-cloud') return 'Message Claude…';
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
19
+ if (provider === 'deepseek') return 'Message DeepSeek…';
19
20
  return 'Message Copilot…';
20
21
  };
21
22
  if (!id || id === 'auto') return providerFallback();
22
23
  if (id.startsWith('claude-')) return 'Message Claude…';
23
24
  if (id.startsWith('grok-')) return 'Message Grok…';
25
+ if (id.startsWith('deepseek-')) return 'Message DeepSeek…';
24
26
  if (id.startsWith('gpt-')) return 'Message GPT…';
25
27
  if (id.startsWith('gemini-')) return 'Message Gemini…';
26
28
  // Composer is Cursor's house model family.
@@ -65,6 +67,15 @@ export function humanizeModelLabel(modelId = '') {
65
67
  const label = words.map((word) => word.text).join(' ');
66
68
  return suffixMatch ? `${label} [${suffixMatch[1]}]` : label;
67
69
  }
70
+ // "deepseek-flash" → "DeepSeek Flash", "deepseek-v4-pro" → "DeepSeek V4 Pro".
71
+ if (/^deepseek-/i.test(text)) {
72
+ return `DeepSeek ${text
73
+ .replace(/^deepseek-/i, '')
74
+ .split('-')
75
+ .filter(Boolean)
76
+ .map((part) => (/^v\d/i.test(part) ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)))
77
+ .join(' ')}`;
78
+ }
68
79
  if (/^gemini-/i.test(text)) {
69
80
  return text
70
81
  .replace(/^gemini-/i, 'Gemini ')
@@ -184,6 +195,22 @@ export function normalizeModelSelectorOptions(models = [], {
184
195
  });
185
196
  }
186
197
 
198
+ /**
199
+ * `humanizeModelLabel` for a list of ids, where two ids that would read the
200
+ * same (two dated snapshots of one model) keep their ids instead: a Map of
201
+ * id → label.
202
+ */
203
+ export function distinctModelLabels(modelIds = []) {
204
+ const ids = (Array.isArray(modelIds) ? modelIds : []).map((id) => String(id || '').trim()).filter(Boolean);
205
+ const labels = new Map(ids.map((id) => [id, humanizeModelLabel(id) || id]));
206
+ const counts = new Map();
207
+ for (const label of labels.values()) counts.set(label, (counts.get(label) || 0) + 1);
208
+ for (const [id, label] of labels) {
209
+ if (counts.get(label) > 1) labels.set(id, id);
210
+ }
211
+ return labels;
212
+ }
213
+
187
214
  /**
188
215
  * The one option list both pickers render (composer #model-select and the
189
216
  * New Chat modal), so they cannot disagree on order or wording. Callers filter
@@ -14,7 +14,9 @@ const KNOWN_TEXT_ONLY_PATTERNS = [
14
14
  /^code-davinci/,
15
15
  /^text-davinci/,
16
16
  /-instruct-text$/,
17
+ // DeepSeek's text-only models (deepseek-flash takes images).
17
18
  /^deepseek-coder/,
19
+ /^deepseek-v4-pro/,
18
20
  /^qwen[\w.-]*-coder/,
19
21
  ];
20
22
 
@@ -25,6 +25,27 @@ export function buildNewConversationModelChoices(options = []) {
25
25
  return choices;
26
26
  }
27
27
 
28
+ /**
29
+ * The model New Chat starts on, from the values its list offers: the
30
+ * composer's last model when the list has it, else the provider's own
31
+ * default (Claude API's settings model; the list is sorted by name, so its
32
+ * first entry is no default), else Auto when allowed, else ''. '' leaves the
33
+ * list's first entry.
34
+ */
35
+ export function preselectNewConversationModel(values = [], {
36
+ storedModel = '',
37
+ providerDefault = '',
38
+ allowAuto = true,
39
+ } = {}) {
40
+ const offered = new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean));
41
+ const stored = String(storedModel || '').trim();
42
+ if (stored && offered.has(stored)) return stored;
43
+ const fallback = String(providerDefault || '').trim();
44
+ if (fallback && offered.has(fallback)) return fallback;
45
+ if (allowAuto && offered.has('auto')) return 'auto';
46
+ return '';
47
+ }
48
+
28
49
  function normalizeProviderKey(provider = '') {
29
50
  const key = String(provider || '').trim().toLowerCase();
30
51
  if (key === 'openai-byok') return 'openai';
@@ -10,7 +10,7 @@
10
10
  import { isSetupRequired } from './provider-readiness.mjs';
11
11
 
12
12
  export const SETUP_HEADING = 'Set up a provider';
13
- export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI or Grok in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
13
+ export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI, Grok or DeepSeek in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
14
14
 
15
15
  const ACTIONS = Object.freeze({
16
16
  openSettings: (primary) => ({ id: 'empty-state-open-settings', action: 'open-settings', label: primary ? 'Open Settings' : 'Settings', primary, link: !primary }),