@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
@@ -0,0 +1,112 @@
1
+ // The composer's billing toggle for a Claude chat: Subscription | API key.
2
+ // Pure and DOM-free: what the toggle shows, from the chat's provider, what
3
+ // the relay said of its billing (`billing`, GET /api/conversation and the
4
+ // `conversation_billing` socket event) and the Claude API tab's state.
5
+ //
6
+ // A switch applies from the chat's next turn; the worker is swapped between
7
+ // turns, and while it waits the relay's `billing.pending` says for what.
8
+
9
+ export const CLAUDE_BILLING_TOGGLE_OPTIONS = Object.freeze([
10
+ Object.freeze({ value: 'claude', label: 'Subscription' }),
11
+ Object.freeze({ value: 'claude-api', label: 'API key' }),
12
+ ]);
13
+
14
+ export const CLAUDE_BILLING_CACHE_WARNING = 'The first turn on the API key has no prompt cache and costs more.';
15
+ // A subscription chat while Claude API's monthly cap is reached: said on the
16
+ // toggle itself, not only when the unavailable side is pressed.
17
+ export const CLAUDE_BILLING_CAP_TEXT = "This month's Claude API cap is reached: the chat stays on the subscription.";
18
+
19
+ function normalizeBilling(value) {
20
+ const billing = String(value || '').trim().toLowerCase();
21
+ return billing === 'claude' || billing === 'claude-api' ? billing : '';
22
+ }
23
+
24
+ /** "the API key" / "the subscription", for running text. */
25
+ export function claudeBillingPlace(billing) {
26
+ return normalizeBilling(billing) === 'claude-api' ? 'the API key' : 'the subscription';
27
+ }
28
+
29
+ /** What the toggle says while the worker swap waits (`billing.pending`), or ''. */
30
+ export function claudeBillingPendingText(pending) {
31
+ if (!pending || typeof pending !== 'object') return '';
32
+ const place = claudeBillingPlace(pending.to);
33
+ if (pending.swapping === true) return `Restarting on ${place}…`;
34
+ const waitingFor = String(pending.waitingFor || '').trim();
35
+ if (waitingFor === 'turn') return `Moves to ${place} after the running turn.`;
36
+ if (waitingFor === 'question') return `Moves to ${place} once the open question is answered.`;
37
+ if (waitingFor === 'compaction') return `Moves to ${place} after the compaction.`;
38
+ if (waitingFor === 'background') {
39
+ const count = Number(pending.backgroundTaskCount) || 0;
40
+ return count > 0
41
+ ? `Moves to ${place} once ${count} background task${count === 1 ? '' : 's'} finish${count === 1 ? 'es' : ''}.`
42
+ : `Moves to ${place} once the background tasks finish.`;
43
+ }
44
+ if (waitingFor === 'stop') return `Moving to ${place}: the old Claude process has not stopped yet, retrying.`;
45
+ return `Moving to ${place}…`;
46
+ }
47
+
48
+ /**
49
+ * The toggle for one chat:
50
+ * `{ visible, current, options: [{ value, label, pressed, available, reason }],
51
+ * status, statusKind }`.
52
+ *
53
+ * Shown in a Claude SDK chat (either billing) whose relay said its billing,
54
+ * when Claude API is on with a saved key; a chat on the key keeps it after
55
+ * Claude API is switched off, so it can move back. `inFlight` is a switch
56
+ * this page sent and has no answer for yet; `warnCache` the chat this page
57
+ * just moved to the key.
58
+ */
59
+ export function claudeBillingToggleModel({
60
+ providerType = '',
61
+ billing = null,
62
+ claudeApi = null,
63
+ inFlight = false,
64
+ warnCache = false,
65
+ } = {}) {
66
+ const hidden = { visible: false, current: '', options: [], status: '', statusKind: '' };
67
+ const chatProvider = normalizeBilling(providerType);
68
+ if (!chatProvider || !billing || typeof billing !== 'object') return hidden;
69
+ const current = normalizeBilling(billing.provider) || chatProvider;
70
+ const claudeApiReady = claudeApi?.configured === true && claudeApi?.enabled === true;
71
+ if (current === 'claude' && !claudeApiReady) return hidden;
72
+ const switchable = billing.switchable && typeof billing.switchable === 'object' ? billing.switchable : {};
73
+ const target = normalizeBilling(switchable.to);
74
+ // The chat's billing was read while Claude API was off; the tab has since
75
+ // switched it on. The relay checks the switch again when it is pressed;
76
+ // a refusal it gave this page since (`refused`) stands.
77
+ const allowed = switchable.allowed === true
78
+ || (target === 'claude-api' && claudeApiReady && switchable.code === 'CLAUDE_API_NOT_CONFIGURED'
79
+ && switchable.refused !== true);
80
+ const options = CLAUDE_BILLING_TOGGLE_OPTIONS.map((option) => {
81
+ const pressed = option.value === current;
82
+ const available = pressed || (option.value === target && allowed && !inFlight);
83
+ const reason = pressed || available
84
+ ? ''
85
+ : (inFlight ? 'A switch is on its way.' : (String(switchable.reason || '').trim() || 'The relay does not allow this switch now.'));
86
+ return { value: option.value, label: option.label, pressed, available, reason };
87
+ });
88
+ let status = '';
89
+ let statusKind = '';
90
+ const pendingText = claudeBillingPendingText(billing.pending);
91
+ if (inFlight) {
92
+ status = 'Switching…';
93
+ statusKind = 'pending';
94
+ } else if (pendingText) {
95
+ status = pendingText;
96
+ statusKind = 'pending';
97
+ } else if (warnCache && current === 'claude-api') {
98
+ status = CLAUDE_BILLING_CACHE_WARNING;
99
+ statusKind = 'warning';
100
+ } else if (current === 'claude' && target === 'claude-api' && !allowed && switchable.code === 'CLAUDE_API_CAP_REACHED') {
101
+ status = CLAUDE_BILLING_CAP_TEXT;
102
+ statusKind = 'warning';
103
+ }
104
+ return { visible: true, current, options, status, statusKind };
105
+ }
106
+
107
+ /** The notice after a switch the relay accepted. */
108
+ export function claudeBillingSwitchedText(to) {
109
+ return normalizeBilling(to) === 'claude-api'
110
+ ? `This chat runs on the API key from the next turn. ${CLAUDE_BILLING_CACHE_WARNING}`
111
+ : 'This chat runs on the subscription from the next turn.';
112
+ }
@@ -1,6 +1,8 @@
1
1
  // Mirrors shared/model-id.mjs, which the browser cannot import (only
