@oar-sh/oar 0.9.24 → 0.9.26
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/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/api-client.js +58 -0
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +85 -25
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/context-usage-view.mjs +2 -2
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -35,6 +35,18 @@ import {
|
|
|
35
35
|
REMOTE_RELAY_APPROVAL_SOURCE,
|
|
36
36
|
normalizeRelayAskInput,
|
|
37
37
|
} from './relay-asks.mjs';
|
|
38
|
+
import {
|
|
39
|
+
OPENAI_IMAGE_BACKGROUNDS,
|
|
40
|
+
OPENAI_IMAGE_LIMITS,
|
|
41
|
+
OPENAI_IMAGE_OUTPUT_FORMATS,
|
|
42
|
+
validateOpenAIImageOptions,
|
|
43
|
+
} from './openai-image-options.mjs';
|
|
44
|
+
import {
|
|
45
|
+
OPENAI_IMAGE_BUILTIN_PRESETS,
|
|
46
|
+
OPENAI_IMAGE_PRESET_LIMITS,
|
|
47
|
+
cleanOpenAIImagePresetName,
|
|
48
|
+
splitOpenAIImagePreset,
|
|
49
|
+
} from './openai-image-presets.mjs';
|
|
38
50
|
|
|
39
51
|
// The one name every provider lists the tool under. Up to 0.9.17 it was
|
|
40
52
|
// `remote_relay`; that name is still accepted where a runtime dispatches a
|
|
@@ -156,6 +168,8 @@ export const REMOTE_RELAY_SESSION_SETTINGS = Object.freeze([
|
|
|
156
168
|
// Plan-only: agents may set and clear it; set, a send or an
|
|
157
169
|
// update_session naming another mode is refused.
|
|
158
170
|
'planOnly',
|
|
171
|
+
// An image session's defaults (size, quality, count, format, background).
|
|
172
|
+
'image',
|
|
159
173
|
]);
|
|
160
174
|
export const REMOTE_RELAY_THINKING_DISPLAYS = THINKING_DISPLAY_MODES;
|
|
161
175
|
// update_session's `group` value that takes a session out of its group (JSON
|
|
@@ -237,6 +251,10 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
|
237
251
|
healthIntervalMs: 60_000,
|
|
238
252
|
requestTimeoutMs: 15_000,
|
|
239
253
|
responseMaxBytes: 5 * 1024 * 1024,
|
|
254
|
+
// send / create_session to an image session: paths of images on the target
|
|
255
|
+
// relay that go to the model with the prompt.
|
|
256
|
+
imagesMax: OPENAI_IMAGE_LIMITS.inputImagesMax,
|
|
257
|
+
imagePathMaxChars: 4096,
|
|
240
258
|
});
|
|
241
259
|
|
|
242
260
|
export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
@@ -313,6 +331,8 @@ export const AGENT_SESSIONS_SOCKET_EVENT = 'agent_sessions_settings_updated';
|
|
|
313
331
|
export const REMOTE_RELAY_LOCAL_ALIAS = 'this';
|
|
314
332
|
export const REMOTE_RELAY_LOCAL_ID = 'self';
|
|
315
333
|
export const REMOTE_RELAY_CLOUD_PROVIDER = 'claude-cloud';
|
|
334
|
+
// The provider of image sessions (gpt-image-*, dall-e-*): images and image go there.
|
|
335
|
+
export const REMOTE_RELAY_IMAGE_PROVIDER = 'openai';
|
|
316
336
|
|
|
317
337
|
export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
318
338
|
'Work with sessions on OAR relays: the other relays this relay is paired with ("remote relays") '
|
|
@@ -360,8 +380,14 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
360
380
|
+ 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
|
|
361
381
|
+ 'with answer_question. Write actions can first need the user\'s approval. Always '
|
|
362
382
|
+ 'tell the user which relay and session you worked on. '
|
|
383
|
+
+ 'Image sessions (provider "openai" with a gpt-image or dall-e model): send and create_session take images (absolute paths '
|
|
384
|
+
+ 'on the target relay to edit or work from) and image {size, quality, count, output_format, background, preset} for that turn '
|
|
385
|
+
+ '(preset: a name relay_info lists in imagePresets; the fields named win over it); '
|
|
386
|
+
+ 'update_session image sets the session\'s defaults. Their replies list images [{path, width, height, mime}] (absolute paths '
|
|
387
|
+
+ 'on that relay, which a later send can attach) with a line on size and model; a failed image call is not repeated more than once. '
|
|
363
388
|
+ 'usage reports what the provider plans of a relay have left (Copilot, Claude, Claude Cloud, Cursor, Grok: '
|
|
364
|
-
+ 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so
|
|
389
|
+
+ 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so; '
|
|
390
|
+
+ 'OpenAI images: today\'s spend against the relay\'s daily cap) '
|
|
365
391
|
+ 'and whether a Claude usage limit has paused turns there, before you start work that costs. '
|
|
366
392
|
+ 'read_task_output {session, task_id} reads the end of a background shell or Monitor task\'s output there '
|
|
367
393
|
+ '(task ids from read_session\'s tasks; also for an hour after the task ended): the last lines, minutes '
|
|
@@ -436,7 +462,7 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
436
462
|
+ 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. '
|
|
437
463
|
+ 'read_task_output: the end of a background shell task\'s output. send: prompt an '
|
|
438
464
|
+ 'existing session. create_session: start a new session with a first prompt. update_session: change '
|
|
439
|
-
+ 'another session\'s settings (model, effort, mode, auto-compact window, title, group, thinking, cwd). wait: keep waiting for '
|
|
465
|
+
+ 'another session\'s settings (model, effort, mode, auto-compact window, title, group, thinking, cwd, image defaults). wait: keep waiting for '
|
|
440
466
|
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
441
467
|
+ 'archive: archive a session. subscribe / unsubscribe / list_subscriptions / transfer_subscriptions: '
|
|
442
468
|
+ 'be woken by events of sessions on this relay or a paired one (relay may be left out: "this"). '
|
|
@@ -545,6 +571,35 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
545
571
|
enum: [...THINKING_DISPLAY_MODES],
|
|
546
572
|
description: 'update_session: whether a Claude session\'s thinking text shows in its transcript (summarized) or not (omitted).',
|
|
547
573
|
},
|
|
574
|
+
images: {
|
|
575
|
+
type: 'array',
|
|
576
|
+
items: { type: 'string' },
|
|
577
|
+
maxItems: REMOTE_RELAY_LIMITS.imagesMax,
|
|
578
|
+
description: `send / create_session to an OpenAI image session (provider openai, a gpt-image or dall-e model): up to ${REMOTE_RELAY_LIMITS.imagesMax} `
|
|
579
|
+
+ `absolute paths of PNG, JPEG or WebP files on the target relay (each at most ${OPENAI_IMAGE_LIMITS.inputImageBytesMax / (1024 * 1024)} MB) that the model edits or works from, `
|
|
580
|
+
+ 'with text as the instruction. Allowed: files inside the target session\'s working directory, or images any session there '
|
|
581
|
+
+ 'generated (the paths its replies list). Refused for other models; a paired relay must run an OAR that takes them.',
|
|
582
|
+
},
|
|
583
|
+
image: {
|
|
584
|
+
type: 'object',
|
|
585
|
+
properties: {
|
|
586
|
+
size: { type: 'string', description: '"auto" or WIDTHxHEIGHT, e.g. 1536x1024: gpt-image takes both edges multiples of 16, the longer at most 3840, at most 3840x2160 pixels (gpt-image-2 and later: at least 655360 pixels, e.g. 1024x640), aspect 1:3 to 3:1; dall-e-3 1024x1024, 1792x1024 or 1024x1792; dall-e-2 256x256, 512x512 or 1024x1024.' },
|
|
587
|
+
quality: { type: 'string', description: 'gpt-image: auto, low, medium or high, and xhigh or max on the gpt-image 2.5 and 2.6 models (checked against the session\'s model); dall-e-3: standard or hd.' },
|
|
588
|
+
count: { type: 'integer', minimum: 1, maximum: OPENAI_IMAGE_LIMITS.countMax, description: `Images per turn, 1 to ${OPENAI_IMAGE_LIMITS.countMax} (dall-e-3: 1). Default 1.` },
|
|
589
|
+
output_format: { type: 'string', enum: [...OPENAI_IMAGE_OUTPUT_FORMATS], description: 'gpt-image only: png (default), webp or jpeg.' },
|
|
590
|
+
background: { type: 'string', enum: [...OPENAI_IMAGE_BACKGROUNDS], description: 'gpt-image only: auto (default), transparent (png or webp) or opaque.' },
|
|
591
|
+
preset: {
|
|
592
|
+
type: 'string',
|
|
593
|
+
maxLength: OPENAI_IMAGE_PRESET_LIMITS.nameChars,
|
|
594
|
+
description: `send / create_session only: a named set of these options, e.g. ${OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => `"${preset.name}"`).join(', ')} `
|
|
595
|
+
+ '(the built-ins; relay_info lists imagePresets with the relay owner\'s own). It sits over the session\'s defaults, '
|
|
596
|
+
+ 'and the fields named next to it win over it. An unknown name is refused with the list; nothing is sent.',
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
description: 'send / create_session: image options for that turn, over the session\'s defaults. update_session: the image '
|
|
600
|
+
+ 'session\'s defaults from now on (the fields named replace those defaults; auto, 1 and png set one back; no preset). '
|
|
601
|
+
+ 'Refused for a session whose model makes no images.',
|
|
602
|
+
},
|
|
548
603
|
question_id: { type: 'string', description: 'answer_question: id from pendingQuestions. withdraw: the id ask returned.' },
|
|
549
604
|
question: {
|
|
550
605
|
type: 'string',
|
|
@@ -696,8 +751,8 @@ export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
|
|
|
696
751
|
read_session: Object.freeze(['session', 'last', 'before', 'include_activity', 'max_chars']),
|
|
697
752
|
read_task_output: Object.freeze(['session', 'task_id', 'lines']),
|
|
698
753
|
wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
|
|
699
|
-
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider']),
|
|
700
|
-
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds', 'planOnly']),
|
|
754
|
+
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider', 'images', 'image']),
|
|
755
|
+
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds', 'planOnly', 'images', 'image']),
|
|
701
756
|
update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
|
|
702
757
|
answer_question: Object.freeze(['question_id', 'answer', 'choices']),
|
|
703
758
|
stop: Object.freeze(['session']),
|
|
@@ -913,6 +968,12 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
913
968
|
}
|
|
914
969
|
args.effort = effort;
|
|
915
970
|
}
|
|
971
|
+
const imagesCheck = readImagePaths(input?.images);
|
|
972
|
+
if (!imagesCheck.ok) return imagesCheck;
|
|
973
|
+
if (imagesCheck.paths.length) args.images = imagesCheck.paths;
|
|
974
|
+
const imageCheck = readTurnImageOptions(input?.image);
|
|
975
|
+
if (!imageCheck.ok) return imageCheck;
|
|
976
|
+
if (Object.keys(imageCheck.values).length) args.image = imageCheck.values;
|
|
916
977
|
if (action === 'send') {
|
|
917
978
|
const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
|
|
918
979
|
if (!busyCheck.ok) return busyCheck;
|
|
@@ -920,10 +981,16 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
920
981
|
const providerCheck = optionalProvider(sendProviders, (value, list) =>
|
|
921
982
|
`provider "${value}" can't be used with send; send takes one of: ${list} (the Claude session's subscription or API key)`);
|
|
922
983
|
if (!providerCheck.ok) return providerCheck;
|
|
984
|
+
if (args.provider && (args.images || args.image)) {
|
|
985
|
+
return invalid('images and image go to an OpenAI image session, not with provider (a Claude session\'s billing)');
|
|
986
|
+
}
|
|
923
987
|
} else {
|
|
924
988
|
const providerCheck = optionalProvider(providers, (value, list) =>
|
|
925
989
|
`provider "${value}" is not a provider here; this relay offers: ${list}`);
|
|
926
990
|
if (!providerCheck.ok) return providerCheck;
|
|
991
|
+
if (args.provider && args.provider !== REMOTE_RELAY_IMAGE_PROVIDER && (args.images || args.image)) {
|
|
992
|
+
return invalid(`images and image go with provider "${REMOTE_RELAY_IMAGE_PROVIDER}" and an image model (gpt-image-*, dall-e-*), not provider "${args.provider}"`);
|
|
993
|
+
}
|
|
927
994
|
optionalText('cwd', 1000);
|
|
928
995
|
optionalText('title', 200);
|
|
929
996
|
// An empty group is no group: the default grouping applies.
|
|
@@ -982,6 +1049,53 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
|
|
|
982
1049
|
return { ok: true, action, args, ...(waits ? waitLimited : {}) };
|
|
983
1050
|
}
|
|
984
1051
|
|
|
1052
|
+
// Image paths are read on the target relay, whose platform this relay may not
|
|
1053
|
+
// share: a POSIX path or a drive path (C:\ or C:/). Network and device paths
|
|
1054
|
+
// (\\host\share, //host/share, \\?\, \\.\) are refused: merely touching one can
|
|
1055
|
+
// open a connection that hands out the relay's credentials.
|
|
1056
|
+
const ABSOLUTE_PATH_PATTERN = /^(?:\/(?!\/)|[A-Za-z]:[\\/])/;
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* send / create_session's `images`: absolute paths, at most imagesMax, the
|
|
1060
|
+
* same path named twice kept once. A single text is one path.
|
|
1061
|
+
*/
|
|
1062
|
+
function readImagePaths(value) {
|
|
1063
|
+
if (value === undefined || value === null || value === '') return { ok: true, paths: [] };
|
|
1064
|
+
const list = typeof value === 'string' ? [value] : value;
|
|
1065
|
+
if (!Array.isArray(list)) return invalid('images must be a list of absolute file paths on the target relay');
|
|
1066
|
+
const paths = [];
|
|
1067
|
+
for (const entry of list) {
|
|
1068
|
+
if (typeof entry !== 'string') return invalid('images must be a list of absolute file paths on the target relay');
|
|
1069
|
+
const path = entry.trim();
|
|
1070
|
+
if (!path) continue;
|
|
1071
|
+
if (path.length > REMOTE_RELAY_LIMITS.imagePathMaxChars || path.includes('\0')) return invalid('images: a path is too long or not a path');
|
|
1072
|
+
if (!ABSOLUTE_PATH_PATTERN.test(path)) {
|
|
1073
|
+
return invalid(`images: "${path.slice(0, 120)}" is not an absolute local path; give the full path on the target relay (no network paths)`);
|
|
1074
|
+
}
|
|
1075
|
+
if (!paths.includes(path)) paths.push(path);
|
|
1076
|
+
}
|
|
1077
|
+
if (paths.length > REMOTE_RELAY_LIMITS.imagesMax) return invalid(`images takes at most ${REMOTE_RELAY_LIMITS.imagesMax} paths`);
|
|
1078
|
+
return { ok: true, paths };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* send / create_session's `image`: the options as the validator checks them
|
|
1083
|
+
* (without a model; the target checks them against its session's), and
|
|
1084
|
+
* `preset` as a clean name. Only its shape is checked here: the target
|
|
1085
|
+
* relay knows its presets and resolves the name.
|
|
1086
|
+
*/
|
|
1087
|
+
function readTurnImageOptions(value) {
|
|
1088
|
+
const { preset, own } = splitOpenAIImagePreset(value);
|
|
1089
|
+
const checked = validateOpenAIImageOptions(own, { field: 'image' });
|
|
1090
|
+
if (!checked.ok) return invalid(checked.error);
|
|
1091
|
+
if (preset === null || preset === undefined || (typeof preset === 'string' && !preset.trim())) {
|
|
1092
|
+
return { ok: true, values: checked.values };
|
|
1093
|
+
}
|
|
1094
|
+
const name = cleanOpenAIImagePresetName(preset);
|
|
1095
|
+
if (name.error) return invalid(`image.preset: ${name.error}`);
|
|
1096
|
+
return { ok: true, values: { ...checked.values, preset: name.name } };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
985
1099
|
function readBoolean(value) {
|
|
986
1100
|
if (value === true || value === false) return value;
|
|
987
1101
|
const text = toText(value).toLowerCase();
|
|
@@ -1060,6 +1174,14 @@ export function validateRemoteRelaySessionSettings(input) {
|
|
|
1060
1174
|
if (!THINKING_DISPLAY_MODES.includes(display)) return invalid(`thinking_display must be one of: ${THINKING_DISPLAY_MODES.join(', ')}`);
|
|
1061
1175
|
values.thinking_display = display;
|
|
1062
1176
|
}
|
|
1177
|
+
// A preset is a turn's: the defaults are the fields themselves.
|
|
1178
|
+
const { preset: defaultsPreset, own: defaults } = splitOpenAIImagePreset(input?.image);
|
|
1179
|
+
if (defaultsPreset !== null && defaultsPreset !== undefined && toText(defaultsPreset) !== '') {
|
|
1180
|
+
return invalid('image.preset: a preset is per turn (send, create_session); name its fields to set defaults');
|
|
1181
|
+
}
|
|
1182
|
+
const image = validateOpenAIImageOptions(defaults, { field: 'image' });
|
|
1183
|
+
if (!image.ok) return invalid(image.error);
|
|
1184
|
+
if (Object.keys(image.values).length) values.image = image.values;
|
|
1063
1185
|
if (!Object.keys(values).length) {
|
|
1064
1186
|
return invalid(`update_session needs at least one setting to change: ${REMOTE_RELAY_SESSION_SETTINGS.join(', ')}`);
|
|
1065
1187
|
}
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
//
|
|
7
7
|
// With image continuity on (the default) the relay created an image operation
|
|
8
8
|
// when the message was sent, and `POST /api/image-operations/:id/execute` does
|
|
9
|
-
// everything: the provider call, the generated-image reply,
|
|
10
|
-
//
|
|
11
|
-
// an operation the worker calls `POST /api/openai/images/generate`
|
|
12
|
-
// publishes the reply
|
|
9
|
+
// everything: the provider call with its one retry, the generated-image reply,
|
|
10
|
+
// and on a failure the failure note. The caller only reports the outcome.
|
|
11
|
+
// Without an operation the worker calls `POST /api/openai/images/generate`
|
|
12
|
+
// (the same request, the same retry) and publishes the reply or the note
|
|
13
|
+
// itself. Neither path requeues a row whose request may have reached OpenAI:
|
|
14
|
+
// a second run is billed again.
|
|
13
15
|
|
|
14
16
|
export const DIRECT_IMAGE_OUTCOMES = Object.freeze(['completed', 'executing', 'terminal', 'uncertain', 'retryable']);
|
|
15
17
|
|
|
16
|
-
// The code of the error thrown when the
|
|
17
|
-
//
|
|
18
|
-
// requeued.
|
|
18
|
+
// The code of the error thrown when the direct request failed: the relay
|
|
19
|
+
// already retried what could pass, so the turn fails with the relay's note
|
|
20
|
+
// instead of being requeued. `imageCode` carries the relay's own code.
|
|
19
21
|
export const OPENAI_IMAGE_TERMINAL_CODE = 'RELAY_OPENAI_IMAGE_TERMINAL';
|
|
20
22
|
|
|
21
23
|
export function shouldUseDirectOpenAIImageApi(message = {}) {
|
|
@@ -25,28 +27,51 @@ export function shouldUseDirectOpenAIImageApi(message = {}) {
|
|
|
25
27
|
return model.startsWith('gpt-image-') || model.startsWith('dall-e-');
|
|
26
28
|
}
|
|
27
29
|
|
|
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
30
|
export function isOpenAIImageTerminalError(error) {
|
|
35
31
|
return String(error?.code || '').trim() === OPENAI_IMAGE_TERMINAL_CODE;
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
const isAutoModel = (value) => String(value || '').trim().toLowerCase() === 'auto';
|
|
39
35
|
|
|
36
|
+
function imageTerminalError(message, { imageCode = '', status, detail = null, relayProcessing = false } = {}) {
|
|
37
|
+
const error = new Error(message);
|
|
38
|
+
error.code = OPENAI_IMAGE_TERMINAL_CODE;
|
|
39
|
+
error.imageCode = String(imageCode || '').trim().toLowerCase().replace(/_/g, '-');
|
|
40
|
+
error.relayProcessing = relayProcessing;
|
|
41
|
+
error.status = status;
|
|
42
|
+
error.detail = detail;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The image options of a direct request: the message's `imageOptions`
|
|
48
|
+
* ({ size, quality, count, output_format, background }) first, then the
|
|
49
|
+
* composer's quality and size fields.
|
|
50
|
+
*/
|
|
51
|
+
function directImageOptions(message = {}) {
|
|
52
|
+
const options = message?.imageOptions && typeof message.imageOptions === 'object' ? message.imageOptions : {};
|
|
53
|
+
const pick = (...values) => values.find((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
|
54
|
+
return {
|
|
55
|
+
n: pick(options.count, message?.n),
|
|
56
|
+
size: pick(options.size, message?.size, message?.contextTier),
|
|
57
|
+
quality: pick(options.quality, message?.quality, message?.reasoningEffort),
|
|
58
|
+
output_format: pick(options.output_format),
|
|
59
|
+
background: pick(options.background),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
/**
|
|
41
64
|
* Run one image turn. `requestOptions` goes to every `api` call that may wait
|
|
42
|
-
* on the provider (a worker passes `{ longCall: true }`: an edit
|
|
43
|
-
* minutes); `responseFields` is merged into the reply the
|
|
44
|
-
* itself (the attempt fence).
|
|
65
|
+
* on the provider (a worker passes `{ longCall: true }`: an edit with its
|
|
66
|
+
* retry can take minutes); `responseFields` is merged into the reply the
|
|
67
|
+
* worker publishes itself (the attempt fence).
|
|
45
68
|
*
|
|
46
|
-
* Resolves `{ via: 'operation', outcome }` or `{ via: 'direct',
|
|
47
|
-
* 'completed', imageCount, model }`.
|
|
48
|
-
*
|
|
49
|
-
* and
|
|
69
|
+
* Resolves `{ via: 'operation', outcome, usage }` or `{ via: 'direct',
|
|
70
|
+
* outcome: 'completed', imageCount, model, usage }`. On the direct path every
|
|
71
|
+
* failure, once the request may have gone out, throws an error with `code`
|
|
72
|
+
* `OPENAI_IMAGE_TERMINAL_CODE` and the relay's note as its message: the
|
|
73
|
+
* caller publishes it and never requeues. A plain error comes only from the
|
|
74
|
+
* operation path (the relay's execute route guards a second run itself).
|
|
50
75
|
*/
|
|
51
76
|
export async function runDirectOpenAIImageTurn({
|
|
52
77
|
api,
|
|
@@ -61,8 +86,8 @@ export async function runDirectOpenAIImageTurn({
|
|
|
61
86
|
{ messageId: message.id },
|
|
62
87
|
requestOptions,
|
|
63
88
|
).catch((error) => {
|
|
64
|
-
// The route answers a failure it already
|
|
65
|
-
//
|
|
89
|
+
// The route answers a failure it already published with 409 (503 on
|
|
90
|
+
// an older relay that requeued it) and the outcome in the body.
|
|
66
91
|
if (error?.status === 409 || error?.status === 503) {
|
|
67
92
|
return error?.body || error?.payload || { outcome: error?.status === 503 ? 'retryable' : 'terminal' };
|
|
68
93
|
}
|
|
@@ -72,14 +97,15 @@ export async function runDirectOpenAIImageTurn({
|
|
|
72
97
|
if (!DIRECT_IMAGE_OUTCOMES.includes(outcome)) {
|
|
73
98
|
throw new Error('Image operation returned an invalid outcome');
|
|
74
99
|
}
|
|
75
|
-
return { via: 'operation', outcome };
|
|
100
|
+
return { via: 'operation', outcome, usage: result?.usage || null };
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
const requestedModel = isAutoModel(message?.model) ? '' : String(message?.model || '').trim();
|
|
79
104
|
const directModel = String(message?.providerModel || requestedModel || message?.model || '').trim();
|
|
80
105
|
const prompt = String(message?.text || '').trim();
|
|
81
|
-
|
|
82
|
-
if (!
|
|
106
|
+
// Nothing was sent: a note, not a requeue that fails the same way.
|
|
107
|
+
if (!directModel) throw imageTerminalError('OpenAI image request is missing model', { imageCode: 'image-request-invalid' });
|
|
108
|
+
if (!prompt) throw imageTerminalError('OpenAI image request is missing prompt', { imageCode: 'image-request-invalid' });
|
|
83
109
|
await api('POST', '/api/activity', {
|
|
84
110
|
messageId: message.id,
|
|
85
111
|
conversationId: message.conversationId,
|
|
@@ -93,40 +119,60 @@ export async function runDirectOpenAIImageTurn({
|
|
|
93
119
|
conversationId: message.conversationId,
|
|
94
120
|
model: directModel,
|
|
95
121
|
prompt,
|
|
96
|
-
|
|
97
|
-
size: message?.size || message?.contextTier,
|
|
98
|
-
quality: message?.quality || message?.reasoningEffort,
|
|
122
|
+
...directImageOptions(message),
|
|
99
123
|
attachments: Array.isArray(message?.attachments) ? message.attachments : [],
|
|
100
124
|
}, requestOptions);
|
|
101
125
|
} catch (error) {
|
|
126
|
+
// The relay answered with its note (it retried what could pass), or the
|
|
127
|
+
// call to the relay itself broke off while OpenAI may have been working.
|
|
102
128
|
// `relayProcessing`: the provider answered 2xx and the relay could not
|
|
103
|
-
// process the reply; the generation is billed
|
|
129
|
+
// process the reply; the generation is billed.
|
|
104
130
|
const relayProcessing = error?.body?.relayProcessing === true;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
const note = String(error?.body?.error || '').trim();
|
|
132
|
+
if (error?.status) {
|
|
133
|
+
throw imageTerminalError(note || `OpenAI image request failed: ${error?.detail || `HTTP ${error.status}`}`, {
|
|
134
|
+
imageCode: error?.body?.code || (relayProcessing ? 'image-reply-unprocessed' : 'image-provider-rejected'),
|
|
135
|
+
status: error.status,
|
|
136
|
+
// The relay's detail (a network error's code), not its note again.
|
|
137
|
+
detail: error?.body?.detail || null,
|
|
138
|
+
relayProcessing,
|
|
139
|
+
});
|
|
114
140
|
}
|
|
115
|
-
throw
|
|
141
|
+
throw imageTerminalError(
|
|
142
|
+
`The relay did not finish the image request (${error?.message || 'connection error'}); the request may have been billed; retry the message.`,
|
|
143
|
+
{ imageCode: 'image-provider-no-answer', detail: error?.message || null },
|
|
144
|
+
);
|
|
116
145
|
}
|
|
117
146
|
const generatedImages = Array.isArray(directResult?.generatedImages) ? directResult.generatedImages : [];
|
|
118
147
|
if (!generatedImages.length) {
|
|
119
|
-
throw
|
|
148
|
+
throw imageTerminalError('OpenAI image API returned no images. The generation may have been billed; the message was not retried.', {
|
|
149
|
+
imageCode: 'image-reply-unprocessed',
|
|
150
|
+
relayProcessing: true,
|
|
151
|
+
});
|
|
120
152
|
}
|
|
121
153
|
const model = String(directResult?.model || directModel).trim() || directModel;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
154
|
+
const usage = directResult?.usage || null;
|
|
155
|
+
// The relay's note when the first try may have been billed as well.
|
|
156
|
+
const note = String(directResult?.note || '').trim();
|
|
157
|
+
try {
|
|
158
|
+
await api('POST', '/api/response', {
|
|
159
|
+
messageId: message.id,
|
|
160
|
+
conversationId: message.conversationId,
|
|
161
|
+
text: '',
|
|
162
|
+
generatedImages,
|
|
163
|
+
model,
|
|
164
|
+
modelOrigin: isAutoModel(message?.model) ? 'auto' : 'manual',
|
|
165
|
+
// What OpenAI counted for this request, for the cost booking.
|
|
166
|
+
...(usage ? { imageUsage: usage } : {}),
|
|
167
|
+
...(note ? { imageNote: note } : {}),
|
|
168
|
+
...responseFields,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// The images are billed; a requeue would pay for them again.
|
|
172
|
+
throw imageTerminalError(
|
|
173
|
+
`The images OpenAI returned could not be saved (${error?.detail || error?.message || 'unknown error'}). The generation was billed; the message was not retried.`,
|
|
174
|
+
{ imageCode: 'image-finalization-uncertain', status: error?.status, detail: error?.detail || null, relayProcessing: true },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return { via: 'direct', outcome: 'completed', imageCount: generatedImages.length, model, usage };
|
|
132
178
|
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
// Which `$` in a run of text delimit inline maths, and which are plain dollar
|
|
2
|
-
// signs. Prices are the common case of the second kind: without a rule,
|
|
3
|
-
// "$5 for one and $9 for two" reads as one formula running from the first
|
|
4
|
-
// sign to the second.
|
|
5
|
-
//
|
|
6
|
-
// The rule is the one pandoc uses for dollar maths: the opening `$` has a
|
|
7
|
-
// non-space character directly after it, the closing `$` is the next dollar
|
|
8
|
-
// sign, has a non-space character directly before it and no digit directly
|
|
9
|
-
// after it. A `$$` pair is
|
|
10
|
-
// display maths and is left alone, and a `$` behind a backslash is escaped.
|
|
11
|
-
|
|
12
|
-
const TEX_LOOKING = /[\\^_{}=]/;
|
|
13
|
-
|
|
14
|
-
function isEscaped(text, index) {
|
|
15
|
-
let backslashes = 0;
|
|
16
|
-
for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) backslashes += 1;
|
|
17
|
-
return backslashes % 2 === 1;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isSpace(char) {
|
|
21
|
-
return char === undefined || /\s/.test(char);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// A formula holds no dollar sign of its own, so only the next one can close
|
|
25
|
-
// it. When that one does not qualify ("$5, and $x^2$": it follows a space),
|
|
26
|
-
// the opening sign is a plain dollar and the next one gets its own turn.
|
|
27
|
-
function findClosingDollar(text, openIndex) {
|
|
28
|
-
let i = openIndex + 1;
|
|
29
|
-
while (i < text.length && (text[i] !== '$' || isEscaped(text, i))) i += 1;
|
|
30
|
-
if (i >= text.length) return -1;
|
|
31
|
-
// Part of a `$$`: not the end of an inline formula.
|
|
32
|
-
if (text[i + 1] === '$') return -1;
|
|
33
|
-
if (isSpace(text[i - 1])) return -1;
|
|
34
|
-
const content = text.slice(openIndex + 1, i);
|
|
35
|
-
// "$5/$10": a digit right behind the sign means the sign starts a number.
|
|
36
|
-
// Real maths directly followed by a digit ("$x^2$3") is told apart by
|
|
37
|
-
// looking like TeX.
|
|
38
|
-
if (/\d/.test(text[i + 1] || '') && !TEX_LOOKING.test(content)) return -1;
|
|
39
|
-
return i;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Scan `text` for single-dollar maths.
|
|
44
|
-
* @returns {{ spans: Array<[number, number]>, literals: number[] }} `spans`
|
|
45
|
-
* are [open, close] index pairs of formulas, `literals` the indexes of `$`
|
|
46
|
-
* that are plain dollar signs.
|
|
47
|
-
*/
|
|
48
|
-
export function scanInlineDollars(text) {
|
|
49
|
-
const source = String(text ?? '');
|
|
50
|
-
const spans = [];
|
|
51
|
-
const literals = [];
|
|
52
|
-
let index = 0;
|
|
53
|
-
while (index < source.length) {
|
|
54
|
-
if (source[index] !== '$' || isEscaped(source, index)) {
|
|
55
|
-
index += 1;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (source[index + 1] === '$') {
|
|
59
|
-
const close = source.indexOf('$$', index + 2);
|
|
60
|
-
index = close < 0 ? index + 2 : close + 2;
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
const close = isSpace(source[index + 1]) ? -1 : findClosingDollar(source, index);
|
|
64
|
-
if (close < 0) {
|
|
65
|
-
literals.push(index);
|
|
66
|
-
index += 1;
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
spans.push([index, close]);
|
|
70
|
-
index = close + 1;
|
|
71
|
-
}
|
|
72
|
-
return { spans, literals };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** `text` cut at its plain dollar signs: `[{ text, literal }]`, in order. */
|
|
76
|
-
export function splitAtLiteralDollars(text) {
|
|
77
|
-
const source = String(text ?? '');
|
|
78
|
-
const { literals } = scanInlineDollars(source);
|
|
79
|
-
if (!literals.length) return [{ text: source, literal: false }];
|
|
80
|
-
const parts = [];
|
|
81
|
-
let from = 0;
|
|
82
|
-
for (const index of literals) {
|
|
83
|
-
if (index > from) parts.push({ text: source.slice(from, index), literal: false });
|
|
84
|
-
parts.push({ text: '$', literal: true });
|
|
85
|
-
from = index + 1;
|
|
86
|
-
}
|
|
87
|
-
if (from < source.length) parts.push({ text: source.slice(from), literal: false });
|
|
88
|
-
return parts;
|
|
89
|
-
}
|