@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +220 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +127 -29
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
- package/server/db-schema.mjs +63 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/bootstrap.js +101 -13
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/journal-view.js +31 -3
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +10 -0
- package/server/public/index.html +408 -33
- package/server/repositories/conversation-group-repository.mjs +13 -6
- package/server/routes/conversation-group-routes.mjs +2 -1
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +192 -15
- package/server/routes/sessions-routes.mjs +364 -55
- package/server/server-runtime.mjs +123 -10
- package/server/services/about-service.mjs +1 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/remote-relay-dispatcher.mjs +2 -1
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/remote-relay-contract.mjs +1 -1
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -0,0 +1,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
|
+
}
|
package/shared/model-id.mjs
CHANGED
|
@@ -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
|
+
}
|