@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
@@ -175,10 +175,11 @@ export function createUsageLimitPauseService({
175
175
  * Decide whether a refused turn is paused. Returns the plan, or null when
176
176
  * the response is to be handled as the terminal failure it says it is.
177
177
  */
178
- function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
178
+ function planPause({ terminalError, conversationId, providerType, chatProviderType = null, queueRow = null, partialText = '' } = {}) {
179
179
  if (!isUsageLimitTerminalError(terminalError)) return null;
180
180
  // Claude sessions, local and in the cloud: both run Claude Code on the
181
- // same account and report the same limit.
181
+ // same account and report the same limit. `providerType` is what the
182
+ // refused turn ran on.
182
183
  if (!['claude', 'claude-cloud'].includes(String(providerType || '').trim().toLowerCase())) return null;
183
184
  if (!String(conversationId || '').trim()) return null;
184
185
  const at = now();
@@ -187,6 +188,21 @@ export function createUsageLimitPauseService({
187
188
  // What the turn had written before the refusal stays above the note.
188
189
  const written = String(partialText || '').trim();
189
190
  const withWritten = (note) => (written ? `${written}\n\n${note}` : note);
191
+ // The chat moved to the API key while this subscription turn ran: its
192
+ // follow-up is not held for the subscription's reset, it runs on the key
193
+ // at once (a row on the key is never held for the usage limit).
194
+ if (String(chatProviderType || '').trim().toLowerCase() === 'claude-api') {
195
+ return {
196
+ conversationId: String(conversationId),
197
+ rateLimitType,
198
+ resetsAt: null,
199
+ resumeAt: null,
200
+ auto: false,
201
+ retries: 0,
202
+ onApiKey: true,
203
+ noteText: withWritten(`⏸ Stopped at the Claude ${label}. This chat now runs on the Claude API key, so the turn carries on there.`),
204
+ };
205
+ }
190
206
  const resetsAtMs = Date.parse(String(terminalError.resetsAt || ''));
191
207
  if (!Number.isFinite(resetsAtMs) || resetsAtMs <= at) {
192
208
  // Counted along the follow-ups: the refused row is one itself when the
@@ -232,7 +248,8 @@ export function createUsageLimitPauseService({
232
248
  function schedulePause(plan, { queueRow = null } = {}) {
233
249
  if (!plan?.conversationId) return null;
234
250
  const at = new Date(now()).toISOString();
235
- const heldUntil = plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED;
251
+ // On the key: due now, never held.
252
+ const heldUntil = plan.onApiKey ? null : (plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED);
236
253
  // What this refusal cut off. A refused follow-up stands for what it
237
254
  // quoted; a turn of the CLI's own has no prompt.
238
255
  const refusedFollowUp = parseJson(queueRow?.usage_limit_pause);
@@ -251,6 +268,7 @@ export function createUsageLimitPauseService({
251
268
  resetsAt: plan.resetsAt,
252
269
  auto: plan.auto,
253
270
  retries: Number(plan.retries || 0),
271
+ ...(plan.onApiKey ? { onApiKey: true, resumedAt: at, resumedBy: 'billing-switch' } : {}),
254
272
  pausedAt: at,
255
273
  refusedMessageId: queueRow?.id ? String(queueRow.id) : null,
256
274
  prompts,
@@ -345,8 +363,15 @@ export function createUsageLimitPauseService({
345
363
  } catch {}
346
364
  }
347
365
  const state = getPause(plan.conversationId);
348
- try { logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`); } catch {}
366
+ try {
367
+ logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} ${plan.onApiKey
368
+ ? `stopped at the ${usageLimitWindowLabel(plan.rateLimitType)}; carries on on the API key`
369
+ : `paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`}`);
370
+ } catch {}
349
371
  emitPause(plan.conversationId, state);
372
+ if (plan.onApiKey) {
373
+ try { onQueueChanged('usage-limit-on-api-key'); } catch {}
374
+ }
350
375
  return { messageId, created: Boolean(created), state };
351
376
  }
352
377
 
@@ -375,6 +400,31 @@ export function createUsageLimitPauseService({
375
400
  return { resumed: true, messageId: String(row.id), state: null };
376
401
  }
377
402
 
403
+ /**
404
+ * Release the held follow-up because the chat moved to the API key: it
405
+ * runs now, on the key (the billing switch re-stamped it). Called inside
406
+ * the switch's transaction; `announce()` tells the clients once that has
407
+ * committed. Null when nothing is held.
408
+ */
409
+ function releaseForBillingSwitch({ conversationId, by = 'billing-switch' } = {}) {
410
+ const id = String(conversationId || '').trim();
411
+ const row = id ? findPausedRowForConversation.get(id) : null;
412
+ if (!row || !stateFromRow(row)) return null;
413
+ const pause = parseJson(row.usage_limit_pause) || {};
414
+ const at = new Date(now()).toISOString();
415
+ setPause.run(null, JSON.stringify({ ...pause, resumedAt: at, resumedBy: String(by || 'billing-switch') }), row.id);
416
+ const messageId = String(row.id);
417
+ return {
418
+ messageId,
419
+ announce() {
420
+ try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} released by the switch to the API key`); } catch {}
421
+ emitPause(id, null);
422
+ try { emit('message_status', { messageId, conversationId: id, status: 'pending' }); } catch {}
423
+ try { onQueueChanged('usage-limit-billing-switch'); } catch {}
424
+ },
425
+ };
426
+ }
427
+
378
428
  /** Drop the held follow-up: the paused turn is not carried on. */
379
429
  function cancel({ conversationId } = {}) {
380
430
  const id = String(conversationId || '').trim();
@@ -436,6 +486,7 @@ export function createUsageLimitPauseService({
436
486
  getPause,
437
487
  listPauses,
438
488
  resumeNow,
489
+ releaseForBillingSwitch,
439
490
  cancel,
440
491
  recordReport,
441
492
  getAccountState: accountState,
@@ -0,0 +1,320 @@
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 { CLAUDE_API_KEY_SETTING_KEY, CLAUDE_API_PROVIDER_TYPE } from './claude-api-settings-service.mjs';
49
+ import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
50
+
51
+ export const REDACTED_SECRET_TEXT = '[redacted]';
52
+ export const MIN_REDACTED_SECRET_LENGTH = 8;
53
+ // The shortest part of a key that is taken out where a text was cut (its
54
+ // first part) or at a text's start (its last part). A shorter part stays. It
55
+ // holds at most 7 characters of the key, for DeepSeek's shape (`sk-` and 32
56
+ // hex digits) the public `sk-` and four digits, too few to help anyone guess
57
+ // it. And a shorter run of ordinary text, the bare `sk-` or a word a key
58
+ // happens to start with, is no key and must not turn into `[redacted]`.
59
+ export const MIN_REDACTED_KEY_PREFIX_LENGTH = 8;
60
+ // How many keys the middleware remembers; the oldest goes first. A saved key
61
+ // is changed by hand, so this many covers every worker still running.
62
+ export const MAX_REMEMBERED_SECRETS = 16;
63
+ export const WORKER_SESSION_HEADER = 'x-relay-session-id';
64
+
65
+ /** The providers whose workers get a key saved in the relay's settings. */
66
+ export const SAVED_PROVIDER_KEY_SETTINGS = Object.freeze([
67
+ Object.freeze({ provider: DEEPSEEK_PROVIDER_TYPE, settingKey: DEEPSEEK_API_KEY_SETTING_KEY }),
68
+ Object.freeze({ provider: CLAUDE_API_PROVIDER_TYPE, settingKey: CLAUDE_API_KEY_SETTING_KEY }),
69
+ ]);
70
+
71
+ // JSON bodies are shallow in practice; a deeper one is left as it is below
72
+ // this depth rather than walked without end.
73
+ const MAX_DEPTH = 64;
74
+ // What a worker puts after a text it cut short (a tool line at 140
75
+ // characters, the remote-relay line's quoted text at 60), with the space
76
+ // some put before it. Matched where a key's first part stops: the text may go
77
+ // on after it (`…”`, `…” (now)`, `…)`).
78
+ const ELLIPSIS_AT = /\s*(?:…|⋯|\.{2,}|\[truncated\])/y;
79
+ // The same marks at the start of a text that begins inside something.
80
+ const LEADING_ELLIPSIS = /^(?:(?:…|⋯|\.{2,}|\[truncated\])\s*)?/;
81
+
82
+ function normalizeSecrets(secrets) {
83
+ const unique = new Set();
84
+ for (const entry of Array.isArray(secrets) ? secrets : []) {
85
+ const value = String(entry ?? '').trim();
86
+ if (value.length >= MIN_REDACTED_SECRET_LENGTH) unique.add(value);
87
+ }
88
+ // Longest first; where secrets overlap in a text, redactString joins what
89
+ // each of them covers.
90
+ return [...unique].sort((a, b) => b.length - a.length);
91
+ }
92
+
93
+ /** Whether a key's first part that stops at `at` was cut there: the text ends, or an ellipsis follows. */
94
+ function isCutAt(text, at) {
95
+ if (at === text.length) return true;
96
+ ELLIPSIS_AT.lastIndex = at;
97
+ return ELLIPSIS_AT.test(text);
98
+ }
99
+
100
+ /**
101
+ * Pushes `[start, end)` for each run in `text` that is the first
102
+ * MIN_REDACTED_KEY_PREFIX_LENGTH or more characters of `secret` (not all of
103
+ * it) and is cut: at the end of the text or before an ellipsis, wherever that
104
+ * is. Of the runs at one place, the longest that is cut counts.
105
+ */
106
+ function pushKeyPrefixRanges(text, secret, ranges) {
107
+ if (secret.length <= MIN_REDACTED_KEY_PREFIX_LENGTH) return;
108
+ const head = secret.slice(0, MIN_REDACTED_KEY_PREFIX_LENGTH);
109
+ for (let index = text.indexOf(head); index !== -1; index = text.indexOf(head, index + 1)) {
110
+ // How far the text goes on as the key does (short of the whole key, a
111
+ // range of its own).
112
+ const most = Math.min(secret.length - 1, text.length - index);
113
+ let matched = MIN_REDACTED_KEY_PREFIX_LENGTH;
114
+ while (matched < most && text.charCodeAt(index + matched) === secret.charCodeAt(matched)) matched += 1;
115
+ for (let length = matched; length >= MIN_REDACTED_KEY_PREFIX_LENGTH; length -= 1) {
116
+ if (isCutAt(text, index + length)) {
117
+ ranges.push([index, index + length]);
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Pushes `[start, end)` when `text`, from `start` on, begins with the last
126
+ * MIN_REDACTED_KEY_PREFIX_LENGTH or more characters of `secret` (not all of
127
+ * it): the longest such end of the key.
128
+ */
129
+ function pushKeySuffixRange(text, start, secret, ranges) {
130
+ if (secret.length <= MIN_REDACTED_KEY_PREFIX_LENGTH) return;
131
+ const lead = text.slice(start, start + MIN_REDACTED_KEY_PREFIX_LENGTH);
132
+ if (lead.length < MIN_REDACTED_KEY_PREFIX_LENGTH) return;
133
+ // Where in the key the text may start, from 1 (not the whole key) on: the
134
+ // first place that fits leaves the longest end.
135
+ for (let at = secret.indexOf(lead, 1); at !== -1; at = secret.indexOf(lead, at + 1)) {
136
+ if (text.startsWith(secret.slice(at), start)) {
137
+ ranges.push([start, start + secret.length - at]);
138
+ return;
139
+ }
140
+ }
141
+ }
142
+
143
+ /** `text` with each range replaced; ranges that overlap or touch become one, so the longest part wins. */
144
+ function replaceRanges(text, ranges) {
145
+ if (!ranges.length) return text;
146
+ ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
147
+ let output = '';
148
+ let emitted = 0;
149
+ let [start, end] = ranges[0];
150
+ for (let index = 1; index < ranges.length; index += 1) {
151
+ const [nextStart, nextEnd] = ranges[index];
152
+ if (nextStart <= end) {
153
+ end = Math.max(end, nextEnd);
154
+ continue;
155
+ }
156
+ output += `${text.slice(emitted, start)}${REDACTED_SECRET_TEXT}`;
157
+ emitted = end;
158
+ [start, end] = [nextStart, nextEnd];
159
+ }
160
+ return `${output}${text.slice(emitted, start)}${REDACTED_SECRET_TEXT}${text.slice(end)}`;
161
+ }
162
+
163
+ /**
164
+ * `text` with every secret and the parts of one a cut left in it replaced:
165
+ * each whole occurrence, its first part where the text was cut
166
+ * (pushKeyPrefixRanges) and its last part at the very start of the text,
167
+ * after an ellipsis it may begin with (pushKeySuffixRange). All are found in
168
+ * the text as it came, of every secret, and replaced together, so where they
169
+ * overlap the longest wins: a key that holds another saved key goes whole,
170
+ * also when only a part of it is there.
171
+ */
172
+ function redactString(text, secrets) {
173
+ const ranges = [];
174
+ const start = LEADING_ELLIPSIS.exec(text)[0].length;
175
+ for (const secret of secrets) {
176
+ for (let index = text.indexOf(secret); index !== -1; index = text.indexOf(secret, index + 1)) {
177
+ ranges.push([index, index + secret.length]);
178
+ }
179
+ pushKeyPrefixRanges(text, secret, ranges);
180
+ pushKeySuffixRange(text, start, secret, ranges);
181
+ }
182
+ return replaceRanges(text, ranges);
183
+ }
184
+
185
+ /**
186
+ * `text` with every occurrence of each secret replaced, a first part of one
187
+ * cut at the text's end or before an ellipsis and a last part of one the text
188
+ * starts with; anything that is not a string as it is.
189
+ */
190
+ export function redactSecretsInText(text, secrets = []) {
191
+ if (typeof text !== 'string' || !text) return text;
192
+ return redactString(text, normalizeSecrets(secrets));
193
+ }
194
+
195
+ function redactValue(value, secrets, depth) {
196
+ if (typeof value === 'string') return value ? redactString(value, secrets) : value;
197
+ if (!value || typeof value !== 'object' || depth >= MAX_DEPTH) return value;
198
+ if (Array.isArray(value)) {
199
+ let copy = null;
200
+ for (let index = 0; index < value.length; index += 1) {
201
+ const next = redactValue(value[index], secrets, depth + 1);
202
+ if (next !== value[index]) {
203
+ copy ||= value.slice();
204
+ copy[index] = next;
205
+ }
206
+ }
207
+ return copy || value;
208
+ }
209
+ const prototype = Object.getPrototypeOf(value);
210
+ if (prototype !== Object.prototype && prototype !== null) return value;
211
+ // Property names too: a key can be one (`{ "<key>": 1 }`). Once a name or a
212
+ // value changes, the entries are copied in their order; two names that come
213
+ // out the same keep the later value.
214
+ const entries = Object.entries(value);
215
+ let copied = null;
216
+ for (let index = 0; index < entries.length; index += 1) {
217
+ const [key, entry] = entries[index];
218
+ const nextKey = key ? redactString(key, secrets) : key;
219
+ const next = redactValue(entry, secrets, depth + 1);
220
+ if (!copied) {
221
+ if (nextKey === key && next === entry) continue;
222
+ copied = entries.slice(0, index);
223
+ }
224
+ copied.push([nextKey, next]);
225
+ }
226
+ // fromEntries defines each property, so a `__proto__` name stays a name.
227
+ return copied ? Object.fromEntries(copied) : value;
228
+ }
229
+
230
+ /**
231
+ * Every string in a JSON value (plain objects and arrays, at any depth),
232
+ * property names included, with the secrets replaced. The value itself comes
233
+ * back when nothing changed; otherwise only the changed branches are copies.
234
+ */
235
+ export function redactSecretsDeep(value, secrets = []) {
236
+ const normalized = normalizeSecrets(secrets);
237
+ if (!normalized.length) return value;
238
+ return redactValue(value, normalized, 0);
239
+ }
240
+
241
+ /** The saved keys of the registered providers, as `readSetting(settingKey)` has them. */
242
+ export function readSavedProviderKeys({ readSetting = () => '', entries = SAVED_PROVIDER_KEY_SETTINGS } = {}) {
243
+ return normalizeSecrets(entries.map((entry) => readSetting(entry.settingKey)));
244
+ }
245
+
246
+ /** Whether a request is a session worker's post with a body to scrub: the header present, even empty. */
247
+ export function isWorkerPostRequest(req) {
248
+ const method = String(req?.method || '').toUpperCase();
249
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
250
+ if (req?.headers?.[WORKER_SESSION_HEADER] === undefined) return false;
251
+ return !/^\/api\/settings(?:\/|$)/.test(String(req?.path || ''));
252
+ }
253
+
254
+ /**
255
+ * The middleware: mounted after `express.json()`, before every route. It
256
+ * reads the saved keys once when it is created (the relay's start) and per
257
+ * request (one settings read per provider), so a key saved applies to the
258
+ * next post, and redacts them with every key read or noted before (see the
259
+ * note on key changes above). `noteSavedSetting(settingKey, value)`, on the
260
+ * function returned, is for the settings save path: a registered key saved
261
+ * there is remembered at once, so it stays redacted even when it is changed
262
+ * again before any worker posts.
263
+ */
264
+ export function createWorkerPostRedaction({
265
+ readSetting = () => '',
266
+ entries = SAVED_PROVIDER_KEY_SETTINGS,
267
+ log = (line) => console.warn(line),
268
+ } = {}) {
269
+ let warned = false;
270
+ // Oldest first; a key read again moves to the end.
271
+ const remembered = new Set();
272
+ let secrets = [];
273
+ const settingKeys = new Set(entries.map((entry) => entry.settingKey));
274
+
275
+ function remember(current) {
276
+ let changed = false;
277
+ for (const key of current) {
278
+ if (remembered.delete(key)) {
279
+ remembered.add(key);
280
+ continue;
281
+ }
282
+ remembered.add(key);
283
+ changed = true;
284
+ }
285
+ while (remembered.size > MAX_REMEMBERED_SECRETS) {
286
+ remembered.delete(remembered.values().next().value);
287
+ changed = true;
288
+ }
289
+ if (changed) secrets = normalizeSecrets([...remembered]);
290
+ }
291
+
292
+ function readAndRemember() {
293
+ try {
294
+ remember(readSavedProviderKeys({ readSetting, entries }));
295
+ } catch (error) {
296
+ // The settings could not be read: the post loses the keys read before,
297
+ // and the relay says so once rather than refusing every worker post.
298
+ if (!warned) {
299
+ warned = true;
300
+ try { log(`worker post redaction: the saved provider keys could not be read, only those read before are redacted (${error?.message || error})`); } catch {}
301
+ }
302
+ }
303
+ }
304
+
305
+ function workerPostRedaction(req, _res, next) {
306
+ if (!isWorkerPostRequest(req) || !req.body || typeof req.body !== 'object') return next();
307
+ readAndRemember();
308
+ if (secrets.length) req.body = redactValue(req.body, secrets, 0);
309
+ return next();
310
+ }
311
+
312
+ /** Remembers `value` when `settingKey` is a registered provider key; any other setting is ignored. */
313
+ workerPostRedaction.noteSavedSetting = function noteSavedSetting(settingKey, value) {
314
+ if (!settingKeys.has(settingKey)) return;
315
+ remember(normalizeSecrets([value]));
316
+ };
317
+
318
+ readAndRemember();
319
+ return workerPostRedaction;
320
+ }
@@ -0,0 +1,202 @@
1
+ // A small client for the one Anthropic endpoint the relay calls itself with a
2
+ // Claude API key: `GET /v1/models`, the model discovery of the Claude API
3
+ // provider (Settings → Providers → Claude API). Turns do not go through here:
4
+ // the session worker runs Claude Code on the key.
5
+ //
6
+ // The key goes to the Anthropic API and nowhere else. The only other target
7
+ // is a fake API on the loopback interface, named by OAR_ANTHROPIC_API_BASE_URL
8
+ // for tests; any other value of that variable is ignored (the rule of
9
+ // shared/claude-cloud/base-url.mjs, which this module reuses). There is no
10
+ // base-URL setting and no parameter that takes one.
11
+ //
12
+ // The key is in no error this module produces: a response body is quoted only
13
+ // after the key has been taken out of it.
14
+
15
+ import { loopbackBaseUrlOrNull } from './claude-cloud/base-url.mjs';
16
+
17
+ export const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com';
18
+ export const ANTHROPIC_API_BASE_URL_ENV = 'OAR_ANTHROPIC_API_BASE_URL';
19
+ export const ANTHROPIC_API_VERSION = '2023-06-01';
20
+
21
+ const DEFAULT_TIMEOUT_MS = 10_000;
22
+ // The largest page `GET /v1/models` serves.
23
+ const MODELS_PAGE_LIMIT = 1000;
24
+ // One page holds every model today; the cap keeps a cursor that never ends
25
+ // from turning into an endless walk.
26
+ const MAX_MODEL_PAGES = 20;
27
+ const MAX_DETAIL_CHARS = 240;
28
+ // The effort levels the Models API reports per model, in ladder order.
29
+ const EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
30
+
31
+ /**
32
+ * The base URL for the client: the loopback URL named by
33
+ * OAR_ANTHROPIC_API_BASE_URL, else the Anthropic API.
34
+ */
35
+ export function resolveAnthropicApiBaseUrl(env = process.env) {
36
+ return loopbackBaseUrlOrNull(env?.[ANTHROPIC_API_BASE_URL_ENV]) || ANTHROPIC_API_BASE_URL;
37
+ }
38
+
39
+ export class AnthropicApiError extends Error {
40
+ constructor(message, { code = 'error', status = null } = {}) {
41
+ super(message);
42
+ this.name = 'AnthropicApiError';
43
+ this.code = code;
44
+ this.status = status;
45
+ }
46
+ }
47
+
48
+ function codeForStatus(status) {
49
+ if (status === 401) return 'unauthorized';
50
+ if (status === 403) return 'forbidden';
51
+ if (status === 402) return 'billing';
52
+ if (status === 429) return 'rate_limited';
53
+ if (status >= 500) return 'transient';
54
+ return 'error';
55
+ }
56
+
57
+ function redact(text, apiKey) {
58
+ const value = String(text ?? '');
59
+ const key = String(apiKey ?? '').trim();
60
+ return key ? value.split(key).join('[redacted]') : value;
61
+ }
62
+
63
+ function toText(value) {
64
+ return String(value ?? '').trim();
65
+ }
66
+
67
+ function positiveInteger(value) {
68
+ const number = Number(value);
69
+ return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null;
70
+ }
71
+
72
+ /**
73
+ * A capability as the API writes it (`{ supported: true }`) or as a plain
74
+ * boolean; null when it is missing or of another shape.
75
+ */
76
+ function capabilitySupported(value) {
77
+ if (typeof value === 'boolean') return value;
78
+ if (value && typeof value === 'object' && typeof value.supported === 'boolean') return value.supported;
79
+ return null;
80
+ }
81
+
82
+ /** `{ type: 'error', error: { type, message } }` as one line, or the raw text. */
83
+ function errorDetail(text) {
84
+ const raw = toText(text);
85
+ if (!raw) return '';
86
+ try {
87
+ const parsed = JSON.parse(raw);
88
+ const type = toText(parsed?.error?.type);
89
+ const message = toText(parsed?.error?.message);
90
+ if (type || message) return [type, message].filter(Boolean).join(': ');
91
+ } catch {
92
+ // Not JSON: the text as it came.
93
+ }
94
+ return raw;
95
+ }
96
+
97
+ /**
98
+ * One entry of `GET /v1/models` in OAR's terms, or null without an id:
99
+ * `{ id, displayName, contextWindow, maxOutput, imageInput, effort,
100
+ * effortLevels, thinking, thinkingTypes }`. Every field but the id may be
101
+ * missing in an answer, so each one is null (or an empty list) then.
102
+ */
103
+ export function mapAnthropicModel(entry) {
104
+ const id = toText(entry?.id);
105
+ if (!id) return null;
106
+ const capabilities = entry?.capabilities && typeof entry.capabilities === 'object' ? entry.capabilities : {};
107
+ const effort = capabilities.effort;
108
+ const effortObject = effort && typeof effort === 'object' ? effort : {};
109
+ const thinking = capabilities.thinking;
110
+ const thinkingTypesObject = thinking && typeof thinking === 'object' && thinking.types && typeof thinking.types === 'object'
111
+ ? thinking.types
112
+ : {};
113
+ return {
114
+ id,
115
+ displayName: toText(entry?.display_name) || id,
116
+ contextWindow: positiveInteger(entry?.max_input_tokens),
117
+ maxOutput: positiveInteger(entry?.max_tokens),
118
+ imageInput: capabilitySupported(capabilities.image_input),
119
+ effort: capabilitySupported(effort),
120
+ effortLevels: EFFORT_LEVELS.filter((level) => capabilitySupported(effortObject[level]) === true),
121
+ thinking: capabilitySupported(thinking),
122
+ thinkingTypes: Object.keys(thinkingTypesObject)
123
+ .filter((type) => capabilitySupported(thinkingTypesObject[type]) === true)
124
+ .map((type) => type.toLowerCase()),
125
+ };
126
+ }
127
+
128
+ export function createAnthropicApiClient({
129
+ apiKey = '',
130
+ env = process.env,
131
+ fetchImpl = globalThis.fetch,
132
+ timeoutMs = DEFAULT_TIMEOUT_MS,
133
+ } = {}) {
134
+ const baseUrl = resolveAnthropicApiBaseUrl(env);
135
+ const key = toText(apiKey);
136
+
137
+ async function getJson(pathname, what) {
138
+ if (!key) throw new AnthropicApiError('Claude API key is not configured', { code: 'not_configured' });
139
+ let response;
140
+ try {
141
+ response = await fetchImpl(`${baseUrl}${pathname}`, {
142
+ headers: {
143
+ 'x-api-key': key,
144
+ 'anthropic-version': ANTHROPIC_API_VERSION,
145
+ accept: 'application/json',
146
+ },
147
+ signal: AbortSignal.timeout(timeoutMs),
148
+ });
149
+ } catch (error) {
150
+ const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError';
151
+ throw new AnthropicApiError(
152
+ timedOut
153
+ ? `Anthropic ${what} timed out after ${timeoutMs} ms`
154
+ : `Anthropic ${what} failed: ${redact(error?.message || error, key)}`,
155
+ { code: timedOut ? 'timeout' : 'network' },
156
+ );
157
+ }
158
+ if (!response.ok) {
159
+ const detail = redact(errorDetail(await response.text().catch(() => '')), key).slice(0, MAX_DETAIL_CHARS);
160
+ throw new AnthropicApiError(
161
+ `Anthropic ${what} failed (${response.status})${detail ? `: ${detail}` : ''}`,
162
+ { code: codeForStatus(response.status), status: response.status },
163
+ );
164
+ }
165
+ try {
166
+ return await response.json();
167
+ } catch {
168
+ throw new AnthropicApiError(`Anthropic ${what} returned invalid JSON`, { code: 'invalid_response' });
169
+ }
170
+ }
171
+
172
+ return {
173
+ baseUrl,
174
+
175
+ /**
176
+ * Every model the key may use (mapAnthropicModel), in the API's order,
177
+ * without duplicates. Follows `has_more` / `last_id` page by page.
178
+ */
179
+ async listModels() {
180
+ const models = [];
181
+ const seen = new Set();
182
+ const cursors = new Set();
183
+ let afterId = '';
184
+ for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
185
+ const query = new URLSearchParams({ limit: String(MODELS_PAGE_LIMIT) });
186
+ if (afterId) query.set('after_id', afterId);
187
+ const payload = await getJson(`/v1/models?${query.toString()}`, 'model discovery');
188
+ for (const entry of Array.isArray(payload?.data) ? payload.data : []) {
189
+ const model = mapAnthropicModel(entry);
190
+ if (!model || seen.has(model.id)) continue;
191
+ seen.add(model.id);
192
+ models.push(model);
193
+ }
194
+ const lastId = toText(payload?.last_id);
195
+ if (payload?.has_more !== true || !lastId || cursors.has(lastId)) break;
196
+ cursors.add(lastId);
197
+ afterId = lastId;
198
+ }
199
+ return models;
200
+ },
201
+ };
202
+ }
@@ -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
  /**