@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,380 @@
1
+ 'use strict';
2
+
3
+ // Settings of the DeepSeek provider (Settings → Providers → DeepSeek).
4
+ //
5
+ // One API key per relay, stored like the OpenAI and Cursor keys in the
6
+ // settings table and never sent to the browser. Next to it: the switch, the
7
+ // default model, the discovered models and the ones enabled for the
8
+ // composer, an optional base URL, and the commit attribution of DeepSeek
9
+ // chats. Discovery asks DeepSeek's `GET /models` (shared/deepseek-api.mjs);
10
+ // when it has not run or failed, the built-in list stands in.
11
+
12
+ import { createDeepSeekApiClient, normalizeDeepSeekBaseUrl } from '../../shared/deepseek-api.mjs';
13
+ import {
14
+ DEEPSEEK_ATTRIBUTION_MODES,
15
+ DEEPSEEK_PROVIDER_TYPE,
16
+ DEEPSEEK_REASONING_EFFORTS,
17
+ DEFAULT_DEEPSEEK_ATTRIBUTION,
18
+ DEFAULT_DEEPSEEK_BASE_URL,
19
+ DEFAULT_DEEPSEEK_MODEL,
20
+ DEFAULT_DEEPSEEK_MODELS,
21
+ isDeepSeekModelId,
22
+ } from '../../shared/deepseek-models.mjs';
23
+ import { DEEPSEEK_PRICE_CURRENCIES } from '../../shared/deepseek-pricing.mjs';
24
+ import { isSafeDeepSeekModelId } from '../../shared/model-id.mjs';
25
+
26
+ export const DEEPSEEK_API_KEY_SETTING_KEY = 'deepseek_api_key';
27
+ export const DEEPSEEK_ENABLED_SETTING_KEY = 'deepseek_enabled';
28
+ export const DEEPSEEK_MODEL_SETTING_KEY = 'deepseek_model';
29
+ export const DEEPSEEK_MODELS_SETTING_KEY = 'deepseek_models';
30
+ export const DEEPSEEK_ENABLED_MODELS_SETTING_KEY = 'deepseek_enabled_models';
31
+ export const DEEPSEEK_BASE_URL_SETTING_KEY = 'deepseek_base_url';
32
+ export const DEEPSEEK_ATTRIBUTION_SETTING_KEY = 'deepseek_attribution';
33
+ // The optional monthly budget for the usage card's budget bar, a positive
34
+ // amount; unset means no bar. Its currency (USD or CNY) is stored with it;
35
+ // a budget saved without one (before the currency was stored) is read in
36
+ // the balance's currency, which can change.
37
+ export const DEEPSEEK_MONTHLY_BUDGET_SETTING_KEY = 'deepseek_monthly_budget';
38
+ export const DEEPSEEK_MONTHLY_BUDGET_CURRENCY_SETTING_KEY = 'deepseek_monthly_budget_currency';
39
+ const MAX_MONTHLY_BUDGET = 1_000_000_000;
40
+ // An optional starting balance for the usage card's balance bar, in the
41
+ // balance's currency: the bar measures what is left against it instead of
42
+ // the balance after the last top-up the relay has seen, or before one the
43
+ // highest balance it has read (plan-usage-deepseek.mjs). When it
44
+ // was saved is kept with it, to tell a later top-up from what came before.
45
+ export const DEEPSEEK_STARTING_BALANCE_SETTING_KEY = 'deepseek_starting_balance';
46
+ export const DEEPSEEK_STARTING_BALANCE_SET_AT_SETTING_KEY = 'deepseek_starting_balance_set_at';
47
+
48
+ function hasOwn(object, key) {
49
+ return Object.prototype.hasOwnProperty.call(object, key);
50
+ }
51
+
52
+ function toText(value) {
53
+ return String(value ?? '').trim();
54
+ }
55
+
56
+ function isUsableModelId(value) {
57
+ return isSafeDeepSeekModelId(value) && isDeepSeekModelId(value);
58
+ }
59
+
60
+ function uniqueModels(values) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const value of values) {
64
+ const model = toText(value);
65
+ const key = model.toLowerCase();
66
+ if (!model || seen.has(key) || !isUsableModelId(model)) continue;
67
+ seen.add(key);
68
+ out.push(model);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * A monthly budget from a request or the store: a positive amount with at
75
+ * most two decimals, or null for none. Undefined when unusable.
76
+ */
77
+ function parseMonthlyBudget(value) {
78
+ if (value === null || value === undefined || value === '') return null;
79
+ const number = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value.trim()) : NaN);
80
+ if (!Number.isFinite(number) || number < 0 || number > MAX_MONTHLY_BUDGET) return undefined;
81
+ const rounded = Math.round(number * 100) / 100;
82
+ return rounded > 0 ? rounded : null;
83
+ }
84
+
85
+ /**
86
+ * The budget's currency from a request or the store: `USD` or `CNY`, or null
87
+ * for none (the balance's currency). Undefined when unusable.
88
+ */
89
+ function parseMonthlyBudgetCurrency(value) {
90
+ if (value === null || value === undefined || value === '') return null;
91
+ if (typeof value !== 'string') return undefined;
92
+ const currency = value.trim().toUpperCase();
93
+ if (!currency) return null;
94
+ return DEEPSEEK_PRICE_CURRENCIES.includes(currency) ? currency : undefined;
95
+ }
96
+
97
+ /**
98
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, baseUrl?, attribution?,
99
+ * monthlyBudget?, monthlyBudgetCurrency?, startingBalance?,
100
+ * startingBalanceEdited?, remove? }` from a
101
+ * request body. The
102
+ * key is never echoed in an error.
103
+ */
104
+ export function parseDeepSeekSettingsUpdateRequest(body = {}) {
105
+ const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
106
+ if (payload.remove === true) return { ok: true, remove: true, enabled: false };
107
+ const update = { remove: false };
108
+ if (hasOwn(payload, 'apiKey')) {
109
+ if (typeof payload.apiKey !== 'string') return { ok: false, error: 'apiKey must be a string' };
110
+ const apiKey = payload.apiKey.trim();
111
+ if (apiKey && !/^[\x21-\x7e]{8,512}$/.test(apiKey)) return { ok: false, error: 'The DeepSeek API key has an invalid format' };
112
+ if (apiKey) update.apiKey = apiKey;
113
+ }
114
+ if (hasOwn(payload, 'enabled')) {
115
+ if (typeof payload.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' };
116
+ update.enabled = payload.enabled;
117
+ }
118
+ if (hasOwn(payload, 'model')) {
119
+ const model = toText(payload.model) || DEFAULT_DEEPSEEK_MODEL;
120
+ if (!isUsableModelId(model)) return { ok: false, error: 'Invalid DeepSeek model ID' };
121
+ update.model = model;
122
+ }
123
+ if (hasOwn(payload, 'enabledModels')) {
124
+ if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
125
+ update.enabledModels = uniqueModels(payload.enabledModels);
126
+ }
127
+ if (hasOwn(payload, 'baseUrl')) {
128
+ const normalized = normalizeDeepSeekBaseUrl(payload.baseUrl === null ? '' : payload.baseUrl);
129
+ if (!normalized.ok) return { ok: false, error: normalized.error };
130
+ update.baseUrl = normalized.baseUrl;
131
+ }
132
+ if (hasOwn(payload, 'attribution')) {
133
+ const attribution = toText(payload.attribution).toLowerCase();
134
+ if (!DEEPSEEK_ATTRIBUTION_MODES.includes(attribution)) return { ok: false, error: 'attribution must be "oar" or "off"' };
135
+ update.attribution = attribution;
136
+ }
137
+ if (hasOwn(payload, 'monthlyBudget')) {
138
+ const monthlyBudget = parseMonthlyBudget(payload.monthlyBudget);
139
+ if (monthlyBudget === undefined) return { ok: false, error: 'monthlyBudget must be an amount of zero or more, or empty' };
140
+ update.monthlyBudget = monthlyBudget;
141
+ }
142
+ if (hasOwn(payload, 'startingBalance')) {
143
+ const startingBalance = parseMonthlyBudget(payload.startingBalance);
144
+ if (startingBalance === undefined) return { ok: false, error: 'startingBalance must be an amount of zero or more, or empty' };
145
+ update.startingBalance = startingBalance;
146
+ }
147
+ // The tab's field was typed in: lets a replaced starting balance be set anew.
148
+ if (hasOwn(payload, 'startingBalanceEdited')) {
149
+ if (typeof payload.startingBalanceEdited !== 'boolean') return { ok: false, error: 'startingBalanceEdited must be true or false' };
150
+ if (hasOwn(update, 'startingBalance')) update.startingBalanceEdited = payload.startingBalanceEdited;
151
+ }
152
+ if (hasOwn(payload, 'monthlyBudgetCurrency')) {
153
+ const monthlyBudgetCurrency = parseMonthlyBudgetCurrency(payload.monthlyBudgetCurrency);
154
+ if (monthlyBudgetCurrency === undefined) return { ok: false, error: 'monthlyBudgetCurrency must be "USD", "CNY" or empty' };
155
+ update.monthlyBudgetCurrency = monthlyBudgetCurrency;
156
+ }
157
+ if (Object.keys(update).length === 1) return { ok: false, error: 'No DeepSeek settings update provided' };
158
+ if (update.apiKey && !hasOwn(update, 'enabled')) update.enabled = true;
159
+ return { ok: true, ...update };
160
+ }
161
+
162
+ export function createDeepSeekSettingsService({
163
+ readSetting = () => '',
164
+ writeSetting = null,
165
+ deleteSetting = null,
166
+ // Runtime sessions with their message and queue counts
167
+ // (stmts.listRuntimeSessionProviderCandidates); guards the key removal.
168
+ listProviderCandidates = () => [],
169
+ fetchImpl = globalThis.fetch,
170
+ now = () => new Date(),
171
+ } = {}) {
172
+ function readList(settingKey) {
173
+ try {
174
+ const parsed = JSON.parse(readSetting(settingKey) || '[]');
175
+ return Array.isArray(parsed) ? uniqueModels(parsed) : [];
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ function readModel() {
182
+ const stored = toText(readSetting(DEEPSEEK_MODEL_SETTING_KEY));
183
+ return stored && isUsableModelId(stored) ? stored : DEFAULT_DEEPSEEK_MODEL;
184
+ }
185
+
186
+ function readBaseUrl() {
187
+ const normalized = normalizeDeepSeekBaseUrl(readSetting(DEEPSEEK_BASE_URL_SETTING_KEY));
188
+ return normalized.ok ? normalized.baseUrl : DEFAULT_DEEPSEEK_BASE_URL;
189
+ }
190
+
191
+ function readAttribution() {
192
+ const stored = toText(readSetting(DEEPSEEK_ATTRIBUTION_SETTING_KEY)).toLowerCase();
193
+ return DEEPSEEK_ATTRIBUTION_MODES.includes(stored) ? stored : DEFAULT_DEEPSEEK_ATTRIBUTION;
194
+ }
195
+
196
+ function readMonthlyBudget() {
197
+ return parseMonthlyBudget(toText(readSetting(DEEPSEEK_MONTHLY_BUDGET_SETTING_KEY))) ?? null;
198
+ }
199
+
200
+ /** The budget's currency, or null: no budget, or one saved before it was stored. */
201
+ function readMonthlyBudgetCurrency() {
202
+ if (readMonthlyBudget() === null) return null;
203
+ return parseMonthlyBudgetCurrency(toText(readSetting(DEEPSEEK_MONTHLY_BUDGET_CURRENCY_SETTING_KEY))) ?? null;
204
+ }
205
+
206
+ function readStartingBalance() {
207
+ return parseMonthlyBudget(toText(readSetting(DEEPSEEK_STARTING_BALANCE_SETTING_KEY))) ?? null;
208
+ }
209
+
210
+ function readStartingBalanceSetAt() {
211
+ if (readStartingBalance() === null) return null;
212
+ const stored = toText(readSetting(DEEPSEEK_STARTING_BALANCE_SET_AT_SETTING_KEY));
213
+ return stored && Number.isFinite(Date.parse(stored)) ? new Date(stored).toISOString() : null;
214
+ }
215
+
216
+ /** Everything stored, the key included: for the relay, never the browser. */
217
+ function getSettings() {
218
+ const apiKey = toText(readSetting(DEEPSEEK_API_KEY_SETTING_KEY));
219
+ const enabledSetting = toText(readSetting(DEEPSEEK_ENABLED_SETTING_KEY));
220
+ const model = readModel();
221
+ const discovered = readList(DEEPSEEK_MODELS_SETTING_KEY);
222
+ const availableModels = uniqueModels([model, ...(discovered.length ? discovered : DEFAULT_DEEPSEEK_MODELS)]);
223
+ const storedEnabled = readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY)
224
+ .filter((entry) => availableModels.some((candidate) => candidate.toLowerCase() === entry.toLowerCase()));
225
+ // The default model always stays enabled; with no choice stored, all are.
226
+ const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
227
+ return {
228
+ configured: !!apiKey,
229
+ enabled: !!apiKey && (enabledSetting === '' || enabledSetting === 'true'),
230
+ apiKey,
231
+ model,
232
+ models,
233
+ availableModels,
234
+ discovered: discovered.length > 0,
235
+ baseUrl: readBaseUrl(),
236
+ attribution: readAttribution(),
237
+ monthlyBudget: readMonthlyBudget(),
238
+ monthlyBudgetCurrency: readMonthlyBudgetCurrency(),
239
+ startingBalance: readStartingBalance(),
240
+ startingBalanceSetAt: readStartingBalanceSetAt(),
241
+ efforts: [...DEEPSEEK_REASONING_EFFORTS],
242
+ providerType: DEEPSEEK_PROVIDER_TYPE,
243
+ };
244
+ }
245
+
246
+ /** What the settings tab and the socket get: the key only as "configured". */
247
+ function describe() {
248
+ const { apiKey: _apiKey, ...rest } = getSettings();
249
+ return { ...rest, defaultBaseUrl: DEFAULT_DEEPSEEK_BASE_URL };
250
+ }
251
+
252
+ function countConversations() {
253
+ const rows = (() => {
254
+ try {
255
+ const listed = listProviderCandidates();
256
+ return Array.isArray(listed) ? listed : [];
257
+ } catch {
258
+ return [];
259
+ }
260
+ })().filter((row) => toText(row?.provider_type).toLowerCase() === DEEPSEEK_PROVIDER_TYPE);
261
+ const started = rows.filter((row) => Number(row?.message_count || 0) > 0).length;
262
+ const queued = rows.filter((row) => Number(row?.active_queue_count || 0) > 0).length;
263
+ const active = rows.filter((row) => Number(row?.message_count || 0) > 0 || Number(row?.active_queue_count || 0) > 0).length;
264
+ return { active, started, queued };
265
+ }
266
+
267
+ /**
268
+ * Applies a parsed update. Returns `{ ok, keyChanged, discover, ... }`:
269
+ * `keyChanged` tells the caller to stop the live workers (they hold the old
270
+ * key), `discover` that model discovery should run. `startingBalanceReplaced`:
271
+ * a top-up since the stored starting balance was set has replaced it (the
272
+ * caller knows the balance): saved again with `startingBalanceEdited` (the
273
+ * field was typed in), the same amount too, it is set anew; without the
274
+ * flag the same amount leaves it replaced.
275
+ */
276
+ function update(parsed = {}, { startingBalanceReplaced = false } = {}) {
277
+ if (typeof writeSetting !== 'function' || typeof deleteSetting !== 'function') {
278
+ return { ok: false, error: 'DeepSeek settings are unavailable' };
279
+ }
280
+ const previous = getSettings();
281
+ const stamp = now().toISOString();
282
+ if (parsed.remove === true) {
283
+ const counts = countConversations();
284
+ if (counts.active > 0) {
285
+ return {
286
+ ok: false,
287
+ code: 'deepseek-key-removal-blocked',
288
+ error: `Cannot remove the DeepSeek API key while ${counts.active} DeepSeek conversation(s) still exist. Switch DeepSeek off for new conversations instead.`,
289
+ activeConversationCount: counts.active,
290
+ startedConversationCount: counts.started,
291
+ activeQueueConversationCount: counts.queued,
292
+ };
293
+ }
294
+ deleteSetting(DEEPSEEK_API_KEY_SETTING_KEY);
295
+ deleteSetting(DEEPSEEK_MODELS_SETTING_KEY);
296
+ deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
297
+ writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, 'false', stamp);
298
+ return { ok: true, removed: true, keyChanged: previous.configured, discover: false, settings: describe() };
299
+ }
300
+ const apiKey = toText(parsed.apiKey);
301
+ const keyChanged = !!apiKey && apiKey !== previous.apiKey;
302
+ const hasKey = !!(apiKey || previous.apiKey);
303
+ const nextEnabled = typeof parsed.enabled === 'boolean' ? parsed.enabled : previous.enabled;
304
+ if (nextEnabled && !hasKey) return { ok: false, error: 'DeepSeek API key is not configured' };
305
+ const baseUrlChanged = hasOwn(parsed, 'baseUrl') && parsed.baseUrl !== previous.baseUrl;
306
+ if (keyChanged) writeSetting(DEEPSEEK_API_KEY_SETTING_KEY, apiKey, stamp);
307
+ // Another key or endpoint may serve other models: the old list goes.
308
+ if (keyChanged || baseUrlChanged) deleteSetting(DEEPSEEK_MODELS_SETTING_KEY);
309
+ if (hasOwn(parsed, 'baseUrl')) {
310
+ if (parsed.baseUrl && parsed.baseUrl !== DEFAULT_DEEPSEEK_BASE_URL) {
311
+ writeSetting(DEEPSEEK_BASE_URL_SETTING_KEY, parsed.baseUrl, stamp);
312
+ } else {
313
+ deleteSetting(DEEPSEEK_BASE_URL_SETTING_KEY);
314
+ }
315
+ }
316
+ if (hasOwn(parsed, 'enabled') || keyChanged) writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', stamp);
317
+ if (hasOwn(parsed, 'model')) writeSetting(DEEPSEEK_MODEL_SETTING_KEY, parsed.model, stamp);
318
+ if (hasOwn(parsed, 'enabledModels')) {
319
+ if (parsed.enabledModels.length) {
320
+ writeSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
321
+ } else {
322
+ deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
323
+ }
324
+ }
325
+ if (hasOwn(parsed, 'attribution')) writeSetting(DEEPSEEK_ATTRIBUTION_SETTING_KEY, parsed.attribution, stamp);
326
+ if (hasOwn(parsed, 'monthlyBudget')) {
327
+ if (parsed.monthlyBudget === null) {
328
+ // No budget, no currency to keep for it.
329
+ deleteSetting(DEEPSEEK_MONTHLY_BUDGET_SETTING_KEY);
330
+ deleteSetting(DEEPSEEK_MONTHLY_BUDGET_CURRENCY_SETTING_KEY);
331
+ } else {
332
+ writeSetting(DEEPSEEK_MONTHLY_BUDGET_SETTING_KEY, String(parsed.monthlyBudget), stamp);
333
+ }
334
+ }
335
+ if (hasOwn(parsed, 'monthlyBudgetCurrency') && parsed.monthlyBudget !== null) {
336
+ if (parsed.monthlyBudgetCurrency === null) deleteSetting(DEEPSEEK_MONTHLY_BUDGET_CURRENCY_SETTING_KEY);
337
+ else writeSetting(DEEPSEEK_MONTHLY_BUDGET_CURRENCY_SETTING_KEY, parsed.monthlyBudgetCurrency, stamp);
338
+ }
339
+ if (hasOwn(parsed, 'startingBalance')) {
340
+ if (parsed.startingBalance === null) {
341
+ deleteSetting(DEEPSEEK_STARTING_BALANCE_SETTING_KEY);
342
+ deleteSetting(DEEPSEEK_STARTING_BALANCE_SET_AT_SETTING_KEY);
343
+ } else if (
344
+ parsed.startingBalance !== previous.startingBalance
345
+ || !previous.startingBalanceSetAt
346
+ || (startingBalanceReplaced && parsed.startingBalanceEdited === true)
347
+ ) {
348
+ // Saved again unchanged while it applies, or replaced but not typed
349
+ // in again, it keeps its time.
350
+ writeSetting(DEEPSEEK_STARTING_BALANCE_SETTING_KEY, String(parsed.startingBalance), stamp);
351
+ writeSetting(DEEPSEEK_STARTING_BALANCE_SET_AT_SETTING_KEY, stamp, stamp);
352
+ }
353
+ }
354
+ const current = getSettings();
355
+ const discover = current.enabled && (keyChanged || baseUrlChanged || !previous.enabled || !current.discovered);
356
+ return { ok: true, removed: false, keyChanged: keyChanged && previous.configured, discover, settings: describe() };
357
+ }
358
+
359
+ /**
360
+ * Asks DeepSeek which models it serves and stores the DeepSeek ones. A
361
+ * failure keeps what was stored, so the pickers never empty out.
362
+ */
363
+ async function refreshModels() {
364
+ const settings = getSettings();
365
+ if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'DeepSeek API key is not configured' };
366
+ try {
367
+ const client = createDeepSeekApiClient({ baseUrl: settings.baseUrl, apiKey: settings.apiKey, fetchImpl });
368
+ const models = uniqueModels(await client.listModels());
369
+ if (!models.length) {
370
+ return { ok: false, models: settings.availableModels, error: 'DeepSeek listed no DeepSeek models' };
371
+ }
372
+ writeSetting?.(DEEPSEEK_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
373
+ return { ok: true, models: getSettings().availableModels, error: null };
374
+ } catch (error) {
375
+ return { ok: false, models: settings.availableModels, error: error?.message || 'DeepSeek model discovery failed', code: error?.code || null };
376
+ }
377
+ }
378
+
379
+ return { getSettings, describe, update, refreshModels };
380
+ }