@oar-sh/oar 0.9.16 → 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 (75) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +220 -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-sdk-adapter.mjs +167 -9
  7. package/server/claude-worker/claude-session-process.mjs +127 -29
  8. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  9. package/server/claude-worker/claude-session-worker.mjs +2 -253
  10. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  12. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  14. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
  16. package/server/db-schema.mjs +63 -0
  17. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  18. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  19. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  20. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  21. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  22. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  23. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  24. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  25. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  26. package/server/public/app/api-client.js +53 -0
  27. package/server/public/app/bootstrap.js +101 -13
  28. package/server/public/app/context-tier-options.mjs +4 -0
  29. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  30. package/server/public/app/conversation-view.js +2 -0
  31. package/server/public/app/deepseek-settings-ui.js +342 -0
  32. package/server/public/app/inbox-ui.js +14 -3
  33. package/server/public/app/journal-view.js +31 -3
  34. package/server/public/app/model-selector-options.mjs +11 -0
  35. package/server/public/app/model-vision-support.mjs +2 -0
  36. package/server/public/app/no-conversation-state.mjs +1 -1
  37. package/server/public/app/plan-usage-view.mjs +165 -13
  38. package/server/public/app/provider-readiness.mjs +4 -1
  39. package/server/public/app/relay-error-ctas.mjs +8 -0
  40. package/server/public/app/settings-modal.js +11 -0
  41. package/server/public/app/settings-tabs.js +1 -1
  42. package/server/public/app/socket-handlers.js +10 -0
  43. package/server/public/index.html +408 -33
  44. package/server/repositories/conversation-group-repository.mjs +13 -6
  45. package/server/routes/conversation-group-routes.mjs +2 -1
  46. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  47. package/server/routes/messages-routes.mjs +192 -15
  48. package/server/routes/sessions-routes.mjs +364 -55
  49. package/server/server-runtime.mjs +123 -10
  50. package/server/services/about-service.mjs +1 -0
  51. package/server/services/claude-session-leftovers.mjs +226 -0
  52. package/server/services/deepseek-balance-service.mjs +102 -0
  53. package/server/services/deepseek-settings-service.mjs +380 -0
  54. package/server/services/deepseek-usage-import.mjs +471 -0
  55. package/server/services/model-variant-catalog-service.mjs +1 -0
  56. package/server/services/plan-usage-contract.mjs +100 -5
  57. package/server/services/plan-usage-deepseek.mjs +931 -0
  58. package/server/services/plan-usage-service.mjs +431 -0
  59. package/server/services/provider-model-selection.mjs +1 -0
  60. package/server/services/provider-reasoning-effort.mjs +15 -2
  61. package/server/services/remote-relay-dispatcher.mjs +2 -1
  62. package/server/services/session-worker-launch-service.mjs +233 -82
  63. package/server/services/worker-secret-redaction.mjs +318 -0
  64. package/shared/context-window-fallbacks.mjs +3 -0
  65. package/shared/deepseek-api.mjs +137 -0
  66. package/shared/deepseek-models.mjs +114 -0
  67. package/shared/deepseek-pricing.mjs +146 -0
  68. package/shared/host-claude-session-env.mjs +101 -0
  69. package/shared/model-id.mjs +7 -0
  70. package/shared/preview-tool-core.mjs +1 -1
  71. package/shared/provider-routing.mjs +2 -2
  72. package/shared/remote-relay-contract.mjs +1 -1
  73. package/shared/steer-settle-failure.mjs +1 -0
  74. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  75. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -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,
@@ -13,7 +13,7 @@ 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
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']);
16
+ export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud', 'deepseek']);
17
17
 
18
18
  export function isLegacyRelayProviderType(value) {
19
19
  const normalized = String(value || '').trim().toLowerCase();
@@ -25,7 +25,7 @@ export function isSessionWorkerProviderType(value) {
25
25
  return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
26
26
  }
27
27
 
28
- /** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
28
+ /** `'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek'` — for interpolation into SQL NOT IN (...) lists. */
29
29
  export function sessionWorkerProviderSqlList() {
30
30
  return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
31
31
  }
@@ -154,7 +154,7 @@ export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_sessio
154
154
  export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
155
155
 
156
156
  export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
157
- export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud']);
157
+ export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek']);
158
158
  export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
159
159
  export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
160
160
  // Reasoning efforts differ per provider and model (none, low, medium, high,
@@ -85,6 +85,7 @@ export function steerAgentLabelForProvider(providerType) {
85
85
  case 'cursor': return 'Cursor';
86
86
  case 'grok': return 'Grok';
87
87
  case 'claude-cloud': return 'Claude Cloud';
88
+ case 'deepseek': return 'DeepSeek';
88
89
  default: return DEFAULT_STEER_AGENT_LABEL;
89
90
  }
90
91
  }
