@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
@@ -226,12 +226,16 @@ export function createRemoteRelayRegistry({
226
226
  version: toText(version) || null,
227
227
  platform: toText(platform) || null,
228
228
  publicUrl: settings.publicUrl || null,
229
- remoteRelays: { protocol: REMOTE_RELAY_PROTOCOL, inbound: settings.inboundEnabled },
229
+ // imageInputs: send and create_session may carry images and image
230
+ // options here; an older relay would drop them and run the turn without.
231
+ remoteRelays: { protocol: REMOTE_RELAY_PROTOCOL, inbound: settings.inboundEnabled, imageInputs: true },
230
232
  };
231
233
  }
232
234
 
235
+ // For this relay's own Settings only, never for other relays: `defaultName`
236
+ // is the name it goes by while the relay name is empty.
233
237
  function selfPayload() {
234
- return { ...selfIdentity(), ...getSelfSettings() };
238
+ return { ...selfIdentity(), ...getSelfSettings(), defaultName: toText(hostname) || 'OAR' };
235
239
  }
236
240
 
237
241
  function emitChange() {
@@ -620,8 +624,12 @@ export function createRemoteRelayRegistry({
620
624
  instanceId,
621
625
  selfName,
622
626
  selfIdentity,
627
+ selfPayload,
623
628
  getSelfSettings,
624
629
  setSelfSettings,
630
+ // The relay name lives outside the registry: its setter calls this so
631
+ // every open Settings sees the new name.
632
+ notifySelfChanged: emitChange,
625
633
  getAgentSessionsSettings,
626
634
  setAgentSessionsSettings,
627
635
  list,
@@ -25,13 +25,24 @@ function toPidList(rows, extraPid) {
25
25
  // read: the caller must not pretend it stopped what it could not see. The
26
26
  // registry's pid counts only while that process is alive: a worker that is
27
27
  // gone is stopped already (on Windows its stop call alone could fail).
28
+ //
29
+ // On POSIX the list is every process with its command line, so a live worker
30
+ // of the session is on it as long as its argv carries the session id within
31
+ // ps's width (procps-ng cuts `args` at 128 KiB even with -ww), and the
32
+ // registry's pid counts only when it is: a pid that is alive but not listed
33
+ // is no worker of this session. It is
34
+ // the old worker's pid handed to another process (another user's, which no
35
+ // signal reaches, or one of the relay user's own, which the stop would
36
+ // kill), or the worker ended and its parent has not reaped it yet (a zombie
37
+ // answers signal 0 and has no command line left).
28
38
  async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
29
39
  const rows = platform === 'win32'
30
40
  ? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
31
41
  : await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
32
42
  const listed = toPidList(rows || []);
33
43
  const workerPid = Number(worker?.pid);
34
- const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
44
+ const workerAlive = Number.isInteger(workerPid) && workerPid > 0
45
+ && (listed.includes(workerPid) || (platform === 'win32' && isPidAliveImpl(workerPid)));
35
46
  return workerAlive ? toPidList(rows || [], workerPid) : listed;
36
47
  }
37
48
 
@@ -98,6 +109,36 @@ export async function stopSessionWorkerProcesses({
98
109
 
99
110
  const survivors = (list) => list.filter((pid) => isPidAliveImpl(pid));
100
111
 
112
+ // POSIX: a signal no process takes (ESRCH) is ignored; one it is not ours
113
+ // to send (EPERM: another user's process) is remembered and the others
114
+ // still get theirs; the re-check below decides about that pid.
115
+ const refused = new Map();
116
+ const signal = (pid, name) => {
117
+ try {
118
+ killImpl(pid, name);
119
+ } catch (error) {
120
+ const code = String(error?.code || '');
121
+ if (code === 'EPERM') refused.set(pid, error);
122
+ else if (code !== 'ESRCH') throw error;
123
+ }
124
+ };
125
+
126
+ // POSIX: a pid that outlived the stop counts only while a fresh list still
127
+ // shows it as the session's. The list matches command lines, so a match
128
+ // can be a process that was ending anyway (a shell that carried the
129
+ // session's arguments, another user's one included), and a process that
130
+ // ended but is not reaped yet (a zombie) still answers signal 0 with no
131
+ // command line left. A list that cannot be read changes nothing.
132
+ const stillListed = async (list) => {
133
+ if (platform === 'win32' || !list.length) return list;
134
+ try {
135
+ const fresh = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
136
+ return list.filter((pid) => fresh.includes(pid) && isPidAliveImpl(pid));
137
+ } catch {
138
+ return list;
139
+ }
140
+ };
141
+
101
142
  const waitFor = async (list, timeoutMs) => {
102
143
  const deadline = nowMs() + Math.max(0, Number(timeoutMs) || 0);
103
144
  let remaining = survivors(list);
@@ -114,13 +155,7 @@ export async function stopSessionWorkerProcesses({
114
155
  // Stop-Process -Force, so there is no gentler first pass to attempt.
115
156
  await stopWindowsPids(processInspector, pids);
116
157
  } else {
117
- for (const pid of pids) {
118
- try {
119
- killImpl(pid, 'SIGTERM');
120
- } catch (error) {
121
- if (String(error?.code || '') !== 'ESRCH') throw error;
122
- }
123
- }
158
+ for (const pid of pids) signal(pid, 'SIGTERM');
124
159
  // The tmux session can outlive its child after SIGTERM.
125
160
  killTmuxSessionImpl?.(sid);
126
161
  }
@@ -140,13 +175,7 @@ export async function stopSessionWorkerProcesses({
140
175
  escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
141
176
  await stopWindowsPids(processInspector, escalationTargets);
142
177
  } else {
143
- for (const pid of escalationTargets) {
144
- try {
145
- killImpl(pid, 'SIGKILL');
146
- } catch (error) {
147
- if (String(error?.code || '') !== 'ESRCH') throw error;
148
- }
149
- }
178
+ for (const pid of escalationTargets) signal(pid, 'SIGKILL');
150
179
  }
151
180
  } catch (error) {
152
181
  return done({
@@ -158,15 +187,18 @@ export async function stopSessionWorkerProcesses({
158
187
  });
159
188
  }
160
189
 
161
- remainingPids = await waitFor(escalationTargets, escalationTimeoutMs);
190
+ remainingPids = await stillListed(await waitFor(escalationTargets, escalationTimeoutMs));
162
191
  if (remainingPids.length) {
192
+ // A refused signal is the reason only when every survivor refused one;
193
+ // a survivor that took its signals and lives on is a timeout.
194
+ const refusal = remainingPids.every((pid) => refused.has(pid)) ? refused.get(remainingPids[0]) : null;
163
195
  return done({
164
196
  ok: false,
165
197
  pids,
166
198
  remainingPids,
167
199
  escalated: true,
168
- timedOut: true,
169
- error: `worker-stop-timeout:${remainingPids.join(',')}`,
200
+ timedOut: !refusal,
201
+ error: refusal ? (refusal.message || 'Failed to stop the CLI') : `worker-stop-timeout:${remainingPids.join(',')}`,
170
202
  });
171
203
  }
172
204
  return done({ pids, escalated: true });
@@ -219,6 +219,8 @@ export function createTokenUsageService({
219
219
  listAllDayRows: db.prepare(`SELECT * FROM ${TOKEN_USAGE_DAYS_TABLE}`),
220
220
  listTurnRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE turn_id = ? ORDER BY at, run_id, model`),
221
221
  listConversationRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ? ORDER BY at, turn_id, run_id, model`),
222
+ listBillingDayRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE day = ? AND provider = ? AND billing = ? ORDER BY at, turn_id, run_id, model`),
223
+ listBillingConversationRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ? AND provider = ? AND billing = ? ORDER BY at, turn_id, run_id, model`),
222
224
  deleteConversationRows: db.prepare(`DELETE FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ?`),
223
225
  };
224
226
  }
@@ -410,6 +412,18 @@ export function createTokenUsageService({
410
412
  };
411
413
  }
412
414
 
415
+ /**
416
+ * The turn rows of one provider and billing, of one day or of one chat
417
+ * (`day` or `conversationId`), for the views that need what the day rows
418
+ * do not keep (cost_source); null when the tables are not there.
419
+ */
420
+ function readTurnRows({ provider, billing = '', day = null, conversationId = null } = {}) {
421
+ const s = ready();
422
+ if (!s) return null;
423
+ if (conversationId) return s.listBillingConversationRows.all(String(conversationId), provider, billing).map(turnRowOut);
424
+ return s.listBillingDayRows.all(String(day || localDay(now())), provider, billing).map(turnRowOut);
425
+ }
426
+
413
427
  /** The chat is deleted: its turn rows go, the day rows stay. */
414
428
  function forgetConversation(conversationId) {
415
429
  const s = ready();
@@ -423,6 +437,7 @@ export function createTokenUsageService({
423
437
  readPeriods,
424
438
  readTurn,
425
439
  readConversation,
440
+ readTurnRows,
426
441
  forgetConversation,
427
442
  localDay: (ms = now()) => localDay(ms),
428
443
  timeZone: formatDay.resolvedOptions().timeZone,
@@ -0,0 +1,90 @@
1
+ // The type and pixel size of a PNG, JPEG or WebP image, read from its first
2
+ // bytes alone (no decoding). Anything else, or a header that does not hold
3
+ // together, gives null.
4
+ //
5
+ // SDK-free and side-effect free: imported by the server and the browser.
6
+
7
+ // Enough for every PNG and WebP header and for the JPEG frame header after
8
+ // usual EXIF/ICC blocks; a JPEG whose frame header lies further in gives null.
9
+ export const IMAGE_HEADER_BYTES = 256 * 1024;
10
+
11
+ function asBytes(input) {
12
+ if (input instanceof Uint8Array) return input;
13
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
14
+ return null;
15
+ }
16
+
17
+ function u16be(bytes, at) {
18
+ return (bytes[at] << 8) | bytes[at + 1];
19
+ }
20
+
21
+ function u32be(bytes, at) {
22
+ return ((bytes[at] << 24) >>> 0) + (bytes[at + 1] << 16) + (bytes[at + 2] << 8) + bytes[at + 3];
23
+ }
24
+
25
+ function u24le(bytes, at) {
26
+ return bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
27
+ }
28
+
29
+ function ascii(bytes, at, length) {
30
+ let text = '';
31
+ for (let index = at; index < at + length && index < bytes.length; index += 1) text += String.fromCharCode(bytes[index]);
32
+ return text;
33
+ }
34
+
35
+ function sized(mime, width, height) {
36
+ return width > 0 && height > 0 ? { mime, width, height } : null;
37
+ }
38
+
39
+ function readPng(bytes) {
40
+ // Signature, then IHDR as the first chunk: length 13, width, height.
41
+ if (bytes.length < 24 || ascii(bytes, 12, 4) !== 'IHDR') return null;
42
+ return sized('image/png', u32be(bytes, 16), u32be(bytes, 20));
43
+ }
44
+
45
+ function readJpeg(bytes) {
46
+ let at = 2;
47
+ while (at + 9 < bytes.length) {
48
+ if (bytes[at] !== 0xff) return null;
49
+ const marker = bytes[at + 1];
50
+ // Fill bytes, and markers without a length.
51
+ if (marker === 0xff) { at += 1; continue; }
52
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { at += 2; continue; }
53
+ if (marker === 0xd9 || marker === 0xda) return null;
54
+ const length = u16be(bytes, at + 2);
55
+ if (length < 2) return null;
56
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
57
+ if (isFrame) return sized('image/jpeg', u16be(bytes, at + 7), u16be(bytes, at + 5));
58
+ at += 2 + length;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function readWebp(bytes) {
64
+ if (bytes.length < 30) return null;
65
+ const chunk = ascii(bytes, 12, 4);
66
+ if (chunk === 'VP8X') return sized('image/webp', u24le(bytes, 24) + 1, u24le(bytes, 27) + 1);
67
+ if (chunk === 'VP8 ') {
68
+ // Key frame start code, then 14-bit width and height.
69
+ if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) return null;
70
+ return sized('image/webp', (bytes[26] | (bytes[27] << 8)) & 0x3fff, (bytes[28] | (bytes[29] << 8)) & 0x3fff);
71
+ }
72
+ if (chunk === 'VP8L') {
73
+ if (bytes[20] !== 0x2f) return null;
74
+ const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24);
75
+ return sized('image/webp', (bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** `{ mime, width, height }` of a PNG, JPEG or WebP image's first bytes, or null. */
81
+ export function readImageHeader(input) {
82
+ const bytes = asBytes(input);
83
+ if (!bytes || bytes.length < 12) return null;
84
+ if (bytes[0] === 0x89 && ascii(bytes, 1, 3) === 'PNG' && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
85
+ return readPng(bytes);
86
+ }
87
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return readJpeg(bytes);
88
+ if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP') return readWebp(bytes);
89
+ return null;
90
+ }
@@ -0,0 +1,30 @@
1
+ // How agents write formulas in their reply messages, taught once per provider
2
+ // through the same four seams as shared/media-embed-instructions.mjs: Claude
3
+ // (system-prompt append), Copilot (relay tool guidance document), Cursor and
4
+ // Grok (once-per-worker prompt prefix). The web app reads a formula from its
5
+ // delimiters only (server/public/app/markdown-math.mjs); a model that writes
6
+ // TeX in bare parentheses or brackets gets its source shown, so every agent
7
+ // is told the notation that renders.
8
+
9
+ export const MATH_INSTRUCTION_HEADING = '## Formulas in replies';
10
+
11
+ export const MATH_INSTRUCTION_TEXT =
12
+ 'To show a formula in a reply, write it in TeX between dollar signs: '
13
+ + '$E = mc^2$ inside a sentence, and $$ ... $$ on lines of its own for a '
14
+ + 'displayed formula. The web app renders both with KaTeX; \\( ... \\) and '
15
+ + '\\[ ... \\] work as well. TeX in bare parentheses or square brackets is '
16
+ + 'not rendered. A dollar amount such as $5 stays text.';
17
+
18
+ export function renderMathInstructionBlock() {
19
+ return `${MATH_INSTRUCTION_HEADING}\n\n${MATH_INSTRUCTION_TEXT}`;
20
+ }
21
+
22
+ /**
23
+ * Append the formula block to a guidance document unless it already carries
24
+ * one (idempotent, like applyMediaEmbedInstructions).
25
+ */
26
+ export function applyMathInstructions(baseInstructions = '') {
27
+ const base = String(baseInstructions || '').trimEnd();
28
+ if (base.includes(MATH_INSTRUCTION_HEADING)) return base;
29
+ return [base, renderMathInstructionBlock()].filter(Boolean).join('\n\n');
30
+ }
@@ -0,0 +1,299 @@
1
+ // The options of an OpenAI image turn (gpt-image-*, dall-e-*): size, quality,
2
+ // count, output format and background, checked in one place for the
3
+ // relay_tool contract, the relay's routes and the composer.
4
+ //
5
+ // SDK-free and side-effect free: imported by the server, the session workers
6
+ // and the browser.
7
+
8
+ // The models a new image session gets when the caller names none, in this
9
+ // order, the first the relay's OpenAI list has; else the first gpt-image id
10
+ // listed. A newer model the list offers (2.6 and up) is never picked by
11
+ // itself: a session gets one only when it is named.
12
+ export const OPENAI_IMAGE_DEFAULT_MODELS = Object.freeze([
13
+ 'gpt-image-2.5-flare',
14
+ 'gpt-image-2.5-flare-2026-09-08',
15
+ 'gpt-image-2',
16
+ ]);
17
+ export const OPENAI_IMAGE_DEFAULT_MODEL = OPENAI_IMAGE_DEFAULT_MODELS[0];
18
+ // An image session asked for without a model on a relay whose OpenAI list
19
+ // has no gpt-image model at all.
20
+ export const OPENAI_IMAGE_NO_MODEL_CODE = 'OPENAI_IMAGE_NO_MODEL';
21
+
22
+ // Images one turn may produce (count) and attach (images): one number for both.
23
+ export const OPENAI_IMAGE_PER_TURN_MAX = 4;
24
+
25
+ // POST /api/message refuses an image path, a file that is no image, a model
26
+ // that makes no images or an option that model does not take with this code
27
+ // (400); nothing is queued then.
28
+ export const IMAGE_INPUT_REFUSED_CODE = 'IMAGE_INPUT_REFUSED';
29
+
30
+ export const OPENAI_IMAGE_LIMITS = Object.freeze({
31
+ countMax: OPENAI_IMAGE_PER_TURN_MAX,
32
+ inputImagesMax: OPENAI_IMAGE_PER_TURN_MAX,
33
+ // One attached image, read from disk on the target relay.
34
+ inputImageBytesMax: 20 * 1024 * 1024,
35
+ // gpt-image sizes: both edges a multiple of sizeStep, the longer at most
36
+ // edgeMax, the area at most pixelsMax (3840x2160), the long edge at most
37
+ // aspectMax times the short one.
38
+ sizeStep: 16,
39
+ edgeMax: 3840,
40
+ pixelsMax: 3840 * 2160,
41
+ aspectMax: 3,
42
+ // The fewest pixels gpt-image-2 and the 2.5 models take ("The total pixel
43
+ // count must be between 655,360 and 8,294,400", OpenAI's image-generation
44
+ // guide, "Size and quality options" and the gpt-image-2 settings, read
45
+ // 2026-11-08). It goes by family: gpt-image-2 and every later 2.x model
46
+ // (2.6 included); earlier models keep their sizes.
47
+ pixelsMin: 655_360,
48
+ });
49
+
50
+ export const OPENAI_IMAGE_OPTION_KEYS = Object.freeze(['size', 'quality', 'count', 'output_format', 'background']);
51
+
52
+ export const OPENAI_IMAGE_OUTPUT_FORMATS = Object.freeze(['png', 'webp', 'jpeg']);
53
+ export const OPENAI_IMAGE_BACKGROUNDS = Object.freeze(['auto', 'transparent', 'opaque']);
54
+ export const OPENAI_IMAGE_MIME_BY_FORMAT = Object.freeze({ png: 'image/png', webp: 'image/webp', jpeg: 'image/jpeg' });
55
+
56
+ // What each model family takes. dall-e keep their fixed size lists; `auto`
57
+ // size or quality on a dall-e model means "not sent" (the API's own default).
58
+ export const OPENAI_IMAGE_FAMILIES = Object.freeze({
59
+ 'gpt-image': Object.freeze({
60
+ sizes: null,
61
+ // Every gpt-image model takes these; the 2.5 and 2.6 models take
62
+ // OPENAI_IMAGE_EXTENDED_QUALITIES too (openAIImageModelQualities).
63
+ qualities: Object.freeze(['auto', 'low', 'medium', 'high']),
64
+ countMax: OPENAI_IMAGE_LIMITS.countMax,
65
+ formats: true,
66
+ }),
67
+ 'dall-e-3': Object.freeze({
68
+ sizes: Object.freeze(['1024x1024', '1792x1024', '1024x1792']),
69
+ qualities: Object.freeze(['auto', 'standard', 'hd']),
70
+ countMax: 1,
71
+ formats: false,
72
+ }),
73
+ 'dall-e-2': Object.freeze({
74
+ sizes: Object.freeze(['256x256', '512x512', '1024x1024']),
75
+ qualities: Object.freeze(['auto', 'standard']),
76
+ countMax: OPENAI_IMAGE_LIMITS.countMax,
77
+ formats: false,
78
+ }),
79
+ });
80
+
81
+ // "gpt-image-2.5-sunburst and gpt-image-2.5-flare add xhigh and max quality
82
+ // settings … Earlier GPT Image models support quality settings up to high",
83
+ // and the calculator's note: "For GPT Image 2.6 and 2.5 Sunburst and Flare,
84
+ // the quality options are low, medium, high, xhigh, and max. For
85
+ // gpt-image-2, the options are low, medium, and high." (OpenAI's
86
+ // image-generation guide, "Size and quality options" and "Calculating
87
+ // costs", read 2026-11-08). A gpt-image id the relay does not know gets the
88
+ // narrower set.
89
+ export const OPENAI_IMAGE_EXTENDED_QUALITIES = Object.freeze(['xhigh', 'max']);
90
+ const EXTENDED_QUALITY_MODEL = /^gpt-image-2\.[56](?:-|$)/;
91
+ // gpt-image-2 and later 2.x ids: the pixel floor applies (pixelsMin).
92
+ const PIXEL_FLOOR_MODEL = /^gpt-image-2(?:$|[.-])/;
93
+
94
+ const ALL_QUALITIES = Object.freeze([
95
+ ...new Set([...Object.values(OPENAI_IMAGE_FAMILIES).flatMap((family) => family.qualities), ...OPENAI_IMAGE_EXTENDED_QUALITIES]),
96
+ ]);
97
+
98
+ function bareModelId(model) {
99
+ return String(model || '').trim().toLowerCase().replace(/^openai\//, '');
100
+ }
101
+
102
+ export function isOpenAIImageModelId(model = '') {
103
+ const id = bareModelId(model);
104
+ return id.startsWith('gpt-image-') || id.startsWith('dall-e-');
105
+ }
106
+
107
+ // Copilot lists image ids its runtime cannot serve: a Copilot chat on one is
108
+ // refused with this code (creation, model switch, send), pointing to an
109
+ // OpenAI Image chat.
110
+ export const OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE = 'IMAGE_MODEL_NEEDS_OPENAI';
111
+
112
+ export function openAIImageModelNeedsOpenAIText(model = '') {
113
+ return `"${String(model || '').trim().slice(0, 120)}" makes images: start an OpenAI Image chat (provider openai) for it.`;
114
+ }
115
+
116
+ /**
117
+ * The model a new image session gets when the caller names none, from the
118
+ * ids the relay's OpenAI provider lists: the first of
119
+ * OPENAI_IMAGE_DEFAULT_MODELS listed, else the first gpt-image id in the
120
+ * list's order, else '' (the caller refuses with OPENAI_IMAGE_NO_MODEL_CODE).
121
+ * Returned in the list's own casing.
122
+ */
123
+ export function pickOpenAIImageDefaultModel(models = []) {
124
+ const ids = (Array.isArray(models) ? models : []).map((value) => String(value || '').trim()).filter(Boolean);
125
+ for (const wanted of OPENAI_IMAGE_DEFAULT_MODELS) {
126
+ const listed = ids.find((id) => bareModelId(id) === wanted);
127
+ if (listed) return listed;
128
+ }
129
+ return ids.find((id) => bareModelId(id).startsWith('gpt-image-')) || '';
130
+ }
131
+
132
+ export function openAIImageNoModelText(where = 'this relay') {
133
+ return `The OpenAI model list on ${where} has no gpt-image model, so an image session has none to start on: `
134
+ + 'name a model the list has, or turn a gpt-image model on in Settings → Providers → OpenAI.';
135
+ }
136
+
137
+ /**
138
+ * The qualities `model` takes: its family's, and xhigh and max for the
139
+ * gpt-image 2.5 and 2.6 models. Without a model, every value any model takes.
140
+ */
141
+ export function openAIImageModelQualities(model = '') {
142
+ const family = openAIImageModelFamily(model);
143
+ if (!family) return [...ALL_QUALITIES];
144
+ const base = OPENAI_IMAGE_FAMILIES[family].qualities;
145
+ return family === 'gpt-image' && EXTENDED_QUALITY_MODEL.test(bareModelId(model)) ? [...base, ...OPENAI_IMAGE_EXTENDED_QUALITIES] : [...base];
146
+ }
147
+
148
+ /** Whether `model`'s sizes have the pixel floor (gpt-image-2 and later 2.x). */
149
+ export function openAIImageModelHasPixelFloor(model = '') {
150
+ return PIXEL_FLOOR_MODEL.test(bareModelId(model));
151
+ }
152
+
153
+ /** 'gpt-image', 'dall-e-3', 'dall-e-2', or null for a model that makes no images. */
154
+ export function openAIImageModelFamily(model = '') {
155
+ const id = bareModelId(model);
156
+ if (id.startsWith('gpt-image-')) return 'gpt-image';
157
+ if (id.startsWith('dall-e-3')) return 'dall-e-3';
158
+ if (id.startsWith('dall-e-')) return 'dall-e-2';
159
+ return null;
160
+ }
161
+
162
+ function invalid(error) {
163
+ return { ok: false, error };
164
+ }
165
+
166
+ /** `{ width, height }` of a "WxH" text (also "W×H", "W X H"), or null. */
167
+ export function parseOpenAIImageSize(value) {
168
+ const match = /^\s*(\d{1,5})\s*[x×]\s*(\d{1,5})\s*$/i.exec(String(value ?? ''));
169
+ if (!match) return null;
170
+ const width = Number(match[1]);
171
+ const height = Number(match[2]);
172
+ return width > 0 && height > 0 ? { width, height } : null;
173
+ }
174
+
175
+ /**
176
+ * A size as the API takes it ("auto" or "WxH"), or `{ error }`. Without a
177
+ * family the gpt-image rules apply, or a dall-e list entry passes as well
178
+ * (the contract does not know the model; the relay checks again with it).
179
+ * `model` adds the pixel floor of gpt-image-2 and later.
180
+ */
181
+ export function checkOpenAIImageSize(value, family = null, { model = '' } = {}) {
182
+ const text = String(value ?? '').trim().toLowerCase();
183
+ if (text === 'auto') return { size: 'auto' };
184
+ const parsed = parseOpenAIImageSize(text);
185
+ if (!parsed) return { error: 'size must be "auto" or WIDTHxHEIGHT in pixels, e.g. 1536x1024' };
186
+ const size = `${parsed.width}x${parsed.height}`;
187
+ const fixed = family ? OPENAI_IMAGE_FAMILIES[family]?.sizes : null;
188
+ if (fixed) {
189
+ return fixed.includes(size) ? { size } : { error: `size must be one of: ${fixed.join(', ')} for ${family}` };
190
+ }
191
+ if (!family && Object.values(OPENAI_IMAGE_FAMILIES).some((entry) => entry.sizes?.includes(size))) return { size };
192
+ const { sizeStep, edgeMax, pixelsMax, aspectMax } = OPENAI_IMAGE_LIMITS;
193
+ const { width, height } = parsed;
194
+ if (width % sizeStep || height % sizeStep) return { error: `size ${size}: both edges must be multiples of ${sizeStep}` };
195
+ if (Math.max(width, height) > edgeMax) return { error: `size ${size}: the longer edge may be at most ${edgeMax}` };
196
+ if (width * height > pixelsMax) return { error: `size ${size}: at most ${pixelsMax} pixels (3840x2160)` };
197
+ if (model && openAIImageModelHasPixelFloor(model) && width * height < OPENAI_IMAGE_LIMITS.pixelsMin) {
198
+ return { error: `size ${size}: ${bareModelId(model)} takes ${OPENAI_IMAGE_LIMITS.pixelsMin} to ${pixelsMax} pixels, this is ${width * height}` };
199
+ }
200
+ if (Math.max(width, height) > aspectMax * Math.min(width, height)) {
201
+ return { error: `size ${size}: the aspect ratio must lie between 1:${aspectMax} and ${aspectMax}:1` };
202
+ }
203
+ return { size };
204
+ }
205
+
206
+ /**
207
+ * Checks the image options of one call or one session's defaults. `input` is
208
+ * an object with any of OPENAI_IMAGE_OPTION_KEYS; empty values (null, '')
209
+ * are left out. `model` names the image model when it is known (the relay);
210
+ * without it every family's values pass that one of them takes. Returns
211
+ * `{ ok:true, values }` (only the keys named, normalised) or `{ ok:false, error }`.
212
+ * `field` prefixes the messages (the contract's "image").
213
+ */
214
+ export function validateOpenAIImageOptions(input, { model = '', field = 'image' } = {}) {
215
+ if (input === undefined || input === null) return { ok: true, values: {} };
216
+ if (typeof input !== 'object' || Array.isArray(input)) {
217
+ return invalid(`${field} must be an object with any of: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
218
+ }
219
+ const unknown = Object.keys(input).filter((key) => !OPENAI_IMAGE_OPTION_KEYS.includes(key) && !isEmpty(input[key]));
220
+ if (unknown.length) {
221
+ return invalid(`${field} does not take: ${unknown.slice(0, 10).join(', ')}. It takes: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
222
+ }
223
+ const family = model ? openAIImageModelFamily(model) : null;
224
+ if (model && !family) return invalid(`${field} options go with an OpenAI image model (gpt-image-*, dall-e-*), not ${String(model).slice(0, 80)}`);
225
+ const rules = family ? OPENAI_IMAGE_FAMILIES[family] : null;
226
+ const values = {};
227
+
228
+ if (!isEmpty(input.size)) {
229
+ const checked = checkOpenAIImageSize(input.size, family, { model });
230
+ if (checked.error) return invalid(`${field}.${checked.error}`);
231
+ values.size = checked.size;
232
+ }
233
+ if (!isEmpty(input.quality)) {
234
+ const quality = String(input.quality).trim().toLowerCase();
235
+ const allowed = openAIImageModelQualities(model);
236
+ if (!allowed.includes(quality)) {
237
+ const extendedNote = family === 'gpt-image' && OPENAI_IMAGE_EXTENDED_QUALITIES.includes(quality)
238
+ ? ` (${OPENAI_IMAGE_EXTENDED_QUALITIES.join(' and ')}: the gpt-image 2.5 and 2.6 models only)`
239
+ : '';
240
+ return invalid(`${field}.quality must be one of: ${allowed.join(', ')}${model ? ` for ${bareModelId(model)}` : ''}${extendedNote}`);
241
+ }
242
+ values.quality = quality;
243
+ }
244
+ if (!isEmpty(input.count)) {
245
+ const count = Number(input.count);
246
+ const max = rules ? rules.countMax : OPENAI_IMAGE_LIMITS.countMax;
247
+ if (typeof input.count === 'boolean' || !Number.isInteger(count) || count < 1 || count > max) {
248
+ return invalid(`${field}.count must be a whole number from 1 to ${max}${family ? ` for ${family}` : ''}`);
249
+ }
250
+ values.count = count;
251
+ }
252
+ for (const [key, allowed] of [['output_format', OPENAI_IMAGE_OUTPUT_FORMATS], ['background', OPENAI_IMAGE_BACKGROUNDS]]) {
253
+ if (isEmpty(input[key])) continue;
254
+ const value = String(input[key]).trim().toLowerCase();
255
+ if (rules && !rules.formats) return invalid(`${field}.${key} goes with gpt-image models only, not ${family}`);
256
+ if (!allowed.includes(value)) return invalid(`${field}.${key} must be one of: ${allowed.join(', ')}`);
257
+ values[key] = value;
258
+ }
259
+ if (values.background === 'transparent' && values.output_format === 'jpeg') {
260
+ return invalid(`${field}.background "transparent" needs output_format png or webp`);
261
+ }
262
+ return { ok: true, values };
263
+ }
264
+
265
+ /**
266
+ * The options of one turn: the session's defaults with the turn's own values
267
+ * over them, and count 1 when neither names one.
268
+ */
269
+ export function mergeOpenAIImageOptions(defaults, override) {
270
+ const merged = {};
271
+ for (const source of [defaults, override]) {
272
+ if (!source || typeof source !== 'object') continue;
273
+ for (const key of OPENAI_IMAGE_OPTION_KEYS) {
274
+ if (!isEmpty(source[key])) merged[key] = source[key];
275
+ }
276
+ }
277
+ if (!merged.count) merged.count = 1;
278
+ return merged;
279
+ }
280
+
281
+ /**
282
+ * The reply line of an image turn, for agents and the transcript alike when
283
+ * the model wrote no text: "2 images · 1536x1024 · gpt-image-2 · ~USD 0.08".
284
+ * `images` are `{ width, height }` entries; the cost part only when known.
285
+ */
286
+ export function formatOpenAIImageSummary({ images = [], model = '', costUsd = null } = {}) {
287
+ const list = Array.isArray(images) ? images : [];
288
+ const parts = [`${list.length} image${list.length === 1 ? '' : 's'}`];
289
+ const sizes = [...new Set(list.filter((image) => image?.width > 0 && image?.height > 0).map((image) => `${image.width}x${image.height}`))];
290
+ if (sizes.length) parts.push(sizes.join(', '));
291
+ const id = String(model || '').trim();
292
+ if (id) parts.push(id);
293
+ if (Number.isFinite(costUsd) && costUsd >= 0) parts.push(`~USD ${costUsd < 0.01 && costUsd > 0 ? costUsd.toFixed(3) : costUsd.toFixed(2)}`);
294
+ return parts.join(' · ');
295
+ }
296
+
297
+ function isEmpty(value) {
298
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
299
+ }