@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,318 @@
1
+ // A provider's saved key out of what session workers post.
2
+ //
3
+ // A provider whose key the relay keeps in its settings (DeepSeek) hands that
4
+ // key to its own workers, and their agent can see it: in its shell's
5
+ // environment, in a file it reads, in an error the API quotes. Whatever the
6
+ // agent prints can come back to the relay as a reply, a stream update, a
7
+ // thought, tool activity, a sub-agent's text, a question or task output. So
8
+ // every occurrence of a saved key is replaced with `[redacted]` in the body of
9
+ // every request a session worker sends, before any route stores, broadcasts
10
+ // or logs it. This keeps the key out of what the relay stores and shows; it
11
+ // is no boundary against the agent, which can change the text, call the
12
+ // relay's API without the worker header or read the relay's database.
13
+ //
14
+ // - One choke point: a middleware after the JSON body parser. A worker
15
+ // request is one that carries the `X-Relay-Session-Id` header every
16
+ // worker's API client sends, even empty (the Copilot extension sends it
17
+ // empty until its session is up); browsers never send it.
18
+ // - Every worker's posts lose every saved key, not only those of the
19
+ // provider the key belongs to: a chat that changes provider between turns,
20
+ // or an agent that reads the relay's own files, would otherwise slip past.
21
+ // - A key changed or removed in Settings is still redacted: a worker started
22
+ // before the change still holds the old one. The middleware keeps the keys
23
+ // saved since the relay started (MAX_REMEMBERED_SECRETS, in memory only,
24
+ // never logged): those it reads when it is created, each one the settings
25
+ // save path notes (noteSavedSetting) and those it reads on each post.
26
+ // - The first part of a key that was cut goes too (redactString): at the
27
+ // end of a text, since stream and thought updates carry the text so far and
28
+ // go out before the key is complete, and before an ellipsis anywhere in a
29
+ // text, since a worker clips a long text and may go on after the clip (the
30
+ // remote-relay activity line ends in `…”`). The last part of a key at the
31
+ // very start of a text goes as well: a task-output snapshot is the output's
32
+ // last 8 KiB and can begin halfway through a key.
33
+ // - Property names are rewritten like values (`{ "<key>": 1 }` becomes
34
+ // `{ "[redacted]": 1 }`), so a key cannot ride along as one.
35
+ // - A key shorter than MIN_REDACTED_SECRET_LENGTH is left alone, so a
36
+ // mistyped setting cannot mangle ordinary text.
37
+ // - The settings routes are never rewritten: a key saved there must arrive
38
+ // as it was typed.
39
+ // - Task output the relay reads from a task's file does not pass here; it
40
+ // goes through cleanTaskOutput (shared/task-output-text.mjs), whose rules
41
+ // take out `sk-` keys (DeepSeek's and Anthropic's shape) and any other long
42
+ // random-looking token, though not by the exact saved text. The snapshots
43
+ // a worker posts for a task without a file do pass here.
44
+ //
45
+ // A provider registers its key by one entry in SAVED_PROVIDER_KEY_SETTINGS.
46
+
47
+ import { DEEPSEEK_PROVIDER_TYPE } from '../../shared/deepseek-models.mjs';
48
+ import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
49
+
50
+ export const REDACTED_SECRET_TEXT = '[redacted]';
51
+ export const MIN_REDACTED_SECRET_LENGTH = 8;
52
+ // The shortest part of a key that is taken out where a text was cut (its
53
+ // first part) or at a text's start (its last part). A shorter part stays. It
54
+ // holds at most 7 characters of the key, for DeepSeek's shape (`sk-` and 32
55
+ // hex digits) the public `sk-` and four digits, too few to help anyone guess
56
+ // it. And a shorter run of ordinary text, the bare `sk-` or a word a key
57
+ // happens to start with, is no key and must not turn into `[redacted]`.
58
+ export const MIN_REDACTED_KEY_PREFIX_LENGTH = 8;
59
+ // How many keys the middleware remembers; the oldest goes first. A saved key
60
+ // is changed by hand, so this many covers every worker still running.
61
+ export const MAX_REMEMBERED_SECRETS = 16;
62
+ export const WORKER_SESSION_HEADER = 'x-relay-session-id';
63
+
64
+ /** The providers whose workers get a key saved in the relay's settings. */
65
+ export const SAVED_PROVIDER_KEY_SETTINGS = Object.freeze([
66
+ Object.freeze({ provider: DEEPSEEK_PROVIDER_TYPE, settingKey: DEEPSEEK_API_KEY_SETTING_KEY }),
67
+ ]);
68
+
69
+ // JSON bodies are shallow in practice; a deeper one is left as it is below
70
+ // this depth rather than walked without end.
71
+ const MAX_DEPTH = 64;
72
+ // What a worker puts after a text it cut short (a tool line at 140
73
+ // characters, the remote-relay line's quoted text at 60), with the space
74
+ // some put before it. Matched where a key's first part stops: the text may go
75
+ // on after it (`…”`, `…” (now)`, `…)`).
76
+ const ELLIPSIS_AT = /\s*(?:…|⋯|\.{2,}|\[truncated\])/y;
77
+ // The same marks at the start of a text that begins inside something.
78
+ const LEADING_ELLIPSIS = /^(?:(?:…|⋯|\.{2,}|\[truncated\])\s*)?/;
79
+
80
+ function normalizeSecrets(secrets) {
81
+ const unique = new Set();
82
+ for (const entry of Array.isArray(secrets) ? secrets : []) {
83
+ const value = String(entry ?? '').trim();
84
+ if (value.length >= MIN_REDACTED_SECRET_LENGTH) unique.add(value);
85
+ }
86
+ // Longest first; where secrets overlap in a text, redactString joins what
87
+ // each of them covers.
88
+ return [...unique].sort((a, b) => b.length - a.length);
89
+ }
90
+
91
+ /** Whether a key's first part that stops at `at` was cut there: the text ends, or an ellipsis follows. */
92
+ function isCutAt(text, at) {
93
+ if (at === text.length) return true;
94
+ ELLIPSIS_AT.lastIndex = at;
95
+ return ELLIPSIS_AT.test(text);
96
+ }
97
+
98
+ /**
99
+ * Pushes `[start, end)` for each run in `text` that is the first
100
+ * MIN_REDACTED_KEY_PREFIX_LENGTH or more characters of `secret` (not all of
101
+ * it) and is cut: at the end of the text or before an ellipsis, wherever that
102
+ * is. Of the runs at one place, the longest that is cut counts.
103
+ */
104
+ function pushKeyPrefixRanges(text, secret, ranges) {
105
+ if (secret.length <= MIN_REDACTED_KEY_PREFIX_LENGTH) return;
106
+ const head = secret.slice(0, MIN_REDACTED_KEY_PREFIX_LENGTH);
107
+ for (let index = text.indexOf(head); index !== -1; index = text.indexOf(head, index + 1)) {
108
+ // How far the text goes on as the key does (short of the whole key, a
109
+ // range of its own).
110
+ const most = Math.min(secret.length - 1, text.length - index);
111
+ let matched = MIN_REDACTED_KEY_PREFIX_LENGTH;
112
+ while (matched < most && text.charCodeAt(index + matched) === secret.charCodeAt(matched)) matched += 1;
113
+ for (let length = matched; length >= MIN_REDACTED_KEY_PREFIX_LENGTH; length -= 1) {
114
+ if (isCutAt(text, index + length)) {
115
+ ranges.push([index, index + length]);
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Pushes `[start, end)` when `text`, from `start` on, begins with the last
124
+ * MIN_REDACTED_KEY_PREFIX_LENGTH or more characters of `secret` (not all of
125
+ * it): the longest such end of the key.
126
+ */
127
+ function pushKeySuffixRange(text, start, secret, ranges) {
128
+ if (secret.length <= MIN_REDACTED_KEY_PREFIX_LENGTH) return;
129
+ const lead = text.slice(start, start + MIN_REDACTED_KEY_PREFIX_LENGTH);
130
+ if (lead.length < MIN_REDACTED_KEY_PREFIX_LENGTH) return;
131
+ // Where in the key the text may start, from 1 (not the whole key) on: the
132
+ // first place that fits leaves the longest end.
133
+ for (let at = secret.indexOf(lead, 1); at !== -1; at = secret.indexOf(lead, at + 1)) {
134
+ if (text.startsWith(secret.slice(at), start)) {
135
+ ranges.push([start, start + secret.length - at]);
136
+ return;
137
+ }
138
+ }
139
+ }
140
+
141
+ /** `text` with each range replaced; ranges that overlap or touch become one, so the longest part wins. */
142
+ function replaceRanges(text, ranges) {
143
+ if (!ranges.length) return text;
144
+ ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
145
+ let output = '';
146
+ let emitted = 0;
147
+ let [start, end] = ranges[0];
148
+ for (let index = 1; index < ranges.length; index += 1) {
149
+ const [nextStart, nextEnd] = ranges[index];
150
+ if (nextStart <= end) {
151
+ end = Math.max(end, nextEnd);
152
+ continue;
153
+ }
154
+ output += `${text.slice(emitted, start)}${REDACTED_SECRET_TEXT}`;
155
+ emitted = end;
156
+ [start, end] = [nextStart, nextEnd];
157
+ }
158
+ return `${output}${text.slice(emitted, start)}${REDACTED_SECRET_TEXT}${text.slice(end)}`;
159
+ }
160
+
161
+ /**
162
+ * `text` with every secret and the parts of one a cut left in it replaced:
163
+ * each whole occurrence, its first part where the text was cut
164
+ * (pushKeyPrefixRanges) and its last part at the very start of the text,
165
+ * after an ellipsis it may begin with (pushKeySuffixRange). All are found in
166
+ * the text as it came, of every secret, and replaced together, so where they
167
+ * overlap the longest wins: a key that holds another saved key goes whole,
168
+ * also when only a part of it is there.
169
+ */
170
+ function redactString(text, secrets) {
171
+ const ranges = [];
172
+ const start = LEADING_ELLIPSIS.exec(text)[0].length;
173
+ for (const secret of secrets) {
174
+ for (let index = text.indexOf(secret); index !== -1; index = text.indexOf(secret, index + 1)) {
175
+ ranges.push([index, index + secret.length]);
176
+ }
177
+ pushKeyPrefixRanges(text, secret, ranges);
178
+ pushKeySuffixRange(text, start, secret, ranges);
179
+ }
180
+ return replaceRanges(text, ranges);
181
+ }
182
+
183
+ /**
184
+ * `text` with every occurrence of each secret replaced, a first part of one
185
+ * cut at the text's end or before an ellipsis and a last part of one the text
186
+ * starts with; anything that is not a string as it is.
187
+ */
188
+ export function redactSecretsInText(text, secrets = []) {
189
+ if (typeof text !== 'string' || !text) return text;
190
+ return redactString(text, normalizeSecrets(secrets));
191
+ }
192
+
193
+ function redactValue(value, secrets, depth) {
194
+ if (typeof value === 'string') return value ? redactString(value, secrets) : value;
195
+ if (!value || typeof value !== 'object' || depth >= MAX_DEPTH) return value;
196
+ if (Array.isArray(value)) {
197
+ let copy = null;
198
+ for (let index = 0; index < value.length; index += 1) {
199
+ const next = redactValue(value[index], secrets, depth + 1);
200
+ if (next !== value[index]) {
201
+ copy ||= value.slice();
202
+ copy[index] = next;
203
+ }
204
+ }
205
+ return copy || value;
206
+ }
207
+ const prototype = Object.getPrototypeOf(value);
208
+ if (prototype !== Object.prototype && prototype !== null) return value;
209
+ // Property names too: a key can be one (`{ "<key>": 1 }`). Once a name or a
210
+ // value changes, the entries are copied in their order; two names that come
211
+ // out the same keep the later value.
212
+ const entries = Object.entries(value);
213
+ let copied = null;
214
+ for (let index = 0; index < entries.length; index += 1) {
215
+ const [key, entry] = entries[index];
216
+ const nextKey = key ? redactString(key, secrets) : key;
217
+ const next = redactValue(entry, secrets, depth + 1);
218
+ if (!copied) {
219
+ if (nextKey === key && next === entry) continue;
220
+ copied = entries.slice(0, index);
221
+ }
222
+ copied.push([nextKey, next]);
223
+ }
224
+ // fromEntries defines each property, so a `__proto__` name stays a name.
225
+ return copied ? Object.fromEntries(copied) : value;
226
+ }
227
+
228
+ /**
229
+ * Every string in a JSON value (plain objects and arrays, at any depth),
230
+ * property names included, with the secrets replaced. The value itself comes
231
+ * back when nothing changed; otherwise only the changed branches are copies.
232
+ */
233
+ export function redactSecretsDeep(value, secrets = []) {
234
+ const normalized = normalizeSecrets(secrets);
235
+ if (!normalized.length) return value;
236
+ return redactValue(value, normalized, 0);
237
+ }
238
+
239
+ /** The saved keys of the registered providers, as `readSetting(settingKey)` has them. */
240
+ export function readSavedProviderKeys({ readSetting = () => '', entries = SAVED_PROVIDER_KEY_SETTINGS } = {}) {
241
+ return normalizeSecrets(entries.map((entry) => readSetting(entry.settingKey)));
242
+ }
243
+
244
+ /** Whether a request is a session worker's post with a body to scrub: the header present, even empty. */
245
+ export function isWorkerPostRequest(req) {
246
+ const method = String(req?.method || '').toUpperCase();
247
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
248
+ if (req?.headers?.[WORKER_SESSION_HEADER] === undefined) return false;
249
+ return !/^\/api\/settings(?:\/|$)/.test(String(req?.path || ''));
250
+ }
251
+
252
+ /**
253
+ * The middleware: mounted after `express.json()`, before every route. It
254
+ * reads the saved keys once when it is created (the relay's start) and per
255
+ * request (one settings read per provider), so a key saved applies to the
256
+ * next post, and redacts them with every key read or noted before (see the
257
+ * note on key changes above). `noteSavedSetting(settingKey, value)`, on the
258
+ * function returned, is for the settings save path: a registered key saved
259
+ * there is remembered at once, so it stays redacted even when it is changed
260
+ * again before any worker posts.
261
+ */
262
+ export function createWorkerPostRedaction({
263
+ readSetting = () => '',
264
+ entries = SAVED_PROVIDER_KEY_SETTINGS,
265
+ log = (line) => console.warn(line),
266
+ } = {}) {
267
+ let warned = false;
268
+ // Oldest first; a key read again moves to the end.
269
+ const remembered = new Set();
270
+ let secrets = [];
271
+ const settingKeys = new Set(entries.map((entry) => entry.settingKey));
272
+
273
+ function remember(current) {
274
+ let changed = false;
275
+ for (const key of current) {
276
+ if (remembered.delete(key)) {
277
+ remembered.add(key);
278
+ continue;
279
+ }
280
+ remembered.add(key);
281
+ changed = true;
282
+ }
283
+ while (remembered.size > MAX_REMEMBERED_SECRETS) {
284
+ remembered.delete(remembered.values().next().value);
285
+ changed = true;
286
+ }
287
+ if (changed) secrets = normalizeSecrets([...remembered]);
288
+ }
289
+
290
+ function readAndRemember() {
291
+ try {
292
+ remember(readSavedProviderKeys({ readSetting, entries }));
293
+ } catch (error) {
294
+ // The settings could not be read: the post loses the keys read before,
295
+ // and the relay says so once rather than refusing every worker post.
296
+ if (!warned) {
297
+ warned = true;
298
+ try { log(`worker post redaction: the saved provider keys could not be read, only those read before are redacted (${error?.message || error})`); } catch {}
299
+ }
300
+ }
301
+ }
302
+
303
+ function workerPostRedaction(req, _res, next) {
304
+ if (!isWorkerPostRequest(req) || !req.body || typeof req.body !== 'object') return next();
305
+ readAndRemember();
306
+ if (secrets.length) req.body = redactValue(req.body, secrets, 0);
307
+ return next();
308
+ }
309
+
310
+ /** Remembers `value` when `settingKey` is a registered provider key; any other setting is ignored. */
311
+ workerPostRedaction.noteSavedSetting = function noteSavedSetting(settingKey, value) {
312
+ if (!settingKeys.has(settingKey)) return;
313
+ remember(normalizeSecrets([value]));
314
+ };
315
+
316
+ readAndRemember();
317
+ return workerPostRedaction;
318
+ }
@@ -50,6 +50,9 @@ export const MODEL_FALLBACK_LIMITS = Object.freeze({
50
50
  // (public xAI docs values); also reachable through the Cursor catalog.
51
51
  'grok-4': 256000,
52
52
  'grok-code-fast-1': 256000,
53
+ // DeepSeek (api-docs.deepseek.com, list-models: context_window 1048576).
54
+ 'deepseek-flash': 1048576,
55
+ 'deepseek-v4-pro': 1048576,
53
56
  });
54
57
 
55
58
  /**
@@ -0,0 +1,137 @@
1
+ // A small client for the two DeepSeek endpoints the relay calls itself:
2
+ // `GET /models` (model discovery) and `GET /user/balance` (the usage card).
3
+ // Turns do not go through here: the session worker runs Claude Code against
4
+ // DeepSeek's Anthropic-compatible endpoint (`deepseekAnthropicBaseUrl`).
5
+ //
6
+ // The API key is sent to the configured base URL and nowhere else, and it is
7
+ // in no error this module produces: a response body is quoted only after the
8
+ // key has been taken out of it.
9
+
10
+ import { DEFAULT_DEEPSEEK_BASE_URL } from './deepseek-models.mjs';
11
+
12
+ const DEFAULT_TIMEOUT_MS = 10_000;
13
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
14
+
15
+ /**
16
+ * The base URL as stored: `https:` on any host, or `http:` on a loopback host
17
+ * (a local proxy, a capture tool, the tests' fake API). No credentials, query
18
+ * or fragment; trailing slashes are dropped. An empty value is the default.
19
+ */
20
+ export function normalizeDeepSeekBaseUrl(value) {
21
+ const raw = String(value ?? '').trim();
22
+ if (!raw) return { ok: true, baseUrl: DEFAULT_DEEPSEEK_BASE_URL };
23
+ let url;
24
+ try {
25
+ url = new URL(raw);
26
+ } catch {
27
+ return { ok: false, error: 'The DeepSeek base URL is not a valid URL' };
28
+ }
29
+ if (url.username || url.password) return { ok: false, error: 'The DeepSeek base URL must not contain credentials' };
30
+ if (url.search || url.hash) return { ok: false, error: 'The DeepSeek base URL must not contain a query or fragment' };
31
+ const loopback = LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
32
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
33
+ return { ok: false, error: 'The DeepSeek base URL must use https (http only on a loopback address)' };
34
+ }
35
+ const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`;
36
+ return { ok: true, baseUrl };
37
+ }
38
+
39
+ /** Where Claude Code is pointed for DeepSeek turns. */
40
+ export function deepseekAnthropicBaseUrl(baseUrl = DEFAULT_DEEPSEEK_BASE_URL) {
41
+ const normalized = normalizeDeepSeekBaseUrl(baseUrl);
42
+ return `${normalized.ok ? normalized.baseUrl : DEFAULT_DEEPSEEK_BASE_URL}/anthropic`;
43
+ }
44
+
45
+ export class DeepSeekApiError extends Error {
46
+ constructor(message, { code = 'error', status = null } = {}) {
47
+ super(message);
48
+ this.name = 'DeepSeekApiError';
49
+ this.code = code;
50
+ this.status = status;
51
+ }
52
+ }
53
+
54
+ function codeForStatus(status) {
55
+ if (status === 401 || status === 403) return 'unauthorized';
56
+ if (status === 402) return 'insufficient_balance';
57
+ if (status === 429) return 'rate_limited';
58
+ if (status >= 500) return 'transient';
59
+ return 'error';
60
+ }
61
+
62
+ function redact(text, apiKey) {
63
+ const value = String(text ?? '');
64
+ const key = String(apiKey ?? '').trim();
65
+ return key ? value.split(key).join('[redacted]') : value;
66
+ }
67
+
68
+ function toAmount(value) {
69
+ const number = Number(value);
70
+ return Number.isFinite(number) ? number : null;
71
+ }
72
+
73
+ export function createDeepSeekApiClient({
74
+ baseUrl = DEFAULT_DEEPSEEK_BASE_URL,
75
+ apiKey = '',
76
+ fetchImpl = globalThis.fetch,
77
+ timeoutMs = DEFAULT_TIMEOUT_MS,
78
+ } = {}) {
79
+ const normalized = normalizeDeepSeekBaseUrl(baseUrl);
80
+ const key = String(apiKey ?? '').trim();
81
+
82
+ async function getJson(pathname, what) {
83
+ if (!normalized.ok) throw new DeepSeekApiError(normalized.error, { code: 'invalid_base_url' });
84
+ if (!key) throw new DeepSeekApiError('DeepSeek API key is not configured', { code: 'not_configured' });
85
+ let response;
86
+ try {
87
+ response = await fetchImpl(`${normalized.baseUrl}${pathname}`, {
88
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
89
+ signal: AbortSignal.timeout(timeoutMs),
90
+ });
91
+ } catch (error) {
92
+ throw new DeepSeekApiError(`DeepSeek ${what} failed: ${redact(error?.message || error, key)}`, { code: 'network' });
93
+ }
94
+ if (!response.ok) {
95
+ const detail = redact(String(await response.text().catch(() => '')).trim(), key).slice(0, 240);
96
+ throw new DeepSeekApiError(
97
+ `DeepSeek ${what} failed (${response.status})${detail ? `: ${detail}` : ''}`,
98
+ { code: codeForStatus(response.status), status: response.status },
99
+ );
100
+ }
101
+ try {
102
+ return await response.json();
103
+ } catch {
104
+ throw new DeepSeekApiError(`DeepSeek ${what} returned invalid JSON`, { code: 'invalid_response' });
105
+ }
106
+ }
107
+
108
+ return {
109
+ baseUrl: normalized.ok ? normalized.baseUrl : '',
110
+
111
+ /** The model ids `GET /models` lists, in its order, without duplicates. */
112
+ async listModels() {
113
+ const payload = await getJson('/models', 'model discovery');
114
+ const ids = (Array.isArray(payload?.data) ? payload.data : [])
115
+ .map((entry) => String(entry?.id ?? '').trim())
116
+ .filter(Boolean);
117
+ return Array.from(new Set(ids));
118
+ },
119
+
120
+ /**
121
+ * `GET /user/balance`: `{ isAvailable, balances: [{ currency, total,
122
+ * granted, toppedUp }] }`, amounts as numbers (DeepSeek sends strings).
123
+ */
124
+ async getBalance() {
125
+ const payload = await getJson('/user/balance', 'balance check');
126
+ const balances = (Array.isArray(payload?.balance_infos) ? payload.balance_infos : [])
127
+ .map((entry) => ({
128
+ currency: String(entry?.currency ?? '').trim().toUpperCase(),
129
+ total: toAmount(entry?.total_balance),
130
+ granted: toAmount(entry?.granted_balance),
131
+ toppedUp: toAmount(entry?.topped_up_balance),
132
+ }))
133
+ .filter((entry) => entry.currency);
134
+ return { isAvailable: payload?.is_available === true, balances };
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,114 @@
1
+ // What OAR knows about DeepSeek without asking it: the provider id, the
2
+ // default endpoint, the models to offer when discovery has not run or failed,
3
+ // and the effort levels DeepSeek's thinking mode takes.
4
+ //
5
+ // Source: api-docs.deepseek.com (pricing, list-models and thinking-mode pages,
6
+ // built 2026-10-19). `deepseek-chat` and `deepseek-reasoner` were retired on
7
+ // 2026-07-24; thinking is a request parameter now, not a model of its own.
8
+
9
+ export const DEEPSEEK_PROVIDER_TYPE = 'deepseek';
10
+ export const DEEPSEEK_PROVIDER_LABEL = 'DeepSeek';
11
+
12
+ export const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
13
+ export const DEFAULT_DEEPSEEK_MODEL = 'deepseek-flash';
14
+
15
+ // Context window and output ceiling are the same for both today (1M / 384K).
16
+ export const DEEPSEEK_CONTEXT_WINDOW_TOKENS = 1_048_576;
17
+ export const DEEPSEEK_MAX_OUTPUT_TOKENS = 393_216;
18
+ // When a DeepSeek session compacts unless the conversation pins a window: far
19
+ // below the 1M window, so a turn after the cache has expired does not re-send
20
+ // most of a million tokens at the cache-miss price.
21
+ export const DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW = 400_000;
22
+
23
+ export const DEFAULT_DEEPSEEK_MODELS = Object.freeze(['deepseek-flash', 'deepseek-v4-pro']);
24
+
25
+ const KNOWN_MODELS = Object.freeze({
26
+ 'deepseek-flash': Object.freeze({ label: 'DeepSeek Flash', imageInput: true }),
27
+ 'deepseek-v4-pro': Object.freeze({ label: 'DeepSeek V4 Pro', imageInput: false }),
28
+ // Aliases DeepSeek still accepts; both are served by the Flash model.
29
+ 'deepseek-v4-flash': Object.freeze({ label: 'DeepSeek V4 Flash', imageInput: true }),
30
+ 'deepseek-v4-flash-vision-exp': Object.freeze({ label: 'DeepSeek V4 Flash Vision', imageInput: true }),
31
+ });
32
+
33
+ // The levels a DeepSeek chat offers. DeepSeek folds medium and xhigh into
34
+ // high, so they are not offered. Its `none` (thinking off) is not offered
35
+ // either: Claude Code, which runs DeepSeek turns, never sends
36
+ // `thinking: {type: disabled}` for a DeepSeek model (requests captured
37
+ // 2026-10-24), and DeepSeek thinks unless told not to.
38
+ export const DEEPSEEK_REASONING_EFFORTS = Object.freeze(['low', 'high', 'max']);
39
+ export const DEFAULT_DEEPSEEK_REASONING_EFFORT = 'high';
40
+
41
+ // The levels DeepSeek itself maps other names to (thinking-mode guide), so an
42
+ // agent asking for "medium" gets what DeepSeek would run, not thinking off.
43
+ const DEEPSEEK_EFFORT_ALIASES = Object.freeze({
44
+ none: 'low',
45
+ minimal: 'low',
46
+ medium: 'high',
47
+ xhigh: 'high',
48
+ ultra: 'max',
49
+ ultracode: 'max',
50
+ });
51
+
52
+ /** A requested effort in DeepSeek's terms: one of DEEPSEEK_REASONING_EFFORTS, or '' for unknown. */
53
+ export function deepseekReasoningEffortFor(value) {
54
+ const effort = String(value ?? '').trim().toLowerCase();
55
+ if (!effort) return '';
56
+ if (DEEPSEEK_REASONING_EFFORTS.includes(effort)) return effort;
57
+ return DEEPSEEK_EFFORT_ALIASES[effort] || '';
58
+ }
59
+
60
+ // Commit attribution in DeepSeek chats: OAR's trailer or none. Claude Code's
61
+ // own ("vanilla") lines would credit Claude for DeepSeek's work.
62
+ export const DEEPSEEK_ATTRIBUTION_MODES = Object.freeze(['oar', 'off']);
63
+ export const DEFAULT_DEEPSEEK_ATTRIBUTION = 'oar';
64
+
65
+ function modelKey(value) {
66
+ return String(value ?? '').trim().toLowerCase();
67
+ }
68
+
69
+ /** Whether a model id is one of DeepSeek's (its ids all carry the prefix). */
70
+ export function isDeepSeekModelId(value) {
71
+ return modelKey(value).startsWith('deepseek-');
72
+ }
73
+
74
+ // What DeepSeek's Anthropic-compatible endpoint serves a Claude model id
75
+ // with (api-docs.deepseek.com, guides/anthropic_api): the Opus ids run on V4
76
+ // Pro, the Sonnet and Haiku ids on Flash. Claude Code sends such an id when
77
+ // an alias ("opus") has no ANTHROPIC_DEFAULT_*_MODEL pin, so a report or a
78
+ // badge may name a Claude model DeepSeek never ran.
79
+ const CLAUDE_FAMILIES_SERVED_AS = Object.freeze([
80
+ ['claude-opus', 'deepseek-v4-pro'],
81
+ ['claude-sonnet', 'deepseek-flash'],
82
+ ['claude-haiku', 'deepseek-flash'],
83
+ ]);
84
+
85
+ /**
86
+ * The DeepSeek model a model name stands for, without Claude Code's `[1m]`
87
+ * suffix: a DeepSeek id as given, a Claude id by the model DeepSeek serves it
88
+ * with, '' for a name DeepSeek is not known to serve (an alias, another
89
+ * vendor's id).
90
+ */
91
+ export function deepseekServedModel(value) {
92
+ const id = String(value ?? '').trim().replace(/\[1m\]$/i, '');
93
+ const key = id.toLowerCase();
94
+ if (key.startsWith('deepseek-')) return id;
95
+ for (const [family, model] of CLAUDE_FAMILIES_SERVED_AS) {
96
+ if (key.startsWith(family)) return model;
97
+ }
98
+ return '';
99
+ }
100
+
101
+ /** The display name of a DeepSeek model id: "DeepSeek Flash", or the id itself. */
102
+ export function deepseekModelLabel(value) {
103
+ const id = String(value ?? '').trim();
104
+ return KNOWN_MODELS[modelKey(id)]?.label || id;
105
+ }
106
+
107
+ /**
108
+ * Whether the model takes image input. A model this module does not know is
109
+ * treated as text-only: sending images to one that refuses them fails the
110
+ * whole request, while passing an image as a file path still works.
111
+ */
112
+ export function deepseekModelAcceptsImages(value) {
113
+ return KNOWN_MODELS[modelKey(value)]?.imageInput === true;
114
+ }