@@ -0,0 +1,128 @@
1
+ // A turn on an OpenAI image model (`gpt-image-*`, `dall-e-*`) is no chat
2
+ // turn: no chat runtime can serve it, and one that is handed it fails ("No
3
+ // available capacity was found for the model", or a request rejected as
4
+ // invalid). Both Copilot engines therefore send it straight to the relay's
5
+ // image routes instead, and this module is the one copy of that path.
6
+ //
7
+ // With image continuity on (the default) the relay created an image operation
8
+ // when the message was sent, and `POST /api/image-operations/:id/execute` does
9
+ // everything: the provider call, the generated-image reply, and on a failure
10
+ // the failure note or the requeue. The caller only reports the outcome. Without
11
+ // an operation the worker calls `POST /api/openai/images/generate` and
12
+ // publishes the reply itself.
13
+
14
+ export const DIRECT_IMAGE_OUTCOMES = Object.freeze(['completed', 'executing', 'terminal', 'uncertain', 'retryable']);
15
+
16
+ // The code of the error thrown when the provider refused the request outright:
17
+ // a retry would be refused the same way, so the turn fails instead of being
18
+ // requeued.
19
+ export const OPENAI_IMAGE_TERMINAL_CODE = 'RELAY_OPENAI_IMAGE_TERMINAL';
20
+
21
+ export function shouldUseDirectOpenAIImageApi(message = {}) {
22
+ const providerType = String(message?.providerType || '').trim().toLowerCase();
23
+ if (providerType !== 'openai') return false;
24
+ const model = String(message?.providerModel || message?.model || '').trim().toLowerCase();
25
+ return model.startsWith('gpt-image-') || model.startsWith('dall-e-');
26
+ }
27
+
28
+ export function isDirectOpenAIImageTerminalStatus(status) {
29
+ const numeric = Number(status);
30
+ if (!Number.isFinite(numeric)) return false;
31
+ return [400, 401, 403, 404, 409, 422, 429, 503].includes(numeric);
32
+ }
33
+
34
+ export function isOpenAIImageTerminalError(error) {
35
+ return String(error?.code || '').trim() === OPENAI_IMAGE_TERMINAL_CODE;
36
+ }
37
+
38
+ const isAutoModel = (value) => String(value || '').trim().toLowerCase() === 'auto';
39
+
40
+ /**
41
+ * Run one image turn. `requestOptions` goes to every `api` call that may wait
42
+ * on the provider (a worker passes `{ longCall: true }`: an edit can take
43
+ * minutes); `responseFields` is merged into the reply the worker publishes
44
+ * itself (the attempt fence).
45
+ *
46
+ * Resolves `{ via: 'operation', outcome }` or `{ via: 'direct', outcome:
47
+ * 'completed', imageCount, model }`. Throws an error with `code`
48
+ * `OPENAI_IMAGE_TERMINAL_CODE` when the provider refused the direct request,
49
+ * and a plain error for anything else (the caller requeues).
50
+ */
51
+ export async function runDirectOpenAIImageTurn({
52
+ api,
53
+ message,
54
+ requestOptions = {},
55
+ responseFields = {},
56
+ } = {}) {
57
+ if (message?.imageOperationId) {
58
+ const result = await api(
59
+ 'POST',
60
+ `/api/image-operations/${encodeURIComponent(message.imageOperationId)}/execute`,
61
+ { messageId: message.id },
62
+ requestOptions,
63
+ ).catch((error) => {
64
+ // The route answers a failure it already handled (published or
65
+ // requeued) with 409 or 503 and the outcome in the body.
66
+ if (error?.status === 409 || error?.status === 503) {
67
+ return error?.body || error?.payload || { outcome: error?.status === 503 ? 'retryable' : 'terminal' };
68
+ }
69
+ throw error;
70
+ });
71
+ const outcome = String(result?.outcome || '').trim().toLowerCase();
72
+ if (!DIRECT_IMAGE_OUTCOMES.includes(outcome)) {
73
+ throw new Error('Image operation returned an invalid outcome');
74
+ }
75
+ return { via: 'operation', outcome };
76
+ }
77
+
78
+ const requestedModel = isAutoModel(message?.model) ? '' : String(message?.model || '').trim();
79
+ const directModel = String(message?.providerModel || requestedModel || message?.model || '').trim();
80
+ const prompt = String(message?.text || '').trim();
81
+ if (!directModel) throw new Error('OpenAI image request is missing model');
82
+ if (!prompt) throw new Error('OpenAI image request is missing prompt');
83
+ await api('POST', '/api/activity', {
84
+ messageId: message.id,
85
+ conversationId: message.conversationId,
86
+ mode: message.relayMode || 'agent',
87
+ text: `Calling OpenAI image API directly (${directModel})`,
88
+ }).catch(() => {});
89
+ let directResult;
90
+ try {
91
+ directResult = await api('POST', '/api/openai/images/generate', {
92
+ messageId: message.id,
93
+ conversationId: message.conversationId,
94
+ model: directModel,
95
+ prompt,
96
+ n: message?.n,
97
+ size: message?.size || message?.contextTier,
98
+ quality: message?.quality || message?.reasoningEffort,
99
+ attachments: Array.isArray(message?.attachments) ? message.attachments : [],
100
+ }, requestOptions);
101
+ } catch (error) {
102
+ if (isDirectOpenAIImageTerminalStatus(error?.status)) {
103
+ const terminalError = new Error(
104
+ `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`,
105
+ );
106
+ terminalError.code = OPENAI_IMAGE_TERMINAL_CODE;
107
+ terminalError.status = error?.status;
108
+ terminalError.detail = error?.detail || null;
109
+ throw terminalError;
110
+ }
111
+ throw error;
112
+ }
113
+ const generatedImages = Array.isArray(directResult?.generatedImages) ? directResult.generatedImages : [];
114
+ if (!generatedImages.length) {
115
+ throw new Error('OpenAI image API returned no images');
116
+ }
117
+ const model = String(directResult?.model || directModel).trim() || directModel;
118
+ await api('POST', '/api/response', {
119
+ messageId: message.id,
120
+ conversationId: message.conversationId,
121
+ text: '',
122
+ generatedImages,
123
+ model,
124
+ modelOrigin: isAutoModel(message?.model) ? 'auto' : 'manual',
125
+ ...responseFields,
126
+ });
127
+ return { via: 'direct', outcome: 'completed', imageCount: generatedImages.length, model };
128
+ }
@@ -95,3 +95,63 @@ export function installWorkerLogFile({
95
95
  },
96
96
  };
