@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,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
+ }
@@ -0,0 +1,146 @@
1
+ // DeepSeek's published prices, for the usage card's estimate of what this
2
+ // relay's DeepSeek turns cost. DeepSeek reports no cost per request, and
3
+ // Claude Code's own `total_cost_usd` is computed at Claude's prices, so the
4
+ // estimate is tokens times this table.
5
+ //
6
+ // Source: api-docs.deepseek.com/quick_start/pricing (USD) and its zh-cn page
7
+ // (CNY), built 2026-10-19. Prices are per 1M tokens: a cache hit, a cache
8
+ // miss (input DeepSeek had not cached) and output. Peak hours cost exactly
9
+ // twice the off-peak price. Peak is Monday to Friday 01:00–04:00 and
10
+ // 06:00–10:00 UTC (09:00–12:00 and 14:00–18:00 Beijing time); everything else
11
+ // is off-peak. On Chinese public holidays DeepSeek charges off-peak prices all
12
+ // day; the estimate doesn't know those dates, so turns at peak hours on a
13
+ // holiday are counted at double the real price.
14
+ //
15
+ // A model the table does not know gets no estimate (null), never a guess.
16
+ //
17
+ // Claude Code's WebSearch runs on DeepSeek's Search API. Source:
18
+ // api-docs.deepseek.com/guides/search (USD) and its zh-cn page (CNY), read
19
+ // 2026-10-24: $0.75 / ¥5 per 1,000 successful requests for each of Search
20
+ // and Fetch, charged from 2026-11-01 00:00 Beijing time (2026-10-31 16:00
21
+ // UTC), no peak or off-peak. Requests before that are counted but cost 0.
22
+ // Claude Code's WebFetch is not a Fetch request: it reads the page on the
23
+ // relay host and sends the text to the model (plain tokens), and DeepSeek's
24
+ // API refuses the server-side fetch tool (422, checked 2026-10-25). The Fetch
25
+ // price stays for a fetch DeepSeek might report one day.
26
+
27
+ export const DEEPSEEK_PRICING_SOURCE = Object.freeze({
28
+ url: 'https://api-docs.deepseek.com/quick_start/pricing',
29
+ date: '2026-10-19',
30
+ });
31
+
32
+ export const DEEPSEEK_SEARCH_PRICING = Object.freeze({
33
+ url: 'https://api-docs.deepseek.com/guides/search',
34
+ date: '2026-10-24',
35
+ chargedFrom: '2026-10-31T16:00:00.000Z',
36
+ // Per 1,000 successful requests, the same for a search and a fetch.
37
+ perThousand: Object.freeze({ USD: 0.75, CNY: 5 }),
38
+ });
39
+
40
+ export const DEEPSEEK_PRICE_CURRENCIES = Object.freeze(['USD', 'CNY']);
41
+
42
+ const TOKENS_PER_PRICE_UNIT = 1_000_000;
43
+
44
+ function prices(hit, miss, output) {
45
+ return Object.freeze({ hit, miss, output });
46
+ }
47
+
48
+ const PRICE_TABLE = Object.freeze({
49
+ 'deepseek-flash': Object.freeze({
50
+ offPeak: Object.freeze({ USD: prices(0.003, 0.15, 0.60), CNY: prices(0.02, 1, 4) }),
51
+ peak: Object.freeze({ USD: prices(0.006, 0.30, 1.20), CNY: prices(0.04, 2, 8) }),
52
+ }),
53
+ 'deepseek-v4-pro': Object.freeze({
54
+ offPeak: Object.freeze({ USD: prices(0.022, 0.66, 1.98), CNY: prices(0.15, 4.5, 13.5) }),
55
+ peak: Object.freeze({ USD: prices(0.044, 1.32, 3.96), CNY: prices(0.30, 9, 27) }),
56
+ }),
57
+ });
58
+
59
+ // Ids DeepSeek still accepts and serves with the Flash model.
60
+ const PRICE_ALIASES = Object.freeze({
61
+ 'deepseek-v4-flash': 'deepseek-flash',
62
+ 'deepseek-v4-flash-vision-exp': 'deepseek-flash',
63
+ });
64
+
65
+ // [start, end) hours in UTC, Monday to Friday. The card shows them in the
66
+ // browser's time zone (plan-usage-view.mjs).
67
+ export const DEEPSEEK_PEAK_HOURS_UTC = Object.freeze([Object.freeze([1, 4]), Object.freeze([6, 10])]);
68
+ const PEAK_HOURS_UTC = DEEPSEEK_PEAK_HOURS_UTC;
69
+
70
+ function toDate(value) {
71
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
72
+ if (typeof value === 'number') {
73
+ const date = new Date(value);
74
+ return Number.isNaN(date.getTime()) ? null : date;
75
+ }
76
+ const text = String(value ?? '').trim();
77
+ if (!text) return null;
78
+ const date = new Date(text);
79
+ return Number.isNaN(date.getTime()) ? null : date;
80
+ }
81
+
82
+ /** The id the table prices a model under ("deepseek-v4-flash[1m]" → "deepseek-flash"), or '' when unknown. */
83
+ export function deepseekPricedModel(value) {
84
+ const id = String(value ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
85
+ if (PRICE_TABLE[id]) return id;
86
+ return PRICE_ALIASES[id] || '';
87
+ }
88
+
89
+ /** Whether DeepSeek bills an instant at its peak price; null for an unreadable time. */
90
+ export function isDeepSeekPeakTime(at) {
91
+ const date = toDate(at);
92
+ if (!date) return null;
93
+ const day = date.getUTCDay();
94
+ if (day === 0 || day === 6) return false;
95
+ const minutes = date.getUTCHours() * 60 + date.getUTCMinutes();
96
+ return PEAK_HOURS_UTC.some(([start, end]) => minutes >= start * 60 && minutes < end * 60);
97
+ }
98
+
99
+ /**
100
+ * The prices per 1M tokens for a model at an instant: `{ hit, miss, output,
101
+ * peak }`, or null for an unknown model, currency or time.
102
+ */
103
+ export function deepseekPriceFor({ model, at, currency = 'USD' } = {}) {
104
+ const entry = PRICE_TABLE[deepseekPricedModel(model)];
105
+ const unit = String(currency ?? '').trim().toUpperCase();
106
+ const peak = isDeepSeekPeakTime(at);
107
+ if (!entry || !DEEPSEEK_PRICE_CURRENCIES.includes(unit) || peak === null) return null;
108
+ return { ...entry[peak ? 'peak' : 'offPeak'][unit], peak };
109
+ }
110
+
111
+ function tokenCount(value) {
112
+ const number = Number(value ?? 0);
113
+ return Number.isFinite(number) && number > 0 ? number : 0;
114
+ }
115
+
116
+ /**
117
+ * What the tokens of one request (or one turn) cost at DeepSeek's price for
118
+ * its time, in `currency` (USD or CNY). A missing count is 0. Null when the
119
+ * model, the currency or the time is unknown.
120
+ */
121
+ export function estimateCost({ model, at, hitTokens = 0, missTokens = 0, outputTokens = 0, currency = 'USD' } = {}) {
122
+ const price = deepseekPriceFor({ model, at, currency });
123
+ if (!price) return null;
124
+ const cost = (
125
+ tokenCount(hitTokens) * price.hit
126
+ + tokenCount(missTokens) * price.miss
127
+ + tokenCount(outputTokens) * price.output
128
+ ) / TOKENS_PER_PRICE_UNIT;
129
+ // Ten decimals: enough for a single token, and no binary-float tails.
130
+ return Math.round(cost * 1e10) / 1e10;
131
+ }
132
+
133
+ /**
134
+ * What the web searches and fetches of one turn cost at the time `at`, in
135
+ * `currency`: 0 before DeepSeek started charging for them, null for an
136
+ * unreadable time or an unknown currency. Flat per request, peak or not.
137
+ */
138
+ export function estimateSearchCost({ at, searchRequests = 0, fetchRequests = 0, currency = 'USD' } = {}) {
139
+ const date = toDate(at);
140
+ const unit = String(currency ?? '').trim().toUpperCase();
141
+ const price = DEEPSEEK_SEARCH_PRICING.perThousand[unit];
142
+ if (!date || price === undefined) return null;
143
+ if (date.getTime() < Date.parse(DEEPSEEK_SEARCH_PRICING.chargedFrom)) return 0;
144
+ const cost = (tokenCount(searchRequests) + tokenCount(fetchRequests)) * price / 1000;
145
+ return Math.round(cost * 1e10) / 1e10;
146
+ }
@@ -0,0 +1,101 @@
1
+ // The variables of a Claude Code session the relay was started from.
2
+ //
3
+ // A relay started from inside a Claude Code session (an agent's shell, a
4
+ // `claude` run that starts it, a test relay an agent starts from a relay
5
+ // worker) carries that session's variables, and so would every worker: the
6
+ // Claude and DeepSeek workers' CLI would take itself for a child of the host's
7
+ // session (messaging socket and token, session id, entrypoint, effort), and a
8
+ // Copilot or Cursor worker has no use for them either. The launcher drops
9
+ // them for every kind and on every path (session-worker-launch-service.mjs),
10
+ // and the DeepSeek backend drops them again from its CLI's environment.
11
+ //
12
+ // A fixed list misses what the next CLI version adds, so the session's
13
+ // variables are matched by family as well:
14
+ // - the names Claude Code gives its child shells and MCP servers, those it
15
+ // sets on its own process (entrypoint, executable path), the markers an
16
+ // SDK host sets (its version and its app's name, which the CLI sends as
17
+ // `x-client-app`), and the per-spawn Bash cap a relay's Claude worker
18
+ // gives its CLI (HOST_CLAUDE_SESSION_ENV_VARS);
19
+ // - every `CLAUDE_CODE_SESSION_*` (id, attended, access token, kind, name,
20
+ // origin, ...), every `CLAUDE_CODE_*_SESSION_ID` (host, remote, bridge,
21
+ // cloud) and every `CLAUDE_CODE_MESSAGING_*`
22
+ // (HOST_CLAUDE_SESSION_ENV_PATTERNS).
23
+ // What stays is configuration: the relay's own (`CLAUDE_CONFIG_DIR`,
24
+ // `CLAUDE_CODE_EXECUTABLE`, a login token, which the launcher hands only to
25
+ // the Claude kinds) and the user's settings: `CLAUDE_CODE_*` ones (a
26
+ // provider such as `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_GIT_BASH_PATH` on
27
+ // Windows, the `DISABLE_*`/`ENABLE_*` switches) and the SDK's
28
+ // `CLAUDE_AGENT_SDK_*` switches (`DISABLE_BUILTIN_AGENTS`, `MCP_NO_PREFIX`,
29
+ // `DISABLE_MCP_MANIFESTS`), which no family reaches. The settings that fall
30
+ // in a family stay through HOST_CLAUDE_SESSION_KEEP_ENV_VARS. Every
31
+ // `CLAUDE_AGENT_SDK_*` and family name the bundled CLI and SDK read was
32
+ // sorted this way (2026-10-25).
33
+
34
+ export const HOST_CLAUDE_SESSION_ENV_VARS = Object.freeze([
35
+ 'CLAUDECODE',
36
+ 'CLAUDE_CODE_SESSION_ID',
37
+ 'CLAUDE_CODE_MESSAGING_SOCKET',
38
+ 'CLAUDE_CODE_MESSAGING_TOKEN',
39
+ 'CLAUDE_CODE_CHILD_SESSION',
40
+ 'CLAUDE_CODE_ENTRYPOINT',
41
+ 'CLAUDE_CODE_SESSION_ATTENDED',
42
+ 'CLAUDE_CODE_EXECPATH',
43
+ 'CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS',
44
+ 'CLAUDE_AGENT_SDK_VERSION',
45
+ 'CLAUDE_AGENT_SDK_CLIENT_APP',
46
+ 'CLAUDE_PID',
47
+ 'CLAUDE_EFFORT',
48
+ 'CLAUDE_PROJECT_DIR',
49
+ 'CLAUDE_ENV_FILE',
50
+ ]);
51
+
52
+ // Shell `case` patterns, matched against the upper-cased name in JavaScript.
53
+ export const HOST_CLAUDE_SESSION_ENV_PATTERNS = Object.freeze([
54
+ 'CLAUDE_CODE_SESSION_*',
55
+ 'CLAUDE_CODE_*_SESSION_ID',
56
+ 'CLAUDE_CODE_MESSAGING_*',
57
+ ]);
58
+
59
+ // Settings inside a family: a behaviour switch and a debug log switch.
60
+ export const HOST_CLAUDE_SESSION_KEEP_ENV_VARS = Object.freeze([
61
+ 'CLAUDE_CODE_SESSION_START_ANNOUNCEMENTS_BEFORE_PROMPT',
62
+ 'CLAUDE_CODE_SESSION_LOG',
63
+ ]);
64
+
65
+ const EXACT = new Set(HOST_CLAUDE_SESSION_ENV_VARS);
66
+ const KEEP = new Set(HOST_CLAUDE_SESSION_KEEP_ENV_VARS);
67
+ const PATTERNS = HOST_CLAUDE_SESSION_ENV_PATTERNS.map((pattern) => new RegExp(
68
+ `^${pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`,
69
+ ));
70
+
71
+ /**
72
+ * Whether a variable is the host Claude Code session's. Upper-cased first:
73
+ * Windows names are case-insensitive.
74
+ */
75
+ export function isHostClaudeSessionVariable(name) {
76
+ const key = String(name ?? '').toUpperCase();
77
+ if (!key || KEEP.has(key)) return false;
78
+ return EXACT.has(key) || PATTERNS.some((pattern) => pattern.test(key));
79
+ }
80
+
81
+ /** The environment without the host Claude Code session's variables. */
82
+ export function withoutHostClaudeSessionVariables(env = {}) {
83
+ const next = {};
84
+ for (const [key, value] of Object.entries(env || {})) {
85
+ if (!isHostClaudeSessionVariable(key)) next[key] = value;
86
+ }
87
+ return next;
88
+ }
89
+
90
+ /**
91
+ * A POSIX sh statement that unsets, in the shell it runs in, every variable
92
+ * of the host session the environment holds: the families as well as the
93
+ * names, for an environment the launcher never saw (a tmux server's). Names
94
+ * only; a value is never read.
95
+ */
96
+ export function hostClaudeSessionUnsetStatement() {
97
+ const keep = HOST_CLAUDE_SESSION_KEEP_ENV_VARS.length ? `${HOST_CLAUDE_SESSION_KEEP_ENV_VARS.join('|')}) ;; ` : '';
98
+ const drop = [...HOST_CLAUDE_SESSION_ENV_VARS, ...HOST_CLAUDE_SESSION_ENV_PATTERNS].join('|');
99
+ return `for oar_host_var in $(env | sed -n 's/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p'); do `
100
+ + `case "$oar_host_var" in ${keep}${drop}) unset "$oar_host_var";; esac; done; unset oar_host_var;`;
101
+ }
@@ -77,6 +77,13 @@ export function isSafeGrokModelId(value) {
77
77
  return isSafeProviderModelId(value);
78
78
  }
79
79
 
80
+ // DeepSeek model ids ("deepseek-flash", "deepseek-v4-pro") follow the same
81
+ // safe-token rules; that they are DeepSeek's is checked by the provider
82
+ // (shared/deepseek-models.mjs).
83
+ export function isSafeDeepSeekModelId(value) {
84
+ return isSafeProviderModelId(value);
85
+ }
86
+
80
87
  // The "[1m]" suffix marks the 1M-context variant of a Claude model. The UI
81
88
  // treats it as a context tier of the base model, not a separate model.
82
89
  export const CLAUDE_LONG_CONTEXT_SUFFIX = '[1m]';
@@ -173,7 +173,7 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
173
173
  const response = await api('POST', '/api/previews', body);
174
174
  const existing = response?.existing === true;
175
175
  const hint = parsed.port
176
- ? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
176
+ ? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Bind the dev server to 127.0.0.1, not 0.0.0.0: the relay's proxy reaches it there and nothing else should. Tell the user the URL and that the link is public.`
177
177
  : 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.';
178
178
  return {
179
179
  ok: true,
@@ -12,8 +12,10 @@
12
12
  export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
13
13
 
14
14
  // `claude-cloud` is its own provider, never an alias of `claude`: its worker
15
- // drives a session in Anthropic's cloud and spawns no CLI.
16
- export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud']);
15
+ // drives a session in Anthropic's cloud and spawns no CLI. `claude-api` is
16
+ // its own too: the Claude worker on a Console API key instead of the host's
17
+ // subscription login.
18
+ export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api']);
17
19
 
18
20
  export function isLegacyRelayProviderType(value) {
19
21
  const normalized = String(value || '').trim().toLowerCase();
@@ -25,7 +27,7 @@ export function isSessionWorkerProviderType(value) {
25
27
  return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
26
28
  }
27
29
 
28
- /** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
30
+ /** `'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api'` — for interpolation into SQL NOT IN (...) lists. */
29
31
  export function sessionWorkerProviderSqlList() {
30
32
  return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
31
33
  }
@@ -1,4 +1,4 @@
1
- // Questions that don't block: remote_relay's `ask` puts a question for the
1
+ // Questions that don't block: relay_tool's `ask` puts a question for the
2
2
  // owner into a session (a card there and in the Inbox) and returns at once;
3
3
  // the asking session keeps working and is told the answer later, as a relay
4
4
  // event (question_answered).
@@ -11,14 +11,20 @@
11
11
  // Every text of an ask is written by an agent, possibly on another relay:
12
12
  // whoever shows it shows it as plain text.
13
13
  //
14
- // SDK-free and side-effect free: imported by the server, the remote_relay
14
+ // SDK-free and side-effect free: imported by the server, the relay_tool
15
15
  // contract and the browser.
16
16
 
17
17
  // The kind a question card carries in /api/relay-questions and the Inbox:
18
18
  // `ask` (this feature), `approval` (a relay's Allow / Deny card on its own
19
- // agent's call, context.source 'remote_relay'), `ask_user` (every other card a
20
- // turn waits on).
19
+ // agent's call, context.source REMOTE_RELAY_APPROVAL_SOURCE), `ask_user`
20
+ // (every other card a turn waits on).
21
21
  export const RELAY_QUESTION_KINDS = Object.freeze(['ask', 'ask_user', 'approval']);
22
+
23
+ // The approval card's `context.source`, re-exported by the contract, which
24
+ // imports this module (defined here so neither import goes in a circle).
25
+ // Stored and sent between relays, so it kept the tool's name from before
26
+ // 0.9.18.
27
+ export const REMOTE_RELAY_APPROVAL_SOURCE = 'remote_relay';
22
28
  export const RELAY_ASK_KIND = 'ask';
23
29
 
24
30
  export const RELAY_ASK_URGENCIES = Object.freeze(['normal', 'now']);
@@ -141,5 +147,5 @@ export function relayQuestionKind(question) {
141
147
  const kind = toText(question?.kind);
142
148
  if (RELAY_QUESTION_KINDS.includes(kind)) return kind;
143
149
  const context = question && typeof question.context === 'object' && question.context ? question.context : {};
144
- return toText(context.source) === 'remote_relay' ? 'approval' : 'ask_user';
150
+ return toText(context.source) === REMOTE_RELAY_APPROVAL_SOURCE ? 'approval' : 'ask_user';
145
151
  }
@@ -8,7 +8,7 @@
8
8
  // then start with the relay's name). `replayed: true` marks an event a paired
9
9
  // relay kept while the link to it was down.
10
10
  //
11
- // SDK-free and side-effect free: imported by the server, the remote_relay
11
+ // SDK-free and side-effect free: imported by the server, the relay_tool
12
12
  // contract and the browser (which renders the event rows).
13
13
 
14
14
  import { RELAY_SCHEDULE_MESSAGE_KIND } from './relay-schedules.mjs';
@@ -22,7 +22,7 @@ export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
22
22
 
23
23
  export const RELAY_EVENT_TYPES = Object.freeze([
24
24
  'question_asked',
25
- // The owner answered or dismissed a question that does not block (remote_relay
25
+ // The owner answered or dismissed a question that does not block (relay_tool
26
26
  // `ask`). The asking session is always told, without a subscription; a
27
27
  // subscription that takes question_asked takes it too.
28
28
  'question_answered',
@@ -1,5 +1,5 @@
1
1
  // Relay schedules: a prompt the relay queues into a session at a set time,
2
- // once or every N minutes. An agent sets one with remote_relay's `schedule`
2
+ // once or every N minutes. An agent sets one with relay_tool's `schedule`
3
3
  // (its own conversation by default, or another session of this relay); the
4
4
  // relay keeps it in its database, so it outlives a restart that the agent's
5
5
  // own timers would not.
@@ -8,7 +8,7 @@
8
8
  // = 'relay-schedule'`) whose first line says what it is: automated, never the
9
9
  // user's, and it approves nothing.
10
10
  //
11
- // SDK-free and side-effect free: imported by the server and the remote_relay
11
+ // SDK-free and side-effect free: imported by the server and the relay_tool
12
12
  // contract (the browser keeps its own copy of what it needs:
13
13
  // server/public/app/relay-schedule-view.mjs).
14
14