@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- 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/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -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 +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -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-claude-api.mjs +445 -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 +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -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 +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -29,9 +29,20 @@ import {
|
|
|
29
29
|
normalizeRelayScheduleAt,
|
|
30
30
|
normalizeRelayScheduleTitle,
|
|
31
31
|
} from './relay-schedules.mjs';
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
import {
|
|
33
|
+
RELAY_ASK_LIMITS,
|
|
34
|
+
RELAY_ASK_URGENCIES,
|
|
35
|
+
REMOTE_RELAY_APPROVAL_SOURCE,
|
|
36
|
+
normalizeRelayAskInput,
|
|
37
|
+
} from './relay-asks.mjs';
|
|
38
|
+
|
|
39
|
+
// The one name every provider lists the tool under. Up to 0.9.17 it was
|
|
40
|
+
// `remote_relay`; that name is still accepted where a runtime dispatches a
|
|
41
|
+
// call by name without having listed it (the OAR MCP server's tools/call),
|
|
42
|
+
// and recognised in transcripts and holds, but never listed: a second
|
|
43
|
+
// definition would cost every turn its context.
|
|
44
|
+
export const REMOTE_RELAY_TOOL_NAME = 'relay_tool';
|
|
45
|
+
export const REMOTE_RELAY_LEGACY_TOOL_NAME = 'remote_relay';
|
|
35
46
|
|
|
36
47
|
// Bumped when a relay starts relying on a newer inbound behaviour of its peers.
|
|
37
48
|
// 0 = a relay without this feature (reachable read/prompt only, no pairing).
|
|
@@ -154,8 +165,11 @@ export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_sessio
|
|
|
154
165
|
export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
|
|
155
166
|
|
|
156
167
|
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']);
|
|
168
|
+
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api']);
|
|
158
169
|
export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
|
|
170
|
+
// What `send` may name as `provider`: a Claude session's billing, the
|
|
171
|
+
// subscription or the API key. Naming the other one switches the session.
|
|
172
|
+
export const REMOTE_RELAY_SEND_PROVIDERS = Object.freeze(['claude', 'claude-api']);
|
|
159
173
|
export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
|
|
160
174
|
// Reasoning efforts differ per provider and model (none, low, medium, high,
|
|
161
175
|
// xhigh, max, ultracode, ...), so the contract only checks the shape of the
|
|
@@ -447,13 +461,15 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
447
461
|
provider: {
|
|
448
462
|
type: 'string',
|
|
449
463
|
enum: [...REMOTE_RELAY_PROVIDERS],
|
|
450
|
-
description: 'create_session: provider (default: the same provider as this session).'
|
|
464
|
+
description: 'create_session: provider (default: the same provider as this session). '
|
|
465
|
+
+ 'send to a Claude session: "claude" (the subscription) or "claude-api" (the API key) switches its billing from the next turn, '
|
|
466
|
+
+ 'together with this message; the other billing must be set up there. Omit it to keep the session\'s billing.',
|
|
451
467
|
},
|
|
452
468
|
model: {
|
|
453
469
|
type: 'string',
|
|
454
470
|
description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
|
|
455
471
|
+ 'send: switch the remote session to this model (default: it keeps its own). update_session: the session\'s model from now on. '
|
|
456
|
-
+ 'Must be an id relay_info lists for the provider (Claude also
|
|
472
|
+
+ 'Must be an id relay_info lists for the provider (Claude and Claude API also take "opus", "sonnet", "haiku"): '
|
|
457
473
|
+ 'an unknown id is refused with the close matches, nothing created or sent.',
|
|
458
474
|
},
|
|
459
475
|
cwd: {
|
|
@@ -660,7 +676,7 @@ export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
|
|
|
660
676
|
read_session: Object.freeze(['session', 'last', 'before', 'include_activity', 'max_chars']),
|
|
661
677
|
read_task_output: Object.freeze(['session', 'task_id', 'lines']),
|
|
662
678
|
wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
|
|
663
|
-
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy']),
|
|
679
|
+
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider']),
|
|
664
680
|
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds']),
|
|
665
681
|
update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
|
|
666
682
|
answer_question: Object.freeze(['question_id', 'answer', 'choices']),
|
|
@@ -867,6 +883,10 @@ function validateToolInput(input, { maxWaitSeconds, now } = {}) {
|
|
|
867
883
|
const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
|
|
868
884
|
if (!busyCheck.ok) return busyCheck;
|
|
869
885
|
args.if_busy = args.if_busy || 'queue';
|
|
886
|
+
const providerCheck = optionalEnum('provider', REMOTE_RELAY_SEND_PROVIDERS);
|
|
887
|
+
if (!providerCheck.ok) {
|
|
888
|
+
return invalid('send takes provider "claude" (the subscription) or "claude-api" (the API key), for a Claude session only');
|
|
889
|
+
}
|
|
870
890
|
} else {
|
|
871
891
|
const providerCheck = optionalEnum('provider', REMOTE_RELAY_PROVIDERS);
|
|
872
892
|
if (!providerCheck.ok) return providerCheck;
|
|
@@ -1222,8 +1242,8 @@ export function formatSessionGroupList(groups, max = REMOTE_RELAY_GROUP_LIMITS.r
|
|
|
1222
1242
|
// another relay (ask / plan mode) is an ordinary relay question whose
|
|
1223
1243
|
// `context.source` is this. Only that relay's user may answer it: its question
|
|
1224
1244
|
// routes hide the card from other relays' agents and refuse their answers, and
|
|
1225
|
-
// a dispatcher never offers one to its agent.
|
|
1226
|
-
export
|
|
1245
|
+
// a dispatcher never offers one to its agent. Defined in relay-asks.mjs.
|
|
1246
|
+
export { REMOTE_RELAY_APPROVAL_SOURCE };
|
|
1227
1247
|
|
|
1228
1248
|
/** True for such a card: a formatted question, or a stored request envelope (both carry `context`). */
|
|
1229
1249
|
export function isRemoteRelayApprovalQuestion(question) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The provider-facing side of the `
|
|
1
|
+
// The provider-facing side of the `relay_tool` tool, in one place.
|
|
2
2
|
//
|
|
3
3
|
// Every adapter (Claude's in-process MCP server, Cursor's customTools, the
|
|
4
4
|
// Copilot SDK engine's session tools, and the OAR stdio MCP server that Grok
|
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
17
17
|
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
18
18
|
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
19
|
+
REMOTE_RELAY_LEGACY_TOOL_NAME,
|
|
19
20
|
REMOTE_RELAY_TOOL_NAME,
|
|
20
21
|
summarizeRemoteRelayCall,
|
|
21
22
|
validateRemoteRelayToolInput,
|
|
22
23
|
} from './remote-relay-contract.mjs';
|
|
23
24
|
|
|
24
25
|
export {
|
|
26
|
+
REMOTE_RELAY_LEGACY_TOOL_NAME,
|
|
25
27
|
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
26
28
|
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
27
29
|
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
@@ -141,7 +143,7 @@ export function remoteRelayCancelledResult() {
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/**
|
|
144
|
-
* Executes one `
|
|
146
|
+
* Executes one `relay_tool` call: validate, then forward
|
|
145
147
|
* `{ conversationId, action, args }` to the local relay and hand back its JSON.
|
|
146
148
|
* Never throws — a refusal reads as an answer the model can act on, the same
|
|
147
149
|
* contract as executePreviewTool.
|
|
@@ -256,7 +258,7 @@ export function createRemoteRelayToolGate({
|
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/**
|
|
259
|
-
* How many
|
|
261
|
+
* How many relay_tool calls of this conversation the relay is still working
|
|
260
262
|
* on. 0 on any failure: the caller's watchdog then does what it would have
|
|
261
263
|
* done without the question.
|
|
262
264
|
*/
|
|
@@ -292,14 +294,29 @@ export function formatRemoteRelayToolResult(result) {
|
|
|
292
294
|
// ─── Activity lines ──────────────────────────────────────────────────────────
|
|
293
295
|
|
|
294
296
|
// A provider may report the tool under its bare name or namespaced by the MCP
|
|
295
|
-
// server that carries it (`
|
|
296
|
-
// `oar/
|
|
297
|
-
|
|
297
|
+
// server that carries it (`mcp__relay__relay_tool`, `oar__relay_tool`,
|
|
298
|
+
// `oar/relay_tool`, `oar-relay_tool`); all of them are this tool. So is the
|
|
299
|
+
// name it had before, `remote_relay`, in every spelling: transcripts and
|
|
300
|
+
// sessions from before the rename still render and hold the same way.
|
|
301
|
+
const REMOTE_RELAY_NAME_PATTERN = new RegExp(
|
|
302
|
+
`(?:^|__|[^A-Za-z0-9_])(?:${REMOTE_RELAY_TOOL_NAME}|${REMOTE_RELAY_LEGACY_TOOL_NAME})$`,
|
|
303
|
+
'i',
|
|
304
|
+
);
|
|
298
305
|
|
|
299
306
|
export function isRemoteRelayToolName(name) {
|
|
300
307
|
return REMOTE_RELAY_NAME_PATTERN.test(toText(name));
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The tool's listed name for a bare call name that means it: `relay_tool`
|
|
312
|
+
* itself, or the legacy `remote_relay` a dispatcher still accepts without
|
|
313
|
+
* listing it. Any other name comes back unchanged.
|
|
314
|
+
*/
|
|
315
|
+
export function canonicalRemoteRelayToolCallName(name) {
|
|
316
|
+
const text = toText(name);
|
|
317
|
+
return text === REMOTE_RELAY_LEGACY_TOOL_NAME ? REMOTE_RELAY_TOOL_NAME : text;
|
|
318
|
+
}
|
|
319
|
+
|
|
303
320
|
/**
|
|
304
321
|
* The arguments of a tool call as an object, whether the provider delivered
|
|
305
322
|
* them parsed or as a JSON string. Null when there is nothing usable.
|
|
@@ -85,6 +85,8 @@ 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';
|
|
89
|
+
case 'claude-api': return 'Claude API';
|
|
88
90
|
default: return DEFAULT_STEER_AGENT_LABEL;
|
|
89
91
|
}
|
|
90
92
|
}
|
|
@@ -19,7 +19,7 @@ function httpError(status, routePath, text) {
|
|
|
19
19
|
error.status = status;
|
|
20
20
|
error.detail = detail;
|
|
21
21
|
// The parsed error body, for callers that pass the relay's answer on as
|
|
22
|
-
// is (a refused
|
|
22
|
+
// is (a refused relay_tool call carries its code there).
|
|
23
23
|
error.body = payload && typeof payload === "object" ? payload : null;
|
|
24
24
|
return error;
|
|
25
25
|
}
|
|
@@ -89,7 +89,7 @@ function requestWithoutTimeout({ url, method, headers, payload, signal }) {
|
|
|
89
89
|
export function createApiClient({ serverUrl, token, getHeaders }) {
|
|
90
90
|
/**
|
|
91
91
|
* `options.longCall`: a call the relay may legitimately hold open for many
|
|
92
|
-
* minutes (the
|
|
92
|
+
* minutes (the relay_tool) — it goes over node:http without any
|
|
93
93
|
* timeout instead of fetch. `options.signal` aborts either kind; an aborted
|
|
94
94
|
* call rejects with an AbortError.
|
|
95
95
|
*/
|
|
@@ -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
|
+
}
|
|
@@ -33,6 +33,10 @@ export function createHeartbeatController({
|
|
|
33
33
|
// Optional: called after every beat the relay answered, for work that
|
|
34
34
|
// waits for a reachable relay (posts kept after a failure).
|
|
35
35
|
onAnswered = () => {},
|
|
36
|
+
// Optional: the kind of worker this is ("claude", "claude-api", ...). The
|
|
37
|
+
// relay hands a Claude chat's row only to a worker of the row's billing,
|
|
38
|
+
// and knows a worker's kind from what it says here and in its hello.
|
|
39
|
+
workerKind = null,
|
|
36
40
|
// requestPulse's window: one turn transition can flip the steering
|
|
37
41
|
// snapshot several times in quick succession, and each flip only needs to
|
|
38
42
|
// reach the relay once.
|
|
@@ -66,6 +70,7 @@ export function createHeartbeatController({
|
|
|
66
70
|
...(steering && typeof steering === "object" ? { steering } : {}),
|
|
67
71
|
...(settleFailed.length ? { settleFailed } : {}),
|
|
68
72
|
...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
|
|
73
|
+
...(workerKind ? { workerKind: String(workerKind) } : {}),
|
|
69
74
|
};
|
|
70
75
|
const response = await api("POST", "/api/heartbeat", body);
|
|
71
76
|
// Failed by the relay, or skipped because this worker has no claim on
|
|
@@ -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
|
+
}
|
|
@@ -66,6 +66,10 @@ export function createWorkerWebSocketLink({
|
|
|
66
66
|
onServerHello = () => {},
|
|
67
67
|
getSessionId = () => null,
|
|
68
68
|
getPid = () => null,
|
|
69
|
+
// The kind of worker this is ("claude", "claude-api", ...), stated in
|
|
70
|
+
// every hello and ready: the relay hands a Claude chat's row only to a
|
|
71
|
+
// worker of the row's billing.
|
|
72
|
+
workerKind = null,
|
|
69
73
|
minBackoffMs = 1000,
|
|
70
74
|
maxBackoffMs = 8_000,
|
|
71
75
|
jitterMs = 250,
|
|
@@ -170,6 +174,7 @@ export function createWorkerWebSocketLink({
|
|
|
170
174
|
reason,
|
|
171
175
|
sessionId: normalizeText(getSessionId()),
|
|
172
176
|
pid: normalizePositiveInt(getPid()),
|
|
177
|
+
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
173
178
|
...(held ? { ready: false } : {}),
|
|
174
179
|
});
|
|
175
180
|
if (sent) lastHelloSentAt = getNowMs();
|
|
@@ -197,6 +202,7 @@ export function createWorkerWebSocketLink({
|
|
|
197
202
|
reason,
|
|
198
203
|
sessionId: normalizeText(getSessionId()),
|
|
199
204
|
pid: normalizePositiveInt(getPid()),
|
|
205
|
+
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
200
206
|
});
|
|
201
207
|
if (sent) lastReadySentAt = getNowMs();
|
|
202
208
|
return sent;
|