97
97
  }
98
+
99
+ const REDACTED_SECRET_TEXT = '[redacted]';
100
+ // As the relay's redaction of worker posts (server/services/worker-secret-redaction.mjs):
101
+ // a shorter value would mangle ordinary text.
102
+ const MIN_REDACTED_SECRET_LENGTH = 8;
103
+
104
+ /**
105
+ * Replaces each secret's exact text with `[redacted]` in what the worker
106
+ * writes to stdout/stderr, on every platform: the console, the Linux
107
+ * launcher's redirect into the worker log, and the Windows copy above (install
108
+ * this after installWorkerLogFile so the copy gets the redacted text). A
109
+ * secret split across two writes is not caught; the worker logs whole lines.
110
+ * Returns null when there is no secret of at least 8 characters.
111
+ */
112
+ export function installWorkerLogRedaction({
113
+ secrets = [],
114
+ processImpl = process,
115
+ } = {}) {
116
+ const values = [...new Set((Array.isArray(secrets) ? secrets : [])
117
+ .map((value) => String(value ?? '').trim())
118
+ .filter((value) => value.length >= MIN_REDACTED_SECRET_LENGTH))]
119
+ .sort((a, b) => b.length - a.length);
120
+ if (!values.length) return null;
121
+
122
+ const redact = (text) => {
123
+ let output = text;
124
+ for (const value of values) {
125
+ if (output.includes(value)) output = output.split(value).join(REDACTED_SECRET_TEXT);
126
+ }
127
+ return output;
128
+ };
129
+
130
+ const restores = [];
131
+ for (const stream of [processImpl.stdout, processImpl.stderr]) {
132
+ if (!stream || typeof stream.write !== 'function') continue;
133
+ const originalWrite = stream.write;
134
+ const redactingWrite = function redactingWrite(chunk, encoding, callback) {
135
+ if (typeof chunk === 'string') {
136
+ return originalWrite.call(this, redact(chunk), encoding, callback);
137
+ }
138
+ if (chunk instanceof Uint8Array) {
139
+ const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
140
+ if (values.some((value) => bytes.includes(value))) {
141
+ const done = typeof encoding === 'function' ? encoding : callback;
142
+ return originalWrite.call(this, redact(bytes.toString('utf8')), 'utf8', done);
143
+ }
144
+ }
145
+ return originalWrite.call(this, chunk, encoding, callback);
146
+ };
147
+ stream.write = redactingWrite;
148
+ restores.push(() => {
149
+ if (stream.write === redactingWrite) stream.write = originalWrite;
150
+ });
151
+ }
152
+ return {
153
+ uninstall() {
154
+ for (const restore of restores) restore();
155
+ },
156
+ };
157
+ }