@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
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|