@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- 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
|
+
}
|