@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.
Files changed (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. 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, 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.
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 provider refused the request outright:
17
- // a retry would be refused the same way, so the turn fails instead of being
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 can take
43
- * minutes); `responseFields` is merged into the reply the worker publishes
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', 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).
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 handled (published or
65
- // requeued) with 409 or 503 and the outcome in the body.
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
- if (!directModel) throw new Error('OpenAI image request is missing model');
82
- if (!prompt) throw new Error('OpenAI image request is missing prompt');
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
- n: message?.n,
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, so no requeue either.
129
+ // process the reply; the generation is billed.
104
130
  const relayProcessing = error?.body?.relayProcessing === true;
105
- if (isDirectOpenAIImageTerminalStatus(error?.status) || relayProcessing) {
106
- const terminalError = new Error(relayProcessing
107
- ? `The relay could not process the image OpenAI returned (${error?.detail || 'unknown error'}). The generation was billed; the message was not retried.`
108
- : `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`);
109
- terminalError.code = OPENAI_IMAGE_TERMINAL_CODE;
110
- terminalError.relayProcessing = relayProcessing;
111
- terminalError.status = error?.status;
112
- terminalError.detail = error?.detail || null;
113
- throw terminalError;
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 error;
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 new Error('OpenAI image API returned no images');
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
- await api('POST', '/api/response', {
123
- messageId: message.id,
124
- conversationId: message.conversationId,
125
- text: '',
126
- generatedImages,
127
- model,
128
- modelOrigin: isAutoModel(message?.model) ? 'auto' : 'manual',
129
- ...responseFields,
130
- });
131
- return { via: 'direct', outcome: 'completed', imageCount: generatedImages.length, model };
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
- }