@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,433 @@
1
+ // The DeepSeek backend of the Claude session runner: everything that differs
2
+ // when the CLI the runner drives talks to DeepSeek's Anthropic-compatible
3
+ // endpoint instead of Anthropic.
4
+ //
5
+ // - The CLI's environment: the endpoint and the key as Claude Code's
6
+ // documented ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN, a Claude Code state
7
+ // directory of its own, no nonessential traffic, and nothing of the host's
8
+ // Claude login or of another Anthropic configuration. The endpoint, the
9
+ // key and everything else that routes the CLI's requests or decides whom
10
+ // it trusts (DEEPSEEK_CLI_PINNED_ENV: provider switches, credentials,
11
+ // proxies, TLS) are pinned again in the CLI's flag settings
12
+ // (liveFlagSettings), over the settings of the chat's folder.
13
+ // - The model: DeepSeek's ids, with Claude Code's `[1m]` suffix so the CLI
14
+ // plans with the 1M window; helper calls (titles, summaries) go to flash.
15
+ // Claude Code's model aliases are pinned to the session's model
16
+ // (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS/SONNET/FABLE_MODEL,
17
+ // CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL), so a
18
+ // sub-agent asked for as "opus" runs on it and not, by DeepSeek's mapping
19
+ // of a Claude id, on V4 Pro; a model switch respawns the CLI for the pins.
20
+ // A Claude id the CLI still names (a session spawned before the pins) is
21
+ // read as the DeepSeek model DeepSeek serves it with (deepseekServedModel).
22
+ // - The effort: DeepSeek's low/high/max, which Claude Code sends as
23
+ // `output_config.effort` with adaptive thinking (captured 2026-10-24). It
24
+ // is applied at spawn (CLAUDE_CODE_EFFORT_LEVEL), so a change respawns the
25
+ // CLI between turns on the same session. There is no thinking-off level:
26
+ // Claude Code never sends `thinking: {type: disabled}` for these models.
27
+ // - What it reports: no Claude plan usage and no Claude usage limit, but the
28
+ // CLI's token totals per model after each turn (POST
29
+ // /api/deepseek-plan-usage) for the DeepSeek card's estimated spend; images
30
+ // only to models that take them; DeepSeek's errors in its own words.
31
+ //
32
+ // Source for the endpoint and the variables: DeepSeek's Claude Code guide
33
+ // (api-docs.deepseek.com, quick_start/agent_integrations/claude_code).
34
+
35
+ import { deepseekAnthropicBaseUrl } from '../../shared/deepseek-api.mjs';
36
+ import { isHostClaudeSessionVariable } from '../../shared/host-claude-session-env.mjs';
37
+ import {
38
+ DEEPSEEK_PROVIDER_TYPE,
39
+ DEFAULT_DEEPSEEK_MODEL,
40
+ deepseekModelAcceptsImages,
41
+ deepseekReasoningEffortFor,
42
+ deepseekServedModel,
43
+ isDeepSeekModelId,
44
+ } from '../../shared/deepseek-models.mjs';
45
+
46
+ const LONG_CONTEXT_SUFFIX = '[1m]';
47
+ const LONG_CONTEXT_SUFFIX_PATTERN = /\[1m\]$/i;
48
+ // Claude Code's helper model; DeepSeek's guide points it at flash.
49
+ const HELPER_MODEL = 'deepseek-flash';
50
+ // Claude Code's alias for its helper model. The other aliases ("opus",
51
+ // "sonnet", "fable", and a sub-agent without a model) resolve to the pins
52
+ // spawnEnv sets, that is to the session's model.
53
+ const HELPER_ALIAS = 'haiku';
54
+ // Where Claude Code reads the model behind each of its aliases, and the model
55
+ // of sub-agents and workflow agents (the bundled CLI, 2026-10-24). All pinned
56
+ // to the session's model; CLAUDE_CODE_SUBAGENT_MODEL_FORCE is left unset, so
57
+ // a sub-agent asked for as "haiku" still runs on the helper model.
58
+ const SESSION_MODEL_VARIABLES = Object.freeze([
59
+ 'ANTHROPIC_MODEL',
60
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
61
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
62
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL',
63
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
64
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
65
+ ]);
66
+ const HELPER_MODEL_VARIABLES = Object.freeze(['ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL']);
67
+
68
+ // Variables that would point the CLI at another Anthropic configuration or
69
+ // hand it another credential. Every ANTHROPIC_* is dropped as well.
70
+ const FOREIGN_CLI_VARIABLES = Object.freeze([
71
+ 'CLAUDE_CODE_OAUTH_TOKEN',
72
+ 'CLAUDE_CODE_USE_BEDROCK',
73
+ 'CLAUDE_CODE_USE_VERTEX',
74
+ 'CLAUDE_CODE_USE_FOUNDRY',
75
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
76
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
77
+ 'CLAUDE_CODE_USE_MANTLE',
78
+ 'CLAUDE_CODE_USE_GATEWAY',
79
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
80
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
81
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
82
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
83
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
84
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
85
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
86
+ 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
87
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
88
+ 'CLAUDE_CODE_EFFORT_LEVEL',
89
+ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
90
+ // Claude Code's subprocess env scrub, if the host exports it: it forces the
91
+ // permission mode to default (a chat in plan mode could edit files) and
92
+ // runs every Bash command in a bubblewrap sandbox.
93
+ 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
94
+ // What the relay hands its worker (session-worker-launch-service.mjs,
95
+ // TMUX_WORKER_EXPORTED_ENV_VARS): the worker's own settings, which neither
96
+ // Claude Code nor anything the agent runs reads. A shell of the agent's
97
+ // that starts a relay of its own would take the worker's kind, folders and
98
+ // tools file for its own. The relay's Claude configuration
99
+ // (CLAUDE_CONFIG_DIR, replaced below; CLAUDE_CODE_EXECUTABLE) is not on it.
100
+ 'COPILOT_ALLOW_ALL',
101
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
102
+ 'COPILOT_SDK_PATH',
103
+ 'EXTENSION_PATH',
104
+ 'COPILOT_MODEL',
105
+ 'COPILOT_RELAY_MODEL',
106
+ 'CLAUDE_RELAY_MODEL',
107
+ 'CLAUDE_CLOUD_RELAY_MODEL',
108
+ 'CURSOR_RELAY_MODEL',
109
+ 'CURSOR_AGENT_STORE_DIR',
110
+ 'GROK_RELAY_MODEL',
111
+ 'GROK_CLI_COMMAND',
112
+ 'GROK_ALWAYS_APPROVE',
113
+ 'SESSION_ID',
114
+ 'COPILOT_WORKSPACE_ROOT',
115
+ 'INIT_CWD',
116
+ ]);
117
+ // The same by prefix: the relay's folders, config, tools file, worker kind
118
+ // and log file (COPILOT_WEB_RELAY_*), the Copilot provider settings, every
119
+ // DeepSeek setting of the worker's (the key included: the CLI gets it as
120
+ // ANTHROPIC_AUTH_TOKEN) and the relay's own OAR_* switches.
121
+ const FOREIGN_CLI_PREFIXES = Object.freeze(['ANTHROPIC_', 'COPILOT_WEB_RELAY_', 'COPILOT_PROVIDER_', 'DEEPSEEK_', 'OAR_']);
122
+
123
+ /**
124
+ * Every variable through which the env of the chat folder's settings
125
+ * (.claude/settings.json, settings.local.json) could send the CLI's requests,
126
+ * and with them the key, somewhere else, or change whom the CLI trusts on the
127
+ * way: the endpoints, the provider switches, the credentials, the proxies and
128
+ * the TLS trust. liveFlagSettings pins each one in the flag settings layer,
129
+ * which the CLI applies over the folder's, to the value the CLI was spawned
130
+ * with: the worker's own, else the other case's for a proxy variable, else ''.
131
+ * '' is unset to the CLI for each of these (truthy or trimmed reads), except
132
+ * SSL_CERT_FILE and SSL_CERT_DIR: '' empties OpenSSL's and Node's system
133
+ * store, so those two are pinned only when the worker sets them.
134
+ *
135
+ * Source: the names Claude Code 2.1.304 (the bundled CLI) reads, by a grep
136
+ * of the binary; the CLI's own lists of endpoints (with their selection
137
+ * switches and companions) and credentials are in it as they are there. A
138
+ * deny-list, because a settings layer can only pin names known in advance.
139
+ * Left out, each for its reason: the model variables (same endpoint, same
140
+ * key), what the CLI already drops from a folder's settings or from all
141
+ * settings (CLAUDE_CONFIG_DIR, CLAUDE_CODE_CUSTOM_OAUTH_URL, the OTEL
142
+ * exporter endpoints, ANTHROPIC_UNIX_SOCKET, the host-managed provider
143
+ * switches, the file-descriptor credentials), a provider's own SDK settings
144
+ * (AWS_*, GOOGLE_*, AZURE_*, read only for the provider the switches here
145
+ * turn off), NODE_OPTIONS (at runtime the CLI reads only its system-CA
146
+ * flags, which add no CA), and what routes only other programs' traffic
147
+ * (NPM_CONFIG_*, GIT_*, the resolver's variables).
148
+ */
149
+ export const DEEPSEEK_CLI_PINNED_ENV = Object.freeze([
150
+ // Endpoints: the CLI's list, its fallback and the headers sent with them.
151
+ 'ANTHROPIC_BASE_URL',
152
+ '_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
153
+ 'ANTHROPIC_CUSTOM_HEADERS',
154
+ 'ANTHROPIC_BEDROCK_BASE_URL',
155
+ 'ANTHROPIC_VERTEX_BASE_URL',
156
+ 'ANTHROPIC_FOUNDRY_BASE_URL',
157
+ 'ANTHROPIC_AWS_BASE_URL',
158
+ 'ANTHROPIC_GOOGLE_CLOUD_BASE_URL',
159
+ 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL',
160
+ 'CLAUDE_CODE_API_BASE_URL',
161
+ 'CLAUDE_CODE_ARTIFACTS_API_BASE_URL',
162
+ 'CLAUDE_CODE_ARTIFACTS_API_TOKEN',
163
+ 'CLAUDE_CODE_ARTIFACT_ASSET_BASE_URL',
164
+ 'CLAUDE_CODE_ARTIFACT_LIVE_BASE_URL',
165
+ 'CLAUDE_CODE_ARTIFACT_SYNC_BASE_URL',
166
+ 'CLAUDE_CODE_ARTIFACT_VIEWER_BASE_URL',
167
+ 'CLAUDE_CODE_MEMORY_API_BASE_URL',
168
+ 'CLAUDE_CODE_MEMORY_API_TOKEN',
169
+ // Provider switches.
170
+ 'CLAUDE_CODE_USE_BEDROCK',
171
+ 'CLAUDE_CODE_USE_VERTEX',
172
+ 'CLAUDE_CODE_USE_FOUNDRY',
173
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
174
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
175
+ 'CLAUDE_CODE_USE_MANTLE',
176
+ 'CLAUDE_CODE_USE_GATEWAY',
177
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
178
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
179
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
180
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
181
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
182
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
183
+ // Credentials: the CLI's list, the OAuth login, the federation profile and
184
+ // the proxy credential helper.
185
+ 'ANTHROPIC_API_KEY',
186
+ 'ANTHROPIC_AUTH_TOKEN',
187
+ 'CLAUDE_CODE_OAUTH_TOKEN',
188
+ 'AWS_BEARER_TOKEN_BEDROCK',
189
+ 'ANTHROPIC_FOUNDRY_API_KEY',
190
+ 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
191
+ 'ANTHROPIC_AWS_API_KEY',
192
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
193
+ 'CLAUDE_CODE_OAUTH_SCOPES',
194
+ 'CLAUDE_CODE_OAUTH_CLIENT_ID',
195
+ 'ANTHROPIC_PROFILE',
196
+ 'ANTHROPIC_IDENTITY_TOKEN',
197
+ 'ANTHROPIC_IDENTITY_TOKEN_FILE',
198
+ 'ANTHROPIC_FEDERATION_RULE_ID',
199
+ 'ANTHROPIC_ORGANIZATION_ID',
200
+ 'ANTHROPIC_SERVICE_ACCOUNT_ID',
201
+ 'ANTHROPIC_SCOPE',
202
+ 'ANTHROPIC_WORKSPACE_ID',
203
+ 'CLAUDE_CODE_ENABLE_PROXY_AUTH_HELPER',
204
+ // Proxies: the CLI reads both cases, the lower one first.
205
+ 'HTTPS_PROXY',
206
+ 'https_proxy',
207
+ 'HTTP_PROXY',
208
+ 'http_proxy',
209
+ 'ALL_PROXY',
210
+ 'all_proxy',
211
+ 'NO_PROXY',
212
+ 'no_proxy',
213
+ 'CLAUDE_CODE_HTTPS_PROXY',
214
+ 'CLAUDE_CODE_HTTP_PROXY',
215
+ // TLS trust and the client certificate.
216
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
217
+ 'NODE_EXTRA_CA_CERTS',
218
+ 'CLAUDE_CODE_CERT_STORE',
219
+ 'CLAUDE_CODE_CLIENT_CERT',
220
+ 'CLAUDE_CODE_CLIENT_KEY',
221
+ 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
222
+ 'SSL_CERT_FILE',
223
+ 'SSL_CERT_DIR',
224
+ ]);
225
+ // The two of DEEPSEEK_CLI_PINNED_ENV for which '' is not unset (see there).
226
+ const PINNED_ONLY_WHEN_SET = new Set(['SSL_CERT_FILE', 'SSL_CERT_DIR']);
227
+ // The proxy variables the CLI and the tools it runs read in either case.
228
+ const OTHER_CASE = new Map(
229
+ ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'NO_PROXY'].flatMap((name) => [
230
+ [name, name.toLowerCase()],
231
+ [name.toLowerCase(), name],
232
+ ]),
233
+ );
234
+
235
+ /**
236
+ * The pins of DEEPSEEK_CLI_PINNED_ENV for a CLI spawned with `cliEnv`: the
237
+ * value it was spawned with, else the other case's (a proxy the worker sets
238
+ * as https_proxy holds for HTTPS_PROXY as well), else ''.
239
+ */
240
+ export function deepseekPinnedEnv(cliEnv = {}) {
241
+ const pins = {};
242
+ for (const name of DEEPSEEK_CLI_PINNED_ENV) {
243
+ const own = cliEnv[name] ?? cliEnv[OTHER_CASE.get(name)];
244
+ if (own !== undefined) pins[name] = String(own);
245
+ else if (!PINNED_ONLY_WHEN_SET.has(name)) pins[name] = '';
246
+ }
247
+ return pins;
248
+ }
249
+
250
+ /**
251
+ * Whether a variable of the worker's stays out of the CLI's environment:
252
+ * another Anthropic configuration or credential, the relay's settings for
253
+ * its worker, or the host Claude Code session's variables when the relay was
254
+ * started from inside one (shared/host-claude-session-env.mjs; the launcher
255
+ * drops those for every worker, the backend again, so a CLI talking to
256
+ * DeepSeek never reads itself as a child of the host's session, however the
257
+ * worker was started).
258
+ */
259
+ function isForeignCliVariable(key) {
260
+ return FOREIGN_CLI_VARIABLES.includes(key)
261
+ || FOREIGN_CLI_PREFIXES.some((prefix) => key.startsWith(prefix))
262
+ || isHostClaudeSessionVariable(key);
263
+ }
264
+
265
+ function text(value) {
266
+ return String(value ?? '').trim();
267
+ }
268
+
269
+ function withoutSuffix(value) {
270
+ return text(value).replace(LONG_CONTEXT_SUFFIX_PATTERN, '');
271
+ }
272
+
273
+ /**
274
+ * The relay's id of a model the CLI names: "deepseek-flash[1m]" →
275
+ * "deepseek-flash"; a Claude id ("claude-opus-5-5", from a CLI spawned
276
+ * without the alias pins) → the DeepSeek model that served it; anything else
277
+ * as given, without the suffix.
278
+ */
279
+ export function deepseekRelayModel(value) {
280
+ return deepseekServedModel(value) || withoutSuffix(value);
281
+ }
282
+
283
+ /** The id the CLI gets: the relay's id with the 1M suffix. */
284
+ export function deepseekCliModel(value) {
285
+ const model = deepseekRelayModel(value);
286
+ return model ? `${model}${LONG_CONTEXT_SUFFIX}` : '';
287
+ }
288
+
289
+ /**
290
+ * The DeepSeek model a sub-agent runs on, from what the runner knows of it:
291
+ * the model its own frames name (a DeepSeek id, or a Claude id DeepSeek
292
+ * served), else the alias the spawning tool pinned ("haiku" is the helper
293
+ * model, every other alias the session's model), else the session's model.
294
+ */
295
+ export function deepseekSubagentModel(value, sessionModel = '') {
296
+ const served = deepseekServedModel(value);
297
+ if (served) return served;
298
+ if (withoutSuffix(value).toLowerCase() === HELPER_ALIAS) return HELPER_MODEL;
299
+ return deepseekRelayModel(sessionModel);
300
+ }
301
+
302
+ /** The session model a CLI is pinned to, in the CLI's terms; unknown or none is the default model. */
303
+ function pinnedCliModel(model) {
304
+ const relayModel = deepseekRelayModel(model);
305
+ return deepseekCliModel(isDeepSeekModelId(relayModel) ? relayModel : DEFAULT_DEEPSEEK_MODEL);
306
+ }
307
+
308
+ /**
309
+ * `{ code, guidance }` for a failed request DeepSeek has its own advice for,
310
+ * or null. Read from the HTTP status the result reports, else from the
311
+ * status the CLI quotes in its error text ("API Error: 402 …").
312
+ */
313
+ export function describeDeepSeekFailure(errorText, { status = null } = {}) {
314
+ const message = text(errorText);
315
+ const code = Number(status);
316
+ if (code === 402 || /\b402\b|insufficient[ _]balance/i.test(message)) {
317
+ return {
318
+ code: 'deepseek-balance',
319
+ guidance: 'The DeepSeek account balance is used up. Top it up at https://platform.deepseek.com/top_up, then send the message again.',
320
+ };
321
+ }
322
+ if (code === 401 || /\b401\b|authentication[_ ]error|invalid api key/i.test(message)) {
323
+ return {
324
+ code: 'deepseek-api-key',
325
+ guidance: 'DeepSeek did not accept the API key. Check it in Settings → Providers → DeepSeek, then send the message again.',
326
+ };
327
+ }
328
+ if (code === 429 || /\b429\b|rate[_ ]limit/i.test(message)) {
329
+ return {
330
+ code: 'deepseek-rate-limit',
331
+ guidance: 'DeepSeek is at its concurrency limit for this account. Send the message again in a moment.',
332
+ };
333
+ }
334
+ return null;
335
+ }
336
+
337
+ export function createDeepSeekClaudeBackend({ env = process.env } = {}) {
338
+ const apiKey = text(env.DEEPSEEK_API_KEY);
339
+ const anthropicBaseUrl = deepseekAnthropicBaseUrl(text(env.DEEPSEEK_BASE_URL));
340
+ const configDir = text(env.DEEPSEEK_CLAUDE_CONFIG_DIR);
341
+
342
+ /** DeepSeek's level for a requested effort; unset and unknown are high. */
343
+ function effortFor(value) {
344
+ return deepseekReasoningEffortFor(value) || 'high';
345
+ }
346
+
347
+ /**
348
+ * The CLI's environment. Built from the worker's own (which must not reach
349
+ * the CLI as it is: it carries DEEPSEEK_API_KEY and possibly the host's
350
+ * Anthropic settings), so every Anthropic variable is replaced. `model` is
351
+ * the session's model, which every alias is pinned to.
352
+ */
353
+ function spawnEnv(baseEnv = process.env, { effort = '', model = '' } = {}) {
354
+ if (!apiKey) throw new Error('deepseek-api-key-not-configured');
355
+ if (!configDir) throw new Error('deepseek-config-dir-not-configured');
356
+ const next = {};
357
+ for (const [key, value] of Object.entries(baseEnv || {})) {
358
+ if (isForeignCliVariable(key)) continue;
359
+ next[key] = value;
360
+ }
361
+ next.ANTHROPIC_BASE_URL = anthropicBaseUrl;
362
+ next.ANTHROPIC_AUTH_TOKEN = apiKey;
363
+ // Every alias Claude Code resolves on its own ("opus", a sub-agent
364
+ // without a model) lands on the session's model: without a pin the CLI
365
+ // sends a Claude id, which DeepSeek serves by its own mapping
366
+ // (claude-opus → V4 Pro), off the model the chat is locked to.
367
+ const sessionModel = pinnedCliModel(model);
368
+ for (const key of SESSION_MODEL_VARIABLES) next[key] = sessionModel;
369
+ for (const key of HELPER_MODEL_VARIABLES) next[key] = HELPER_MODEL;
370
+ next.CLAUDE_CONFIG_DIR = configDir;
371
+ next.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
372
+ next.CLAUDE_CODE_EFFORT_LEVEL = effortFor(effort);
373
+ return next;
374
+ }
375
+
376
+ return {
377
+ id: DEEPSEEK_PROVIDER_TYPE,
378
+ providerType: DEEPSEEK_PROVIDER_TYPE,
379
+ label: 'DeepSeek',
380
+ configDir,
381
+ defaultModel: DEFAULT_DEEPSEEK_MODEL,
382
+ // Neither is DeepSeek's: Claude's plan windows and its usage limit.
383
+ reportsPlanUsage: false,
384
+ reportsUsageLimit: false,
385
+ // What it reports instead: the CLI's token totals per model, by the
386
+ // relay's model ids (relayModel); the relay books what each report adds.
387
+ // Claude Code's `total_cost_usd` is at Claude's prices and is not sent.
388
+ usageRoute: '/api/deepseek-plan-usage',
389
+
390
+ // A Claude id is not a model of DeepSeek's to request, only one it may
391
+ // have served (relayModel).
392
+ isModel: (value) => isDeepSeekModelId(withoutSuffix(value)),
393
+ cliModel: deepseekCliModel,
394
+ relayModel: deepseekRelayModel,
395
+ subagentModel: deepseekSubagentModel,
396
+ acceptsImages: (model) => deepseekModelAcceptsImages(deepseekRelayModel(model)),
397
+ describeFailure: describeDeepSeekFailure,
398
+
399
+ effortFor,
400
+ /** The SDK's `effort` option: DeepSeek's level. */
401
+ effortOption: effortFor,
402
+ /** DeepSeek always thinks (see the effort note above). */
403
+ thinkingEnabled: () => true,
404
+ /**
405
+ * What only a fresh CLI takes, the effort and the model the aliases are
406
+ * pinned to: a change respawns it between turns. `model` is the turn's
407
+ * resolved model (the runner's, not the message's alone).
408
+ */
409
+ spawnKey: (message = {}, { model = '' } = {}) => `${effortFor(message.reasoningEffort)} ${pinnedCliModel(model)}`,
410
+
411
+ spawnEnv,
412
+
413
+ /**
414
+ * The CLI's routing, trust and credentials once more, in its flag
415
+ * settings layer, which outranks the settings of the chat's folder: a
416
+ * .claude/settings.json or settings.local.json there whose
417
+ * env.ANTHROPIC_BASE_URL, proxy or CA points elsewhere would otherwise
418
+ * get the DeepSeek key as its bearer token (scripts/deepseek-cli-proof.mjs).
419
+ * Every variable of DEEPSEEK_CLI_PINNED_ENV holds the value of `cliEnv`,
420
+ * the environment the CLI was spawned with (spawnEnv), or is blanked, and
421
+ * so is an apiKeyHelper of the folder's. The key may not go on the
422
+ * command line, so the adapter sends these over the control channel
423
+ * before the first message (startClaudeSession).
424
+ */
425
+ liveFlagSettings(cliEnv = spawnEnv(env)) {
426
+ if (!apiKey) throw new Error('deepseek-api-key-not-configured');
427
+ return {
428
+ env: { ...deepseekPinnedEnv(cliEnv), ANTHROPIC_BASE_URL: anthropicBaseUrl, ANTHROPIC_AUTH_TOKEN: apiKey },
429
+ apiKeyHelper: '',
430
+ };
431
+ },
432
+ };
433
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // DeepSeek session worker: the Claude session worker with its CLI pointed at
3
+ // DeepSeek's Anthropic-compatible endpoint (deepseek-claude-backend.mjs). The
4
+ // relay's launch puts the key in DEEPSEEK_API_KEY (through the owner-only
5
+ // secret file under tmux) and names the worker's own Claude Code state
6
+ // directory in DEEPSEEK_CLAUDE_CONFIG_DIR.
7
+ import fs from 'node:fs';
8
+
9
+ import { installWorkerLogFile, installWorkerLogRedaction } from '../../shared/worker-runtime/worker-log-file.mjs';
10
+ import { runClaudeSessionWorker } from '../claude-worker/claude-session-worker-main.mjs';
11
+ import { createDeepSeekClaudeBackend } from './deepseek-claude-backend.mjs';
12
+
13
+ installWorkerLogFile();
14
+ // The key never shows in this worker's console or log, whatever the CLI or
15
+ // the agent prints (installed after the log file, so its copy is redacted too).
16
+ installWorkerLogRedaction({ secrets: [process.env.DEEPSEEK_API_KEY] });
17
+
18
+ const backend = createDeepSeekClaudeBackend({ env: process.env });
19
+
20
+ // The worker reads the CLI's state too (the transcript a resume needs, the
21
+ // workflow files): it has to look where its CLI writes, never in the host's
22
+ // ~/.claude. The directory holds sessions, so only this user may open it.
23
+ if (backend.configDir) {
24
+ fs.mkdirSync(backend.configDir, { recursive: true, mode: 0o700 });
25
+ try { fs.chmodSync(backend.configDir, 0o700); } catch {}
26
+ process.env.CLAUDE_CONFIG_DIR = backend.configDir;
27
+ }
28
+
29
+ runClaudeSessionWorker({
30
+ logTag: 'deepseek-worker',
31
+ workerName: 'deepseek-session-worker',
32
+ defaultModel: String(process.env.DEEPSEEK_RELAY_MODEL || '').trim() || backend.defaultModel,
33
+ backend,
34
+ }).catch((error) => {
35
+ console.error('deepseek-session-worker fatal:', error?.stack || error?.message || error);
36
+ process.exit(1);
37
+ });
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: options of relay event subscriptions.
5
+ *
6
+ * Columns on `relay_event_subscriptions` (all NULL = off, as before):
7
+ *
8
+ * - `digest_minutes`: the subscription's non-urgent events are held and
9
+ * delivered as one row every N minutes; `digest_last_at` is when its last
10
+ * such row went out (the next one is due N minutes after it).
11
+ * - `quiet_hours_json`: `{ from, to, tz }`; non-urgent events in the window
12
+ * are held until it ends.
13
+ * - `exclude_json`: session ids an "all" subscription leaves out.
14
+ *
15
+ * `relay_event_own_prompts`: the prompts a watcher sent through send or
16
+ * create_session (`message_id` is the prompt's), and when it received the
17
+ * finished reply (`received_at`): the turn_end of such a turn is not
18
+ * delivered to it again. Rows go after a day.
19
+ *
20
+ * Held events are ordinary undelivered `relay_events` rows: they and the
21
+ * timers rebuilt from them survive a restart without anything more here.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot.
24
+ *
25
+ * Usage: node server/migrations/0019-relay-event-subscription-options.mjs [path/to/copilot.db]
26
+ */
27
+
28
+ import { fileURLToPath } from 'url';
29
+ import path from 'path';
30
+ import process from 'process';
31
+ import Database from 'better-sqlite3';
32
+
33
+ export const RELAY_EVENT_OPTION_COLUMNS = Object.freeze([
34
+ ['digest_minutes', 'INTEGER'],
35
+ ['digest_last_at', 'TEXT'],
36
+ ['quiet_hours_json', 'TEXT'],
37
+ ['exclude_json', 'TEXT'],
38
+ ]);
39
+
40
+ function tableExists(db, name) {
41
+ return !!db
42
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
43
+ .get(name);
44
+ }
45
+
46
+ export function migrateRelayEventSubscriptionOptions(db) {
47
+ if (!tableExists(db, 'relay_event_subscriptions')) {
48
+ throw new Error('relay_event_subscriptions is missing (migration 0011 has not run)');
49
+ }
50
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().map((column) => column.name));
51
+ let columnsAdded = 0;
52
+ for (const [name, type] of RELAY_EVENT_OPTION_COLUMNS) {
53
+ if (present.has(name)) continue;
54
+ db.exec(`ALTER TABLE relay_event_subscriptions ADD COLUMN ${name} ${type}`);
55
+ columnsAdded += 1;
56
+ }
57
+ let tablesAdded = 0;
58
+ if (!tableExists(db, 'relay_event_own_prompts')) {
59
+ db.exec(`
60
+ CREATE TABLE relay_event_own_prompts (
61
+ message_id TEXT PRIMARY KEY,
62
+ watcher_conversation_id TEXT NOT NULL,
63
+ relay_id TEXT NOT NULL DEFAULT 'self',
64
+ session_id TEXT NOT NULL,
65
+ created_at TEXT NOT NULL,
66
+ received_at TEXT
67
+ )
68
+ `);
69
+ tablesAdded += 1;
70
+ }
71
+ db.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_relay_event_own_prompts_created
73
+ ON relay_event_own_prompts(created_at)
74
+ `);
75
+ return { applied: columnsAdded > 0 || tablesAdded > 0, columnsAdded, tablesAdded };
76
+ }
77
+
78
+ function defaultDbPath() {
79
+ const here = path.dirname(fileURLToPath(import.meta.url));
80
+ return path.resolve(here, '..', 'data', 'copilot.db');
81
+ }
82
+
83
+ export function migrate(dbPath, options = {}) {
84
+ const db = new Database(dbPath, options);
85
+ try {
86
+ return migrateRelayEventSubscriptionOptions(db);
87
+ } finally {
88
+ db.close();
89
+ }
90
+ }
91
+
92
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
93
+ if (isMain) {
94
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
95
+ try {
96
+ const result = migrate(dbPath);
97
+ console.log(`Migration 0019 on ${dbPath}`);
98
+ console.log(result.applied
99
+ ? ` relay event subscription options: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
100
+ : ' skipped: nothing to change');
101
+ process.exit(0);
102
+ } catch (err) {
103
+ console.error(`Migration 0019 failed: ${err?.message || err}`);
104
+ process.exit(1);
105
+ }
106
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the owner's "agents in this conversation may start sessions".
5
+ *
6
+ * `may_create_sessions` is 1 when the owner allowed the agents of a session
7
+ * that an agent started to start sessions on this relay themselves (the
8
+ * remote_relay tool's one-level rule is lifted for it); NULL or 0 otherwise.
9
+ * Only the owner sets it, in the conversation's settings or through the
10
+ * preferences route; no agent can.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
13
+ * data change: older code ignores the column and keeps the one-level rule.
14
+ *
15
+ * Usage: node server/migrations/0020-conversation-may-create-sessions.mjs [path/to/copilot.db]
16
+ */
17
+
18
+ import { fileURLToPath } from 'url';
19
+ import path from 'path';
20
+ import process from 'process';
21
+ import Database from 'better-sqlite3';
22
+
23
+ function tableExists(db, name) {
24
+ return !!db
25
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
26
+ .get(name);
27
+ }
28
+
29
+ export function migrateConversationMayCreateSessions(db) {
30
+ if (!tableExists(db, 'conversations')) {
31
+ throw new Error('conversations is missing (the base schema has not run)');
32
+ }
33
+ const present = new Set(db.prepare(`PRAGMA table_info(conversations)`).all().map((column) => column.name));
34
+ if (present.has('may_create_sessions')) return { applied: false, columnsAdded: 0 };
35
+ db.exec(`ALTER TABLE conversations ADD COLUMN may_create_sessions INTEGER`);
36
+ return { applied: true, columnsAdded: 1 };
37
+ }
38
+
39
+ function defaultDbPath() {
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ return path.resolve(here, '..', 'data', 'copilot.db');
42
+ }
43
+
44
+ export function migrate(dbPath, options = {}) {
45
+ const db = new Database(dbPath, options);
46
+ try {
47
+ return migrateConversationMayCreateSessions(db);
48
+ } finally {
49
+ db.close();
50
+ }
51
+ }
52
+
53
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
54
+ if (isMain) {
55
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
56
+ try {
57
+ const result = migrate(dbPath);
58
+ console.log(`Migration 0020 on ${dbPath}`);
59
+ console.log(result.applied
60
+ ? ` may create sessions: columns added ${result.columnsAdded}`
61
+ : ' skipped: nothing to change');
62
+ process.exit(0);
63
+ } catch (err) {
64
+ console.error(`Migration 0020 failed: ${err?.message || err}`);
65
+ process.exit(1);
66
+ }
67
+ }