@oar-sh/oar 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,317 @@
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 (CLAUDE_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
+ // - Hooks: none run, neither the user's nor the chat folder's, and no
14
+ // statusLine command (CLAUDE_CLI_KEY_CHAT_SETTINGS, on the command line).
15
+ // - The model: DeepSeek's ids, with Claude Code's `[1m]` suffix so the CLI
16
+ // plans with the 1M window; helper calls (titles, summaries) go to flash.
17
+ // Claude Code's model aliases are pinned to the session's model
18
+ // (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS/SONNET/FABLE_MODEL,
19
+ // CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL), so a
20
+ // sub-agent asked for as "opus" runs on it and not, by DeepSeek's mapping
21
+ // of a Claude id, on V4 Pro; a model switch respawns the CLI for the pins.
22
+ // A Claude id the CLI still names (a session spawned before the pins) is
23
+ // read as the DeepSeek model DeepSeek serves it with (deepseekServedModel).
24
+ // - The effort: DeepSeek's low/high/max, which Claude Code sends as
25
+ // `output_config.effort` with adaptive thinking (captured 2026-10-24). It
26
+ // is applied at spawn (CLAUDE_CODE_EFFORT_LEVEL), so a change respawns the
27
+ // CLI between turns on the same session. There is no thinking-off level:
28
+ // Claude Code never sends `thinking: {type: disabled}` for these models.
29
+ // - What it reports: no Claude plan usage and no Claude usage limit, but the
30
+ // CLI's token totals per model after each turn (POST
31
+ // /api/deepseek-plan-usage) for the DeepSeek card's estimated spend; images
32
+ // only to models that take them; DeepSeek's errors in its own words.
33
+ //
34
+ // Source for the endpoint and the variables: DeepSeek's Claude Code guide
35
+ // (api-docs.deepseek.com, quick_start/agent_integrations/claude_code).
36
+
37
+ import { deepseekAnthropicBaseUrl } from '../../shared/deepseek-api.mjs';
38
+ import { isHostClaudeSessionVariable } from '../../shared/host-claude-session-env.mjs';
39
+ import { CLAUDE_CLI_KEY_CHAT_SETTINGS, claudeCliPinnedEnv } from '../claude-worker/claude-cli-pinned-env.mjs';
40
+ import {
41
+ DEEPSEEK_PROVIDER_TYPE,
42
+ DEFAULT_DEEPSEEK_MODEL,
43
+ deepseekModelAcceptsImages,
44
+ deepseekReasoningEffortFor,
45
+ deepseekServedModel,
46
+ isDeepSeekModelId,
47
+ } from '../../shared/deepseek-models.mjs';
48
+
49
+ const LONG_CONTEXT_SUFFIX = '[1m]';
50
+ const LONG_CONTEXT_SUFFIX_PATTERN = /\[1m\]$/i;
51
+ // Claude Code's helper model; DeepSeek's guide points it at flash.
52
+ const HELPER_MODEL = 'deepseek-flash';
53
+ // Claude Code's alias for its helper model. The other aliases ("opus",
54
+ // "sonnet", "fable", and a sub-agent without a model) resolve to the pins
55
+ // spawnEnv sets, that is to the session's model.
56
+ const HELPER_ALIAS = 'haiku';
57
+ // Where Claude Code reads the model behind each of its aliases, and the model
58
+ // of sub-agents and workflow agents (the bundled CLI, 2026-10-24). All pinned
59
+ // to the session's model; CLAUDE_CODE_SUBAGENT_MODEL_FORCE is left unset, so
60
+ // a sub-agent asked for as "haiku" still runs on the helper model.
61
+ const SESSION_MODEL_VARIABLES = Object.freeze([
62
+ 'ANTHROPIC_MODEL',
63
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
64
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
65
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL',
66
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
67
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
68
+ ]);
69
+ const HELPER_MODEL_VARIABLES = Object.freeze(['ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL']);
70
+
71
+ // Variables that would point the CLI at another Anthropic configuration or
72
+ // hand it another credential. Every ANTHROPIC_* is dropped as well.
73
+ const FOREIGN_CLI_VARIABLES = Object.freeze([
74
+ 'CLAUDE_CODE_OAUTH_TOKEN',
75
+ 'CLAUDE_CODE_USE_BEDROCK',
76
+ 'CLAUDE_CODE_USE_VERTEX',
77
+ 'CLAUDE_CODE_USE_FOUNDRY',
78
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
79
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
80
+ 'CLAUDE_CODE_USE_MANTLE',
81
+ 'CLAUDE_CODE_USE_GATEWAY',
82
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
83
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
84
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
85
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
86
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
87
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
88
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
89
+ 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
90
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
91
+ 'CLAUDE_CODE_EFFORT_LEVEL',
92
+ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
93
+ // Claude Code's subprocess env scrub, if the host exports it: it forces the
94
+ // permission mode to default (a chat in plan mode could edit files) and
95
+ // runs every Bash command in a bubblewrap sandbox.
96
+ 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
97
+ // What the relay hands its worker (session-worker-launch-service.mjs,
98
+ // TMUX_WORKER_EXPORTED_ENV_VARS): the worker's own settings, which neither
99
+ // Claude Code nor anything the agent runs reads. A shell of the agent's
100
+ // that starts a relay of its own would take the worker's kind, folders and
101
+ // tools file for its own. The relay's Claude configuration
102
+ // (CLAUDE_CONFIG_DIR, replaced below; CLAUDE_CODE_EXECUTABLE) is not on it.
103
+ 'COPILOT_ALLOW_ALL',
104
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
105
+ 'COPILOT_SDK_PATH',
106
+ 'EXTENSION_PATH',
107
+ 'COPILOT_MODEL',
108
+ 'COPILOT_RELAY_MODEL',
109
+ 'CLAUDE_RELAY_MODEL',
110
+ 'CLAUDE_CLOUD_RELAY_MODEL',
111
+ 'CURSOR_RELAY_MODEL',
112
+ 'CURSOR_AGENT_STORE_DIR',
113
+ 'GROK_RELAY_MODEL',
114
+ 'GROK_CLI_COMMAND',
115
+ 'GROK_ALWAYS_APPROVE',
116
+ 'SESSION_ID',
117
+ 'COPILOT_WORKSPACE_ROOT',
118
+ 'INIT_CWD',
119
+ ]);
120
+ // The same by prefix: the relay's folders, config, tools file, worker kind
121
+ // and log file (COPILOT_WEB_RELAY_*), the Copilot provider settings, every
122
+ // DeepSeek setting of the worker's (the key included: the CLI gets it as
123
+ // ANTHROPIC_AUTH_TOKEN) and the relay's own OAR_* switches.
124
+ const FOREIGN_CLI_PREFIXES = Object.freeze(['ANTHROPIC_', 'COPILOT_WEB_RELAY_', 'COPILOT_PROVIDER_', 'DEEPSEEK_', 'OAR_']);
125
+
126
+ /**
127
+ * Whether a variable of the worker's stays out of the CLI's environment:
128
+ * another Anthropic configuration or credential, the relay's settings for
129
+ * its worker, or the host Claude Code session's variables when the relay was
130
+ * started from inside one (shared/host-claude-session-env.mjs; the launcher
131
+ * drops those for every worker, the backend again, so a CLI talking to
132
+ * DeepSeek never reads itself as a child of the host's session, however the
133
+ * worker was started).
134
+ */
135
+ function isForeignCliVariable(key) {
136
+ return FOREIGN_CLI_VARIABLES.includes(key)
137
+ || FOREIGN_CLI_PREFIXES.some((prefix) => key.startsWith(prefix))
138
+ || isHostClaudeSessionVariable(key);
139
+ }
140
+
141
+ function text(value) {
142
+ return String(value ?? '').trim();
143
+ }
144
+
145
+ function withoutSuffix(value) {
146
+ return text(value).replace(LONG_CONTEXT_SUFFIX_PATTERN, '');
147
+ }
148
+
149
+ /**
150
+ * The relay's id of a model the CLI names: "deepseek-flash[1m]" →
151
+ * "deepseek-flash"; a Claude id ("claude-opus-5-5", from a CLI spawned
152
+ * without the alias pins) → the DeepSeek model that served it; anything else
153
+ * as given, without the suffix.
154
+ */
155
+ export function deepseekRelayModel(value) {
156
+ return deepseekServedModel(value) || withoutSuffix(value);
157
+ }
158
+
159
+ /** The id the CLI gets: the relay's id with the 1M suffix. */
160
+ export function deepseekCliModel(value) {
161
+ const model = deepseekRelayModel(value);
162
+ return model ? `${model}${LONG_CONTEXT_SUFFIX}` : '';
163
+ }
164
+
165
+ /**
166
+ * The DeepSeek model a sub-agent runs on, from what the runner knows of it:
167
+ * the model its own frames name (a DeepSeek id, or a Claude id DeepSeek
168
+ * served), else the alias the spawning tool pinned ("haiku" is the helper
169
+ * model, every other alias the session's model), else the session's model.
170
+ */
171
+ export function deepseekSubagentModel(value, sessionModel = '') {
172
+ const served = deepseekServedModel(value);
173
+ if (served) return served;
174
+ if (withoutSuffix(value).toLowerCase() === HELPER_ALIAS) return HELPER_MODEL;
175
+ return deepseekRelayModel(sessionModel);
176
+ }
177
+
178
+ /** The session model a CLI is pinned to, in the CLI's terms; unknown or none is the default model. */
179
+ function pinnedCliModel(model) {
180
+ const relayModel = deepseekRelayModel(model);
181
+ return deepseekCliModel(isDeepSeekModelId(relayModel) ? relayModel : DEFAULT_DEEPSEEK_MODEL);
182
+ }
183
+
184
+ /**
185
+ * `{ code, guidance }` for a failed request DeepSeek has its own advice for,
186
+ * or null. Read from the HTTP status the result reports, else from the
187
+ * status the CLI quotes in its error text ("API Error: 402 …").
188
+ */
189
+ export function describeDeepSeekFailure(errorText, { status = null } = {}) {
190
+ const message = text(errorText);
191
+ const code = Number(status);
192
+ if (code === 402 || /\b402\b|insufficient[ _]balance/i.test(message)) {
193
+ return {
194
+ code: 'deepseek-balance',
195
+ guidance: 'The DeepSeek account balance is used up. Top it up at https://platform.deepseek.com/top_up, then send the message again.',
196
+ };
197
+ }
198
+ if (code === 401 || /\b401\b|authentication[_ ]error|invalid api key/i.test(message)) {
199
+ return {
200
+ code: 'deepseek-api-key',
201
+ guidance: 'DeepSeek did not accept the API key. Check it in Settings → Providers → DeepSeek, then send the message again.',
202
+ };
203
+ }
204
+ if (code === 429 || /\b429\b|rate[_ ]limit/i.test(message)) {
205
+ return {
206
+ code: 'deepseek-rate-limit',
207
+ guidance: 'DeepSeek is at its concurrency limit for this account. Send the message again in a moment.',
208
+ };
209
+ }
210
+ return null;
211
+ }
212
+
213
+ export function createDeepSeekClaudeBackend({ env = process.env } = {}) {
214
+ const apiKey = text(env.DEEPSEEK_API_KEY);
215
+ const anthropicBaseUrl = deepseekAnthropicBaseUrl(text(env.DEEPSEEK_BASE_URL));
216
+ const configDir = text(env.DEEPSEEK_CLAUDE_CONFIG_DIR);
217
+
218
+ /** DeepSeek's level for a requested effort; unset and unknown are high. */
219
+ function effortFor(value) {
220
+ return deepseekReasoningEffortFor(value) || 'high';
221
+ }
222
+
223
+ /**
224
+ * The CLI's environment. Built from the worker's own (which must not reach
225
+ * the CLI as it is: it carries DEEPSEEK_API_KEY and possibly the host's
226
+ * Anthropic settings), so every Anthropic variable is replaced. `model` is
227
+ * the session's model, which every alias is pinned to.
228
+ */
229
+ function spawnEnv(baseEnv = process.env, { effort = '', model = '' } = {}) {
230
+ if (!apiKey) throw new Error('deepseek-api-key-not-configured');
231
+ if (!configDir) throw new Error('deepseek-config-dir-not-configured');
232
+ const next = {};
233
+ for (const [key, value] of Object.entries(baseEnv || {})) {
234
+ if (isForeignCliVariable(key)) continue;
235
+ next[key] = value;
236
+ }
237
+ next.ANTHROPIC_BASE_URL = anthropicBaseUrl;
238
+ next.ANTHROPIC_AUTH_TOKEN = apiKey;
239
+ // Every alias Claude Code resolves on its own ("opus", a sub-agent
240
+ // without a model) lands on the session's model: without a pin the CLI
241
+ // sends a Claude id, which DeepSeek serves by its own mapping
242
+ // (claude-opus → V4 Pro), off the model the chat is locked to.
243
+ const sessionModel = pinnedCliModel(model);
244
+ for (const key of SESSION_MODEL_VARIABLES) next[key] = sessionModel;
245
+ for (const key of HELPER_MODEL_VARIABLES) next[key] = HELPER_MODEL;
246
+ next.CLAUDE_CONFIG_DIR = configDir;
247
+ next.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
248
+ next.CLAUDE_CODE_EFFORT_LEVEL = effortFor(effort);
249
+ return next;
250
+ }
251
+
252
+ return {
253
+ id: DEEPSEEK_PROVIDER_TYPE,
254
+ providerType: DEEPSEEK_PROVIDER_TYPE,
255
+ label: 'DeepSeek',
256
+ configDir,
257
+ defaultModel: DEFAULT_DEEPSEEK_MODEL,
258
+ // Neither is DeepSeek's: Claude's plan windows and its usage limit.
259
+ reportsPlanUsage: false,
260
+ reportsUsageLimit: false,
261
+ // What it reports instead: the CLI's token totals per model, by the
262
+ // relay's model ids (relayModel); the relay books what each report adds.
263
+ // Claude Code's `total_cost_usd` is at Claude's prices and is not sent.
264
+ usageRoute: '/api/deepseek-plan-usage',
265
+
266
+ // A Claude id is not a model of DeepSeek's to request, only one it may
267
+ // have served (relayModel).
268
+ isModel: (value) => isDeepSeekModelId(withoutSuffix(value)),
269
+ cliModel: deepseekCliModel,
270
+ relayModel: deepseekRelayModel,
271
+ subagentModel: deepseekSubagentModel,
272
+ acceptsImages: (model) => deepseekModelAcceptsImages(deepseekRelayModel(model)),
273
+ describeFailure: describeDeepSeekFailure,
274
+
275
+ effortFor,
276
+ /** The SDK's `effort` option: DeepSeek's level. */
277
+ effortOption: effortFor,
278
+ /** DeepSeek always thinks (see the effort note above). */
279
+ thinkingEnabled: () => true,
280
+ /**
281
+ * What only a fresh CLI takes, the effort and the model the aliases are
282
+ * pinned to: a change respawns it between turns. `model` is the turn's
283
+ * resolved model (the runner's, not the message's alone).
284
+ */
285
+ spawnKey: (message = {}, { model = '' } = {}) => `${effortFor(message.reasoningEffort)} ${pinnedCliModel(model)}`,
286
+
287
+ spawnEnv,
288
+
289
+ /**
290
+ * The settings layer every CLI of this backend is spawned with, on its
291
+ * command line (no key in it): no hooks and no statusLine command, from
292
+ * the user's settings or the chat folder's. They would run with the key
293
+ * in their environment, a folder's SessionStart hook before the pins.
294
+ */
295
+ flagSettings: () => ({ ...CLAUDE_CLI_KEY_CHAT_SETTINGS }),
296
+
297
+ /**
298
+ * The CLI's routing, trust and credentials once more, in its flag
299
+ * settings layer, which outranks the settings of the chat's folder: a
300
+ * .claude/settings.json or settings.local.json there whose
301
+ * env.ANTHROPIC_BASE_URL, proxy or CA points elsewhere would otherwise
302
+ * get the DeepSeek key as its bearer token (scripts/deepseek-cli-proof.mjs).
303
+ * Every variable of CLAUDE_CLI_PINNED_ENV holds the value of `cliEnv`,
304
+ * the environment the CLI was spawned with (spawnEnv), or is blanked, and
305
+ * so is an apiKeyHelper of the folder's. The key may not go on the
306
+ * command line, so the adapter sends these over the control channel
307
+ * before the first message (startClaudeSession).
308
+ */
309
+ liveFlagSettings(cliEnv = spawnEnv(env)) {
310
+ if (!apiKey) throw new Error('deepseek-api-key-not-configured');
311
+ return {
312
+ env: { ...claudeCliPinnedEnv(cliEnv), ANTHROPIC_BASE_URL: anthropicBaseUrl, ANTHROPIC_AUTH_TOKEN: apiKey },
313
+ apiKeyHelper: '',
314
+ };
315
+ },
316
+ };
317
+ }
@@ -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
+ });
@@ -358,7 +358,7 @@ export class AcpClient extends EventEmitter {
358
358
  * `holdStall` (async, optional) is asked once the inactivity window has run
359
359
  * out: `true` re-arms the full window instead of failing the prompt. It is
360
360
  * for work the agent is blocked on that this client cannot see — a
361
- * remote_relay call the agent runs through its MCP server, which the relay
361
+ * relay_tool call the agent runs through its MCP server, which the relay
362
362
  * reports as in flight. A question that fails or takes longer than
363
363
  * `holdCheckTimeoutMs` counts as `false`.
364
364
  *
@@ -134,7 +134,7 @@ export function createGrokTurnRunner({
134
134
  } = {}) {
135
135
  const terminalTasks = taskPublisher;
136
136
  // The OAR MCP server gives Grok the relay's real tools — `preview`, and
137
- // `remote_relay` when remotes are paired — bound to this conversation.
137
+ // `relay_tool` when remotes are paired — bound to this conversation.
138
138
  // `OAR_MCP_SERVER=0` is the operator's way back to the preview instruction
139
139
  // block, should an agent build turn out to ignore ACP `mcpServers`.
140
140
  const oarMcpLaunch = oarMcpServer !== undefined
@@ -238,7 +238,7 @@ export function createGrokTurnRunner({
238
238
  }
239
239
 
240
240
  /**
241
- * The inactivity hold. A remote_relay call runs inside the MCP server, which
241
+ * The inactivity hold. A relay_tool call runs inside the MCP server, which
242
242
  * this worker cannot see: Grok just goes quiet for as long as the relay
243
243
  * waits on the remote turn. Before the ACP watchdog calls that a stall, the
244
244
  * relay is asked whether a call of this conversation is still in flight.
@@ -247,7 +247,7 @@ export function createGrokTurnRunner({
247
247
  */
248
248
  async function remoteRelayCallInFlight() {
249
249
  const inflight = await fetchRemoteRelayInflight({ api, conversationId: sdkSessionId });
250
- if (inflight > 0) dbg('remote_relay call in flight; holding the inactivity watchdog', String(inflight));
250
+ if (inflight > 0) dbg('relay_tool call in flight; holding the inactivity watchdog', String(inflight));
251
251
  return inflight > 0;
252
252
  }
253
253
 
@@ -447,7 +447,7 @@ export function createGrokTurnRunner({
447
447
  // absolute cap: "No limit" (0) must not be silently trimmed to the
448
448
  // 30-minute default. The inactivity watchdog stays — it detects a dead
449
449
  // transport, not a long turn — but asks the relay before it fires while
450
- // Grok may be blocked on a remote_relay call.
450
+ // Grok may be blocked on a relay_tool call.
451
451
  const watchdog = {
452
452
  ...(turnCeilingMs !== null ? { maxTurnMs: turnCeilingMs > 0 ? turnCeilingMs : 0 } : {}),
453
453
  ...(acpMcpServers.length ? { holdStall: remoteRelayCallInFlight } : {}),
@@ -64,7 +64,7 @@ export function summarizeToolInput(input) {
64
64
  }
65
65
 
66
66
  export function formatToolActivityText(toolName, input, title = '') {
67
- // The OAR MCP server's `remote_relay`, however the agent labels it (bare,
67
+ // The OAR MCP server's `relay_tool`, however the agent labels it (bare,
68
68
  // or namespaced by the server), reads as what it does on which relay.
69
69
  if (isRemoteRelayToolName(toolName) || isRemoteRelayToolName(title)) {
70
70
  const remoteSummary = remoteRelayActivitySummary(input);
@@ -2,7 +2,7 @@
2
2
  // OAR's own stdio MCP server. It gives the agents OAR launches without an
3
3
  // in-process tool surface — Grok (ACP `mcpServers`) and the Copilot CLI
4
4
  // extension engine (`--additional-mcp-config`) — the relay's real tools:
5
- // `remote_relay` and `preview`, bound to the one conversation it was started
5
+ // `relay_tool` and `preview`, bound to the one conversation it was started
6
6
  // for, so the relay's mention gate and approval apply exactly as they do for
7
7
  // the Claude, Cursor and Copilot SDK workers.
8
8
  //
@@ -34,6 +34,7 @@ import {
34
34
  REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS,
35
35
  REMOTE_RELAY_TOOL_DESCRIPTION,
36
36
  REMOTE_RELAY_TOOL_NAME,
37
+ canonicalRemoteRelayToolCallName,
37
38
  cloneRemoteRelayToolInputSchema,
38
39
  executeRemoteRelayTool,
39
40
  formatRemoteRelayToolResult,
@@ -216,10 +217,15 @@ export function createOarMcpServer({
216
217
  }
217
218
 
218
219
  async function callTool(id, params) {
219
- const name = toText(params?.name);
220
+ // `remote_relay`, the tool's name up to 0.9.17, still dispatches while
221
+ // the tool is on, but is never listed: a second definition would only
222
+ // cost context. The hosts in use (Grok, the Copilot CLI) call only what
223
+ // tools/list gave them, so for them the old name is gone; a client that
224
+ // kept it still works.
225
+ const name = canonicalRemoteRelayToolCallName(params?.name);
220
226
  const tools = await listTools();
221
227
  if (!tools.some((tool) => tool.name === name)) {
222
- return fail(id, JSON_RPC.invalidParams, `Unknown tool: ${name || '(none)'}`);
228
+ return fail(id, JSON_RPC.invalidParams, `Unknown tool: ${toText(params?.name) || '(none)'}`);
223
229
  }
224
230
  const args = params?.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
225
231
  ? params.arguments
@@ -348,7 +354,7 @@ export function createOarMcpServer({
348
354
 
349
355
  /**
350
356
  * The stdio process: parse the arguments, reach the relay like a worker does,
351
- * decide whether `remote_relay` is listed (asked once, at startup), then serve
357
+ * decide whether `relay_tool` is listed (asked once, at startup), then serve
352
358
  * until stdin closes. Returns the server, or null after a startup failure.
353
359
  */
354
360
  export async function runOarMcpServer({
@@ -413,7 +419,7 @@ export async function runOarMcpServer({
413
419
  remoteRelay: Promise.resolve()
414
420
  .then(() => shouldRegisterRemoteRelayToolImpl({ api }))
415
421
  .then((enabled) => {
416
- log(`serving conversation ${conversationId.slice(0, 8)}; remote_relay ${enabled ? 'on' : 'off'}`);
422
+ log(`serving conversation ${conversationId.slice(0, 8)}; relay_tool ${enabled ? 'on' : 'off'}`);
417
423
  return enabled;
418
424
  }),
419
425
  version: readPackageVersion(),
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek usage totals (services/plan-usage-service.mjs, the
5
+ * DeepSeek card in Check Usage).
6
+ *
7
+ * `deepseek_usage_totals`: one row per DeepSeek model this relay's DeepSeek
8
+ * turns used, summed over every turn the workers reported
9
+ * (POST /api/deepseek-plan-usage). A row only ever grows.
10
+ *
11
+ * - `model`: the model's id as the CLI reported it, without Claude Code's
12
+ * `[1m]` suffix, lower case.
13
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: input DeepSeek served from
14
+ * its cache, input it did not, and output.
15
+ * - `turn_count`: the reports that named the model.
16
+ * - `cost_usd`, `cost_cny`: the estimate from shared/deepseek-pricing.mjs at
17
+ * the price of each turn's own time (peak or off-peak); NULL while no turn
18
+ * of the model could be priced (a model the price table does not know).
19
+ * - `first_seen_at`, `last_seen_at`: the earliest and latest turn time.
20
+ *
21
+ * `deepseek_usage_checkpoints`: what a worker last reported per
22
+ * (conversation, Claude Code session, model). A worker reports the CLI's
23
+ * running totals, and a CLI resumed on a session starts from that session's
24
+ * earlier totals, so the relay books only what a report adds to the totals
25
+ * last reported for the session: a restarted worker or a resumed CLI books
26
+ * nothing twice, and a new session starts from zero. A total below the
27
+ * checkpoint is a CLI counting from zero again: that report is booked in
28
+ * full and the checkpoint starts over from it.
29
+ *
30
+ * - `conversation_id`, `native_session_id` (the CLI's session id), `model`.
31
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: the totals last reported.
32
+ *
33
+ * Additive and idempotent. Safe to run on every boot.
34
+ *
35
+ * Usage: node server/migrations/0024-deepseek-usage-totals.mjs [path/to/copilot.db]
36
+ */
37
+
38
+ import { fileURLToPath } from 'url';
39
+ import path from 'path';
40
+ import process from 'process';
41
+ import Database from 'better-sqlite3';
42
+
43
+ export const DEEPSEEK_USAGE_TOTALS_TABLE = 'deepseek_usage_totals';
44
+ export const DEEPSEEK_USAGE_CHECKPOINTS_TABLE = 'deepseek_usage_checkpoints';
45
+
46
+ export const DEEPSEEK_USAGE_TOTALS_COLUMNS = Object.freeze([
47
+ 'model',
48
+ 'hit_tokens',
49
+ 'miss_tokens',
50
+ 'output_tokens',
51
+ 'turn_count',
52
+ 'cost_usd',
53
+ 'cost_cny',
54
+ 'first_seen_at',
55
+ 'last_seen_at',
56
+ 'updated_at',
57
+ ]);
58
+
59
+ export const DEEPSEEK_USAGE_CHECKPOINT_COLUMNS = Object.freeze([
60
+ 'conversation_id',
61
+ 'native_session_id',
62
+ 'model',
63
+ 'hit_tokens',
64
+ 'miss_tokens',
65
+ 'output_tokens',
66
+ 'updated_at',
67
+ ]);
68
+
69
+ function tableExists(db, name) {
70
+ return !!db
71
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
72
+ .get(name);
73
+ }
74
+
75
+ /** What keeps the service from using the table: '' when nothing does. */
76
+ export function deepseekUsageTotalsTableProblem(db) {
77
+ for (const [table, columns] of [
78
+ [DEEPSEEK_USAGE_TOTALS_TABLE, DEEPSEEK_USAGE_TOTALS_COLUMNS],
79
+ [DEEPSEEK_USAGE_CHECKPOINTS_TABLE, DEEPSEEK_USAGE_CHECKPOINT_COLUMNS],
80
+ ]) {
81
+ if (!tableExists(db, table)) return `no ${table} table`;
82
+ const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
83
+ const missing = columns.filter((name) => !present.has(name));
84
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
85
+ }
86
+ return '';
87
+ }
88
+
89
+ export function migrateDeepSeekUsageTotals(db) {
90
+ let tablesAdded = 0;
91
+ if (!tableExists(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
92
+ db.exec(`
93
+ CREATE TABLE ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} (
94
+ conversation_id TEXT NOT NULL,
95
+ native_session_id TEXT NOT NULL,
96
+ model TEXT NOT NULL,
97
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
98
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
99
+ output_tokens INTEGER NOT NULL DEFAULT 0,
100
+ updated_at TEXT NOT NULL,
101
+ PRIMARY KEY (conversation_id, native_session_id, model)
102
+ )
103
+ `);
104
+ tablesAdded += 1;
105
+ }
106
+ if (tableExists(db, DEEPSEEK_USAGE_TOTALS_TABLE)) return { applied: tablesAdded > 0, tablesAdded };
107
+ db.exec(`
108
+ CREATE TABLE ${DEEPSEEK_USAGE_TOTALS_TABLE} (
109
+ model TEXT PRIMARY KEY,
110
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
111
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
112
+ output_tokens INTEGER NOT NULL DEFAULT 0,
113
+ turn_count INTEGER NOT NULL DEFAULT 0,
114
+ cost_usd REAL,
115
+ cost_cny REAL,
116
+ first_seen_at TEXT NOT NULL,
117
+ last_seen_at TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL
119
+ )
120
+ `);
121
+ tablesAdded += 1;
122
+ return { applied: true, tablesAdded };
123
+ }
124
+
125
+ function defaultDbPath() {
126
+ const here = path.dirname(fileURLToPath(import.meta.url));
127
+ return path.resolve(here, '..', 'data', 'copilot.db');
128
+ }
129
+
130
+ export function migrate(dbPath, options = {}) {
131
+ const db = new Database(dbPath, options);
132
+ try {
133
+ return migrateDeepSeekUsageTotals(db);
134
+ } finally {
135
+ db.close();
136
+ }
137
+ }
138
+
139
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
140
+ if (isMain) {
141
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
142
+ try {
143
+ const result = migrate(dbPath);
144
+ console.log(`Migration 0024 on ${dbPath}`);
145
+ console.log(result.applied ? ` deepseek usage totals: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
146
+ process.exit(0);
147
+ } catch (err) {
148
+ console.error(`Migration 0024 failed: ${err?.message || err}`);
149
+ process.exit(1);
150
+ }
151
+ }