2
2
  // server/public is served).
3
3
  const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1000000;
4
+ // Mirrors shared/deepseek-models.mjs for the same reason.
5
+ const DEEPSEEK_CONTEXT_WINDOW_TOKENS = 1048576;
4
6
  export const UNKNOWN_WINDOW_LABEL = '—';
5
7
 
6
8
  // Shared by the context chip and both model pickers so a window reads the same
@@ -44,7 +46,9 @@ function claudeTiersForModel(modelId, claudeTiers) {
44
46
  * windows on a Claude conversation. For the Claude provider the offered tiers
45
47
  * are exactly the enabled catalog ids the server folded into
46
48
  * claudeContextTiersByModel: base id → default, "[1m]" id → long_context, and a
47
- * model may legitimately have only one of the two.
49
+ * model may legitimately have only one of the two. Claude API (`claude-api`)
50
+ * serves the same ids on an API key, with its own folded list
51
+ * (claudeApiContextTiersByModel), which the caller passes as `claudeTiers`.
48
52
  */
49
53
  export function buildContextTierOptions({
50
54
  modelId = '',
@@ -59,7 +63,11 @@ export function buildContextTierOptions({
59
63
  // metadata either (that number is Copilot's): one blank default, as for a
60
64
  // Claude model whose window is unknown.
61
65
  if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
62
- const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
66
+ // DeepSeek's models have one window, 1M: no tier to choose.
67
+ if (provider === 'deepseek') return [{ value: 'default', label: tokenLabel(DEEPSEEK_CONTEXT_WINDOW_TOKENS) }];
68
+ const claudeTierList = provider === 'claude' || provider === 'claude-api'
69
+ ? claudeTiersForModel(modelId, claudeTiers)
70
+ : null;
63
71
  if (claudeTierList) {
64
72
  return claudeTierList
65
73
  .map((tier) => String(tier?.value || '').trim().toLowerCase())
@@ -352,7 +352,7 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
352
352
 
353
353
  /**
354
354
  * The owner's switch for a session an agent started: may its agents start
355
- * sessions themselves (the remote_relay tool's one-level rule lifted for it)?
355
+ * sessions themselves (the relay_tool's one-level rule lifted for it)?
356
356
  * Only the owner sets it; no agent can.
357
357
  */
358
358
  export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
@@ -23,6 +23,16 @@ export function isConversationUsingGrokProvider(conversation = null) {
23
23
  return resolveConversationProviderType(conversation) === 'grok';
24
24
  }
25
25
 
26
+ export function isConversationUsingDeepSeekProvider(conversation = null) {
27
+ return resolveConversationProviderType(conversation) === 'deepseek';
28
+ }
29
+
30
+ // A Claude chat on the saved Claude Console key: the same Claude worker as
31
+ // `claude`, on another bill. A chat can switch between the two.
32
+ export function isConversationUsingClaudeApiProvider(conversation = null) {
33
+ return resolveConversationProviderType(conversation) === 'claude-api';
34
+ }
35
+
26
36
  // Its own provider, never a flavour of `claude`: the session runs in a sandbox
27
37
  // at Anthropic, so nothing that is true of a local Claude worker (modes,
28
38
  // effort, steering, a working directory) may be assumed for it.
@@ -36,9 +46,11 @@ const CLAUDE_CLOUD_MARKER = '☁';
36
46
  const PROVIDER_NAMES = Object.freeze({
37
47
  openai: 'OpenAI',
38
48
  claude: 'Claude',
49
+ 'claude-api': 'Claude API',
39
50
  'claude-cloud': 'Claude Cloud',
40
51
  cursor: 'Cursor',
41
52
  grok: 'Grok',
53
+ deepseek: 'DeepSeek',
42
54
  github: 'Copilot',
43
55
  'github-copilot': 'Copilot',
44
56
  });
@@ -74,8 +86,12 @@ const SESSION_LOCK_LABELS = {
74
86
  openai: 'OpenAI',
75
87
  'openai-image': 'OpenAI Image',
76
88
  claude: 'Claude SDK',
89
+ // The same Claude SDK models as `claude`, named for the bill, as the pill
90
+ // and New Chat name it.
91
+ 'claude-api': 'Claude API',
77
92
  cursor: 'Cursor SDK',
78
93
  grok: 'Grok',
94
+ deepseek: 'DeepSeek',
79
95
  'claude-cloud': `${CLAUDE_CLOUD_MARKER} Claude Cloud`,
80
96
  };
81
97
 
@@ -93,9 +109,11 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
93
109
  }
94
110
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
95
111
  if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
112
+ if (normalized === 'claude-api') return 'claude-api';
96
113
  if (normalized === 'claude-cloud') return 'claude-cloud';
97
114
  if (normalized === 'cursor') return 'cursor';
98
115
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
116
+ if (normalized === 'deepseek') return 'deepseek';
99
117
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
100
118
  return '';
101
119
  }
@@ -81,6 +81,9 @@ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
81
81
  import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
82
82
  import { attachCodeCopyButtons } from './code-copy.mjs';
83
83
  import { relayErrorCtaActions } from './relay-error-ctas.mjs';
84
+ import { switchClaudeBilling } from './claude-billing-toggle-ui.js';
85
+ import { CLAUDE_API_BILLING_URL, CLAUDE_API_LIMITS_URL, focusClaudeApiCapInput } from './claude-api-settings-ui.js';
86
+ import { knownConversationBilling, noteConversationBilling } from './usage-limit-ui.js';
84
87
  import { confirmCliInstall } from './cli-install-ui.js';
85
88
  import { startGrokSignIn } from './grok-auth-ui.js';
86
89
  import { openSettingsModal } from './settings-modal.js';
@@ -3508,8 +3511,9 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3508
3511
 
3509
3512
  // Each CTA lands the user where the state actually lives: the provider panel
3510
3513
  // shows the install log / the device code, so the settings modal is opened
3511
- // first and the action then runs on top of it.
3512
- function runRelayErrorCta(cta) {
3514
+ // first and the action then runs on top of it. New Chat runs them too, for a
3515
+ // refusal of its own (journal-view.js).
3516
+ export function runRelayErrorCta(cta) {
3513
3517
  if (cta === 'install-grok-cli') {
3514
3518
  openSettingsModal('providers', 'grok');
3515
3519
  // The confirm sheet — never a bare curl | bash from a chat bubble.
@@ -3523,6 +3527,17 @@ function runRelayErrorCta(cta) {
3523
3527
  }
3524
3528
  if (cta === 'open-grok-settings') openSettingsModal('providers', 'grok');
3525
3529
  if (cta === 'open-claude-settings') openSettingsModal('providers', 'claude');
3530
+ if (cta === 'open-deepseek-settings') openSettingsModal('providers', 'deepseek');
3531
+ if (cta === 'open-deepseek-top-up') window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener');
3532
+ if (cta === 'open-claude-api-settings') openSettingsModal('providers', 'claude-api');
3533
+ if (cta === 'open-claude-api-cap') {
3534
+ openSettingsModal('providers', 'claude-api');
3535
+ focusClaudeApiCapInput();
3536
+ }
3537
+ if (cta === 'open-claude-api-billing') window.open(CLAUDE_API_BILLING_URL, '_blank', 'noopener');
3538
+ if (cta === 'open-claude-api-limits') window.open(CLAUDE_API_LIMITS_URL, '_blank', 'noopener');
3539
+ // The chat this note is in, not a chat opened since: the bubble is on screen.
3540
+ if (cta === 'switch-claude-billing-to-subscription' && currentConvId) void switchClaudeBilling(currentConvId, 'claude');
3526
3541
  }
3527
3542
 
3528
3543
  function handleBubbleActionClick(event) {
@@ -4613,6 +4628,10 @@ export async function sendMessage() {
4613
4628
  relayMode: selectedMode,
4614
4629
  conversationId: targetConversationId || undefined,
4615
4630
  newConversation: isNew || undefined,
4631
+ // The billing this page shows for a Claude chat: the relay refuses the
4632
+ // send when the chat has moved to the other one since, so nobody's
4633
+ // turn runs on a bill they did not see.
4634
+ billing: (!isNew && targetConversationId && knownConversationBilling(targetConversationId)) || undefined,
4616
4635
  attachments,
4617
4636
  imageTarget: draftImageEditTarget
4618
4637
  ? {
@@ -4626,7 +4645,16 @@ export async function sendMessage() {
4626
4645
  // A refusal the user can act on (the conversation's model left the
4627
4646
  // catalog) is shown as the relay words it.
4628
4647
  let refusal = '';
4629
- const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
4648
+ const r = await sendMessageApi(body, {
4649
+ onRefused: (reason, payload) => {
4650
+ refusal = reason;
4651
+ // The chat's billing changed under this page: it learns the new one,
4652
+ // so sending again goes through.
4653
+ if (payload?.code === 'CLAUDE_BILLING_CHANGED' && targetConversationId) {
4654
+ noteConversationBilling(targetConversationId, payload.billing);
4655
+ }
4656
+ },
4657
+ });
4630
4658
  if (!r) {
4631
4659
  // Offline: park the send in the durable outbox instead of bouncing it
4632
4660
  // back into the composer. The client-generated messageId makes a replay
@@ -0,0 +1,342 @@
1
+ // Settings → Providers → DeepSeek: the key (written, never read back), the
2
+ // switch, the default model, the commit attribution, the monthly budget for
3
+ // the usage card with its currency, the starting balance its balance bar
4
+ // measures from and, under Advanced, the base URL. The
5
+ // relay discovers the models when a key is saved. Below them, the import of
6
+ // DeepSeek's usage export (two CSV files) for the usage card.
7
+
8
+ import { importDeepSeekUsageExport as postDeepSeekUsageExport, loadDeepSeekSettings, updateDeepSeekSettings } from './api-client.js';
9
+ import { showTransientRelayNotice } from './store.js';
10
+
11
+ const DEFAULT_MODEL = 'deepseek-flash';
12
+ const DEFAULT_BASE_URL = 'https://api.deepseek.com';
13
+
14
+ let updateInFlight = false;
15
+ let inputsDirty = false;
16
+ // The starting balance field was typed in since the tab was filled.
17
+ let startingBalanceEdited = false;
18
+ let state = {
19
+ configured: false,
20
+ enabled: false,
21
+ model: DEFAULT_MODEL,
22
+ availableModels: [DEFAULT_MODEL, 'deepseek-v4-pro'],
23
+ baseUrl: DEFAULT_BASE_URL,
24
+ attribution: 'oar',
25
+ monthlyBudget: null,
26
+ // 'USD' or 'CNY'; null for a budget saved before the currency was stored
27
+ // (the card reads it in the balance's currency), or no budget.
28
+ monthlyBudgetCurrency: null,
29
+ startingBalance: null,
30
+ // What the relay says of the starting balance (for example that a later
31
+ // top-up has replaced it), or null.
32
+ startingBalanceNote: null,
33
+ };
34
+
35
+ const BUDGET_CURRENCIES = ['USD', 'CNY'];
36
+
37
+ const CONTROL_IDS = [
38
+ 'deepseek-api-key-input',
39
+ 'deepseek-model-select',
40
+ 'deepseek-attribution-select',
41
+ 'deepseek-monthly-budget-input',
42
+ 'deepseek-monthly-budget-currency-select',
43
+ 'deepseek-starting-balance-input',
44
+ 'deepseek-base-url-input',
45
+ 'deepseek-enabled-toggle',
46
+ 'deepseek-save-btn',
47
+ 'deepseek-remove-btn',
48
+ ];
49
+
50
+ function element(id) {
51
+ return document.getElementById(id);
52
+ }
53
+
54
+ function ensureInputTracking() {
55
+ for (const id of ['deepseek-api-key-input', 'deepseek-model-select', 'deepseek-attribution-select', 'deepseek-monthly-budget-input', 'deepseek-monthly-budget-currency-select', 'deepseek-starting-balance-input', 'deepseek-base-url-input']) {
56
+ const input = element(id);
57
+ if (!input || input.dataset.deepseekDirtyTracking === '1') continue;
58
+ input.dataset.deepseekDirtyTracking = '1';
59
+ input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
60
+ inputsDirty = true;
61
+ if (id === 'deepseek-starting-balance-input') startingBalanceEdited = true;
62
+ });
63
+ }
64
+ }
65
+
66
+ function setControlsDisabled(disabled) {
67
+ for (const id of CONTROL_IDS) {
68
+ const control = element(id);
69
+ if (control) control.disabled = disabled;
70
+ }
71
+ }
72
+
73
+ function fillModelSelect(select) {
74
+ const models = Array.from(new Set([state.model, ...state.availableModels].filter(Boolean)));
75
+ select.innerHTML = '';
76
+ for (const model of models) {
77
+ const option = document.createElement('option');
78
+ option.value = model;
79
+ option.textContent = model;
80
+ select.appendChild(option);
81
+ }
82
+ select.value = state.model;
83
+ }
84
+
85
+ export function applyDeepSeekSettingsState(settings = {}, { resetInputs = false } = {}) {
86
+ ensureInputTracking();
87
+ const available = Array.isArray(settings?.availableModels)
88
+ ? settings.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
89
+ : state.availableModels;
90
+ state = {
91
+ configured: settings?.configured === true,
92
+ enabled: settings?.configured === true && settings?.enabled === true,
93
+ model: String(settings?.model || state.model || DEFAULT_MODEL).trim() || DEFAULT_MODEL,
94
+ availableModels: available.length ? available : state.availableModels,
95
+ baseUrl: String(settings?.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL,
96
+ attribution: settings?.attribution === 'off' ? 'off' : 'oar',
97
+ monthlyBudget: Number.isFinite(Number(settings?.monthlyBudget)) && Number(settings.monthlyBudget) > 0 ? Number(settings.monthlyBudget) : null,
98
+ monthlyBudgetCurrency: BUDGET_CURRENCIES.includes(String(settings?.monthlyBudgetCurrency || '').toUpperCase())
99
+ ? String(settings.monthlyBudgetCurrency).toUpperCase()
100
+ : null,
101
+ startingBalance: Number.isFinite(Number(settings?.startingBalance)) && Number(settings.startingBalance) > 0 ? Number(settings.startingBalance) : null,
102
+ startingBalanceNote: String(settings?.startingBalanceNote || '').trim() || null,
103
+ };
104
+ const keepInputs = inputsDirty && !resetInputs;
105
+ const keyInput = element('deepseek-api-key-input');
106
+ if (keyInput && !keepInputs) {
107
+ keyInput.value = '';
108
+ keyInput.placeholder = state.configured ? 'Saved API key (enter to replace)' : 'DeepSeek API key…';
109
+ }
110
+ const modelSelect = element('deepseek-model-select');
111
+ if (modelSelect && !keepInputs) fillModelSelect(modelSelect);
112
+ const attributionSelect = element('deepseek-attribution-select');
113
+ if (attributionSelect && !keepInputs) attributionSelect.value = state.attribution;
114
+ const budgetInput = element('deepseek-monthly-budget-input');
115
+ if (budgetInput && !keepInputs) budgetInput.value = state.monthlyBudget === null ? '' : String(state.monthlyBudget);
116
+ const budgetCurrencySelect = element('deepseek-monthly-budget-currency-select');
117
+ // A new budget is in dollars unless chosen otherwise; a budget saved
118
+ // without a currency stays in the balance's until one is chosen.
119
+ if (budgetCurrencySelect && !keepInputs) budgetCurrencySelect.value = state.monthlyBudgetCurrency || (state.monthlyBudget === null ? 'USD' : '');
120
+ const startingInput = element('deepseek-starting-balance-input');
121
+ if (startingInput && !keepInputs) startingInput.value = state.startingBalance === null ? '' : String(state.startingBalance);
122
+ const startingStatus = element('deepseek-starting-balance-status');
123
+ if (startingStatus) {
124
+ startingStatus.textContent = state.startingBalanceNote || '';
125
+ startingStatus.hidden = !state.startingBalanceNote;
126
+ }
127
+ const baseUrlInput = element('deepseek-base-url-input');
128
+ if (baseUrlInput && !keepInputs) baseUrlInput.value = state.baseUrl === DEFAULT_BASE_URL ? '' : state.baseUrl;
129
+ if (resetInputs) {
130
+ inputsDirty = false;
131
+ startingBalanceEdited = false;
132
+ }
133
+ const toggle = element('deepseek-enabled-toggle');
134
+ if (toggle) {
135
+ toggle.checked = state.enabled;
136
+ toggle.disabled = updateInFlight || !state.configured;
137
+ }
138
+ const removeButton = element('deepseek-remove-btn');
139
+ if (removeButton) removeButton.disabled = updateInFlight || !state.configured;
140
+ const status = element('deepseek-settings-status');
141
+ if (status) {
142
+ status.textContent = state.enabled
143
+ ? 'DeepSeek is enabled. Select DeepSeek in New Chat to use it.'
144
+ : (state.configured
145
+ ? 'API key saved but currently disabled. Enable it to allow DeepSeek selection in New Chat.'
146
+ : 'Not configured. New conversations use GitHub Copilot.');
147
+ status.dataset.state = state.enabled ? 'active' : (state.configured ? 'saved' : 'unconfigured');
148
+ }
149
+ window.syncAutoModelAvailability?.();
150
+ return state;
151
+ }
152
+
153
+ export async function refreshDeepSeekSettingsState() {
154
+ const settings = await loadDeepSeekSettings();
155
+ if (!settings) return null;
156
+ return applyDeepSeekSettingsState(settings);
157
+ }
158
+
159
+ /** Called when the Settings modal opens. */
160
+ export async function syncDeepSeekSettingsInputs() {
161
+ inputsDirty = false;
162
+ startingBalanceEdited = false;
163
+ ensureInputTracking();
164
+ const status = element('deepseek-settings-status');
165
+ const settings = await refreshDeepSeekSettingsState();
166
+ if (!settings && status) {
167
+ status.textContent = 'Unable to load DeepSeek settings.';
168
+ status.dataset.state = 'error';
169
+ }
170
+ }
171
+
172
+ async function runUpdate(payload, { successText, failureText, resetInputs = false }) {
173
+ if (updateInFlight) return null;
174
+ updateInFlight = true;
175
+ setControlsDisabled(true);
176
+ try {
177
+ const result = await updateDeepSeekSettings(payload);
178
+ if (!result) throw new Error(failureText);
179
+ applyDeepSeekSettingsState(result, { resetInputs });
180
+ const text = typeof successText === 'function' ? successText(result) : successText;
181
+ showTransientRelayNotice(result.warning ? `${text} ${result.warning}` : text, result.warning ? 8000 : 4000);
182
+ return result;
183
+ } catch (error) {
184
+ // A refused key removal (409) names the conversations in its message.
185
+ alert(error?.message || failureText);
186
+ return null;
187
+ } finally {
188
+ updateInFlight = false;
189
+ setControlsDisabled(false);
190
+ applyDeepSeekSettingsState(state);
191
+ }
192
+ }
193
+
194
+ export async function saveDeepSeekSettings() {
195
+ const apiKey = String(element('deepseek-api-key-input')?.value || '').trim();
196
+ if (!apiKey && !state.configured) {
197
+ alert('Enter a DeepSeek API key.');
198
+ return;
199
+ }
200
+ const baseUrlText = String(element('deepseek-base-url-input')?.value || '').trim();
201
+ const budgetText = String(element('deepseek-monthly-budget-input')?.value || '').trim();
202
+ const monthlyBudget = budgetText ? Number(budgetText) : null;
203
+ if (budgetText && !(Number.isFinite(monthlyBudget) && monthlyBudget >= 0)) {
204
+ alert('The monthly budget must be an amount of zero or more, or empty for no budget bar.');
205
+ return;
206
+ }
207
+ const startingText = String(element('deepseek-starting-balance-input')?.value || '').trim();
208
+ const startingBalance = startingText ? Number(startingText) : null;
209
+ if (startingText && !(Number.isFinite(startingBalance) && startingBalance >= 0)) {
210
+ alert('The starting balance must be an amount of zero or more, or empty to measure from the balance after the last top-up.');
211
+ return;
212
+ }
213
+ await runUpdate({
214
+ apiKey,
215
+ model: String(element('deepseek-model-select')?.value || state.model || DEFAULT_MODEL),
216
+ attribution: String(element('deepseek-attribution-select')?.value || state.attribution),
217
+ // Empty is DeepSeek's own endpoint.
218
+ baseUrl: baseUrlText || null,
219
+ // Empty (or zero) is no budget bar on the usage card. The currency goes
220
+ // with the amount; empty is the balance's currency.
221
+ monthlyBudget,
222
+ monthlyBudgetCurrency: String(element('deepseek-monthly-budget-currency-select')?.value || '').trim() || null,
223
+ // Empty (or zero): the balance bar measures from the balance after the
224
+ // last top-up the relay has seen (before one, the highest balance read).
225
+ startingBalance,
226
+ // Typed in since the tab was filled: only then does the relay set a
227
+ // starting balance a top-up has replaced anew (the same amount too). The
228
+ // relay decides "replaced" itself, so a tab opened before a top-up was
229
+ // seen does not bring the old amount back when another field is saved.
230
+ startingBalanceEdited,
231
+ enabled: state.configured ? state.enabled : true,
232
+ }, {
233
+ successText: (result) => `DeepSeek settings saved for ${result.model}.`,
234
+ failureText: 'Failed to save DeepSeek settings.',
235
+ resetInputs: true,
236
+ });
237
+ }
238
+
239
+ export async function toggleDeepSeekProvider(enabled) {
240
+ if (enabled && !state.configured) {
241
+ applyDeepSeekSettingsState(state);
242
+ alert('Save a DeepSeek API key before enabling DeepSeek.');
243
+ return;
244
+ }
245
+ await runUpdate({ enabled: enabled === true }, {
246
+ successText: (result) => (result.enabled ? 'DeepSeek enabled.' : 'DeepSeek disabled.'),
247
+ failureText: 'Failed to update the DeepSeek provider.',
248
+ });
249
+ }
250
+
251
+ export async function removeDeepSeekSettings() {
252
+ if (!state.configured) return;
253
+ if (!confirm('Remove the saved DeepSeek API key?')) return;
254
+ await runUpdate({ remove: true }, {
255
+ successText: 'DeepSeek API key removed. New conversations use GitHub Copilot.',
256
+ failureText: 'Failed to remove DeepSeek settings.',
257
+ resetInputs: true,
258
+ });
259
+ }
260
+
261
+ // The export's times keep its offset: "2026-10-26T00:00:00+01:00" reads
262
+ // "2026-10-26 00:00 (UTC+01:00)".
263
+ function exportTimeLabel(value) {
264
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(String(value || ''));
265
+ if (!match) return String(value || '');
266
+ const zone = !match[3] || match[3] === 'Z' ? 'UTC' : `UTC${match[3]}`;
267
+ return `${match[1]} ${match[2]} (${zone})`;
268
+ }
269
+
270
+ function listText(items) {
271
+ if (items.length <= 1) return items.join('');
272
+ return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
273
+ }
274
+
275
+ /** The import's answer, in the panel. */
276
+ export function deepseekUsageImportSummaryText(summary = {}) {
277
+ const days = Array.isArray(summary.days) ? summary.days : [];
278
+ const models = Array.isArray(summary.models) ? summary.models : [];
279
+ const keys = Array.isArray(summary.keys) ? summary.keys : [];
280
+ const rows = Number(summary.rows) || 0;
281
+ const parts = [];
282
+ if (!rows) {
283
+ parts.push('Nothing to import: the export has no period that has ended.');
284
+ } else {
285
+ const span = days.length > 1 ? `${days[0]} to ${days.at(-1)}` : (days[0] || '');
286
+ parts.push(`Imported ${rows} row${rows === 1 ? '' : 's'}${span ? ` for ${span}` : ''}${models.length ? `: ${listText(models)}` : ''}.`);
287
+ }
288
+ const skipped = Array.isArray(summary.skippedPeriods) ? summary.skippedPeriods.length : 0;
289
+ if (skipped) parts.push(`${skipped} period${skipped === 1 ? '' : 's'} not ended yet left out.`);
290
+ const currencies = Array.isArray(summary.currencies) ? summary.currencies : [];
291
+ const cardCurrency = summary.cardCurrency || '';
292
+ const exportCurrency = currencies.length ? listText(currencies) : '';
293
+ if (rows && summary.savedKeyMatched && summary.currencyMatched === false) {
294
+ parts.push(`The export is in ${exportCurrency || 'another currency'}, the usage card in ${cardCurrency} (the balance's): the card does not use these figures and stays estimated.`);
295
+ } else if (rows && summary.savedKeyMatched) {
296
+ const inCurrency = exportCurrency ? ` (${exportCurrency})` : '';
297
+ const cardNote = !cardCurrency && exportCurrency ? ` It uses them while the balance is in ${exportCurrency}.` : '';
298
+ parts.push(`The usage card shows the saved key's figures${inCurrency} on the export's days through ${exportTimeLabel(summary.through)}, estimated on the others.${cardNote}`);
299
+ } else if (rows && summary.savedKeyFp) {
300
+ parts.push(`The saved key (${summary.savedKeyFp}) is not in this export${keys.length ? `, only ${listText(keys.map((key) => key.keyFp))}` : ''}; the usage card stays estimated.`);
301
+ } else if (rows) {
302
+ parts.push('No DeepSeek key is saved: save the key this export belongs to and the usage card shows its figures.');
303
+ }
304
+ return parts.join(' ');
305
+ }
306
+
307
+ let importInFlight = false;
308
+
309
+ function setImportStatus(text, stateName) {
310
+ const status = element('deepseek-usage-import-status');
311
+ if (!status) return;
312
+ status.textContent = text;
313
+ status.dataset.state = stateName;
314
+ }
315
+
316
+ export async function importDeepSeekUsageExport() {
317
+ if (importInFlight) return null;
318
+ const amountFile = element('deepseek-usage-amount-input')?.files?.[0] || null;
319
+ const costFile = element('deepseek-usage-cost-input')?.files?.[0] || null;
320
+ if (!amountFile || !costFile) {
321
+ setImportStatus('Choose both files of the export: the amount file and the cost file.', 'error');
322
+ return null;
323
+ }
324
+ const button = element('deepseek-usage-import-btn');
325
+ importInFlight = true;
326
+ if (button) button.disabled = true;
327
+ setImportStatus('Importing…', 'saved');
328
+ try {
329
+ const [amount, cost] = await Promise.all([amountFile.text(), costFile.text()]);
330
+ const result = await postDeepSeekUsageExport({ amount, cost });
331
+ if (!result?.ok) throw new Error('The relay could not be reached.');
332
+ const used = result.summary?.savedKeyMatched && result.summary?.currencyMatched !== false;
333
+ setImportStatus(deepseekUsageImportSummaryText(result.summary), used ? 'active' : 'saved');
334
+ return result;
335
+ } catch (error) {
336
+ setImportStatus(String(error?.message || 'The import failed.'), 'error');
337
+ return null;
338
+ } finally {
339
+ importInFlight = false;
340
+ if (button) button.disabled = false;
341
+ }
342
+ }
@@ -41,6 +41,10 @@ const drafts = new Map();
41
41
  // replaces it; the socket updates single asks in between).
42
42
  let sessionAsks = new Map();
43
43
  let sessionCardKey = '';
44
+ // Where the Inbox was scrolled when it was last left; the next opening
45
+ // starts there (the first one at the top). The transcript's own scroll
46
+ // height is the transcript's, never the Inbox's.
47
+ let inboxScrollTop = 0;
44
48
 
45
49
  function readFilter() {
46
50
  try {
@@ -90,12 +94,17 @@ function restoreDrafts(host, focusedKey) {
90
94
  }
91
95
  }
92
96
 
93
- function renderInboxNow() {
97
+ /**
98
+ * Draws the Inbox into the transcript's place. A re-render keeps the scroll
99
+ * height; the render that opens the view (`opening`) takes the height the
100
+ * Inbox had when it was last left, not the one the transcript showed.
101
+ */
102
+ function renderInboxNow({ opening = false } = {}) {
94
103
  if (!inboxActive) return;
95
104
  const host = document.getElementById('messages');
96
105
  if (!host) return;
97
106
  const focused = captureDrafts(host);
98
- const top = host.scrollTop;
107
+ const top = opening ? inboxScrollTop : host.scrollTop;
99
108
  host.replaceChildren(renderInbox(document, { relays, filter, now: Date.now(), loading: !loaded }));
100
109
  restoreDrafts(host, focused);
101
110
  host.scrollTop = top;
@@ -153,6 +162,8 @@ export function noteInboxQuestionsChanged() {
153
162
  export function leaveInboxView() {
154
163
  const wasActive = inboxActive;
155
164
  if (!wasActive) return false;
165
+ const host = document.getElementById('messages');
166
+ if (host) inboxScrollTop = Math.max(0, Number(host.scrollTop) || 0);
156
167
  inboxActive = false;
157
168
  document.body.classList.remove('inbox-view-active');
158
169
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -187,7 +198,7 @@ export async function toggleInboxView() {
187
198
  }
188
199
  }
189
200
  syncInboxBadge();
190
- renderInboxNow();
201
+ renderInboxNow({ opening: true });
191
202
  document.getElementById('sidebar')?.classList.remove('open');
192
203
  document.getElementById('sidebar-overlay')?.classList.remove('visible');
193
204
  await refreshInbox();