@oar-sh/oar 0.9.23 → 0.9.25

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 (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  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/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -0,0 +1,516 @@
1
+ /**
2
+ * What OpenAI image turns cost this relay today, and its daily cap
3
+ * (shared/openai-image-spend.mjs).
4
+ *
5
+ * One instance per relay process: the send function of image requests
6
+ * (messages-routes runOpenAIImageRequest) asks it before each try leaves the
7
+ * relay, and books with it when the try ends.
8
+ *
9
+ * - Before a try: today's booked spend (token_usage_days, provider 'openai',
10
+ * billing 'image', estimates included), the tries in flight today and this
11
+ * try's estimate against the cap, and the try's estimate reserved, in one
12
+ * synchronous step, so two turns cannot both pass on the same headroom.
13
+ * A reservation belongs to the relay-local day its try started on: a check
14
+ * after midnight does not see yesterday's.
15
+ * - When a try ends: its cost booked (tokenUsageService.recordTurn, one row
16
+ * per try, report.at = the try's start) and its reservation dropped, in the
17
+ * same synchronous step. A booking that fails stays counted for its day in
18
+ * memory (fail closed) until a later booking writes it, or its day ends.
19
+ * - A restart drops the reservations along with every try in flight; the
20
+ * recovery books an estimate for an attempt that was cut off.
21
+ *
22
+ * Every booking emits the spend state (OPENAI_IMAGE_USAGE_EVENT).
23
+ */
24
+
25
+ import {
26
+ OPENAI_IMAGE_BOOKING_BILLING,
27
+ OPENAI_IMAGE_BOOKING_PROVIDER,
28
+ OPENAI_IMAGE_CAP_WARN_RATIO,
29
+ OPENAI_IMAGE_COST_SOURCES,
30
+ OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD,
31
+ OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
32
+ OPENAI_IMAGE_SPEND_SETTING_KEYS,
33
+ OPENAI_IMAGE_USAGE_EVENT,
34
+ formatOpenAIImageUsd,
35
+ } from '../../shared/openai-image-spend.mjs';
36
+ import {
37
+ estimateOpenAIImageCost,
38
+ openAIImageBookingModel,
39
+ openAIImageCostFloor,
40
+ priceOpenAIImageUsage,
41
+ } from '../../shared/openai-image-prices.mjs';
42
+
43
+ // A try that ended this way may have been billed without a usage to show:
44
+ // it books the estimate (a refusal only when it came after the stream had
45
+ // started). The 5xx are among them, both the HTTP status and the stream's
46
+ // server error event: OpenAI does not say they are free.
47
+ const ESTIMATED_KINDS = new Set(['timeout', 'broken', 'stream', 'unprocessed', 'server']);
48
+ // How far a try's start may lie back for its booking to keep that time
49
+ // (token-usage-service.mjs takes the relay's clock past it).
50
+ const AT_SKEW_MS = 24 * 60 * 60 * 1000;
51
+ const CAP_PLACE = 'Settings → Providers → OpenAI';
52
+ const CAP_REMEDY = `Allow more for today in ${CAP_PLACE}, or wait until tomorrow (relay time).`;
53
+
54
+ /** The run_id of one try: `image:<attempt or claim>:try<n>`. */
55
+ export function openAIImageTryRunId(scopeId, tryNumber) {
56
+ return `image:${scopeId}:try${tryNumber}`;
57
+ }
58
+
59
+ /** The run_id of the estimate the recovery books for an attempt cut off. */
60
+ export function openAIImageRecoveredRunId(attemptId) {
61
+ return `image:${attemptId}:recovered`;
62
+ }
63
+
64
+ /**
65
+ * What one ended try books, or null for nothing:
66
+ * - the usage OpenAI reported, whenever any was read (a completed reply, or
67
+ * the completed events before a stream broke off, or a 2xx the relay could
68
+ * not process), priced as it is;
69
+ * - a reply with its images but no usage: the per-image floor × count;
70
+ * - a try that may have been billed without a usage: the estimate, with the
71
+ * partials it received;
72
+ * - nothing for a try that never reached OpenAI, or a refusal before any work.
73
+ * `request` is `{ model, size, quality, count, inputImages }`.
74
+ */
75
+ export function openAIImageTryCharge({ tried, request }) {
76
+ if (!tried) return null;
77
+ const priced = tried.usage ? priceOpenAIImageUsage({ model: request.model, usage: tried.usage }) : null;
78
+ if (priced) {
79
+ return {
80
+ basis: 'usage',
81
+ inputTokens: priced.inputTokens,
82
+ outputTokens: priced.outputTokens,
83
+ costUsd: priced.costUsd,
84
+ costSource: priced.source,
85
+ costUnknown: priced.costUnknown,
86
+ };
87
+ }
88
+ let estimate = null;
89
+ let basis = 'estimate';
90
+ if (tried.ok) {
91
+ estimate = openAIImageCostFloor(request);
92
+ basis = 'floor';
93
+ } else if (ESTIMATED_KINDS.has(tried.kind) || (tried.kind === 'refused' && tried.streamed === true)) {
94
+ estimate = estimateOpenAIImageCost({ ...request, partials: tried.partials || 0 });
95
+ }
96
+ if (!estimate) return null;
97
+ return {
98
+ basis,
99
+ inputTokens: null,
100
+ outputTokens: null,
101
+ costUsd: estimate.costUsd,
102
+ costSource: OPENAI_IMAGE_COST_SOURCES.estimate,
103
+ costUnknown: estimate.costUnknown,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The failure note of a try the cap stopped before it was sent
109
+ * (`refusal`: reserveTry's). On the first try nothing went to OpenAI; on the
110
+ * second the first may have been billed.
111
+ */
112
+ export function openAIImageCapRefusalText({ tryNumber = 1, refusal = {} } = {}) {
113
+ const first = tryNumber <= 1;
114
+ if (refusal.unreadable) {
115
+ return first
116
+ ? 'The relay could not read today\'s OpenAI image spend, so nothing was sent to OpenAI. Retry the message.'
117
+ : 'The first try may have been billed, and the relay could not read today\'s OpenAI image spend, so the second was not sent. Retry the message.';
118
+ }
119
+ const figures = `today's estimated OpenAI image spend on this relay, ${formatOpenAIImageUsd(refusal.spentUsd)}, and this ${first ? 'turn' : 'try'}'s estimate, ${formatOpenAIImageUsd(refusal.estimateUsd)}, would pass the daily cap of ${formatOpenAIImageUsd(refusal.capUsd)}`;
120
+ return first
121
+ ? `${figures.charAt(0).toUpperCase()}${figures.slice(1)}, so nothing was sent to OpenAI. ${CAP_REMEDY}`
122
+ : `The first try may have been billed, and the daily cap stopped the second: ${figures}. ${CAP_REMEDY}`;
123
+ }
124
+
125
+ function dayFormatter(timeZone) {
126
+ try {
127
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
128
+ } catch {
129
+ return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
130
+ }
131
+ }
132
+
133
+ function usd(value) {
134
+ return Math.round((Number(value) || 0) * 1e6) / 1e6;
135
+ }
136
+
137
+ // An app_settings value as text: stored plain or as JSON.
138
+ function settingText(raw) {
139
+ const text = String(raw ?? '').trim();
140
+ if (text.startsWith('"')) {
141
+ try { return String(JSON.parse(text)).trim(); } catch {}
142
+ }
143
+ return text;
144
+ }
145
+
146
+ function countGeneratedImages(attachmentsJson) {
147
+ try {
148
+ const list = JSON.parse(String(attachmentsJson || '[]'));
149
+ return Array.isArray(list) ? list.filter((entry) => entry?.generatedImage).length : 0;
150
+ } catch {
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ export function createOpenAIImageSpendService({
156
+ tokenUsageService = null,
157
+ // (key) → the app_settings value as text, '' when unset.
158
+ readSetting = () => '',
159
+ // For a chat's image count (messages); optional.
160
+ db = null,
161
+ // (event, payload) → the owner's clients.
162
+ emit = null,
163
+ now = () => Date.now(),
164
+ timeZone = undefined,
165
+ logger = console,
166
+ } = {}) {
167
+ const ownFormat = dayFormatter(timeZone);
168
+ const localDay = (ms) => (typeof tokenUsageService?.localDay === 'function'
169
+ ? tokenUsageService.localDay(ms)
170
+ : ownFormat.format(new Date(ms)));
171
+ const reservations = new Map();
172
+ let reservationSeq = 0;
173
+ // Bookings the token tables did not take: { day, usd, estimate, unknown,
174
+ // conversationId, turnId, runId, args }.
175
+ let unbooked = [];
176
+ const warned = new Set();
177
+ const warnOnce = (key, text) => {
178
+ if (warned.has(key)) return;
179
+ warned.add(key);
180
+ logger?.warn?.(text);
181
+ };
182
+
183
+ function readCap(nowMs) {
184
+ const day = localDay(nowMs);
185
+ let capUsd = OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD;
186
+ let overrideDay = '';
187
+ let overrideAt = '';
188
+ try {
189
+ const raw = settingText(readSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.dailyCapUsd));
190
+ if (raw !== '') {
191
+ const value = Number(raw);
192
+ // A value that cannot be read keeps the default: never no cap.
193
+ if (Number.isFinite(value) && value >= 0) capUsd = value;
194
+ }
195
+ overrideDay = settingText(readSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideDay));
196
+ overrideAt = settingText(readSetting(OPENAI_IMAGE_SPEND_SETTING_KEYS.overrideAt));
197
+ } catch (error) {
198
+ warnOnce('settings', `[image-spend] the cap settings could not be read; the default cap applies: ${error?.message || error}`);
199
+ }
200
+ const allowedToday = Boolean(overrideDay) && overrideDay === day;
201
+ return { day, capUsd: capUsd > 0 ? capUsd : null, allowedToday, allowedAt: allowedToday ? (overrideAt || null) : null };
202
+ }
203
+
204
+ // Booked today and this month (day rows), or null when they cannot be read.
205
+ function readBooked(nowMs) {
206
+ if (!tokenUsageService?.readPeriods) return { today: 0, month: 0 };
207
+ try {
208
+ const periods = tokenUsageService.readPeriods({ provider: OPENAI_IMAGE_BOOKING_PROVIDER, now: nowMs });
209
+ const group = periods?.groups?.find((entry) => entry.billing === OPENAI_IMAGE_BOOKING_BILLING);
210
+ return { today: Number(group?.periods?.today?.costUsd || 0), month: Number(group?.periods?.month?.costUsd || 0) };
211
+ } catch (error) {
212
+ warnOnce('read', `[image-spend] today's image spend could not be read: ${error?.message || error}`);
213
+ return null;
214
+ }
215
+ }
216
+
217
+ function reservedFor(day) {
218
+ let sum = 0;
219
+ for (const entry of reservations.values()) if (entry.day === day) sum += entry.usd;
220
+ return sum;
221
+ }
222
+
223
+ function unbookedFor(filter) {
224
+ let sum = 0;
225
+ for (const entry of unbooked) if (filter(entry)) sum += entry.usd;
226
+ return sum;
227
+ }
228
+
229
+ // The cap's figures without the turn rows: for the checks.
230
+ function snapshot(nowMs) {
231
+ const cap = readCap(nowMs);
232
+ const booked = readBooked(nowMs);
233
+ const month = cap.day.slice(0, 7);
234
+ const spentUsd = (booked?.today || 0) + unbookedFor((entry) => entry.day === cap.day);
235
+ const monthUsd = (booked?.month || 0) + unbookedFor((entry) => entry.day.startsWith(`${month}-`));
236
+ return { ...cap, unreadable: !booked, spentUsd, monthUsd, reservedUsd: reservedFor(cap.day) };
237
+ }
238
+
239
+ /**
240
+ * Before a try leaves the relay: refused (`{ ok:false, … }`, nothing to
241
+ * send) when today's spend, the tries in flight and this estimate would
242
+ * pass the cap and today is not allowed; else the estimate is reserved
243
+ * (`{ ok:true, reservation }`) until settleTry or releaseTry.
244
+ */
245
+ function reserveTry({ estimateUsd = 0, startedAtMs = now() } = {}) {
246
+ const state = snapshot(startedAtMs);
247
+ const estimate = usd(estimateUsd);
248
+ if (state.capUsd !== null && !state.allowedToday) {
249
+ if (state.unreadable) return { ok: false, unreadable: true, day: state.day, capUsd: state.capUsd, estimateUsd: estimate };
250
+ const spentUsd = usd(state.spentUsd + state.reservedUsd);
251
+ if (spentUsd + estimate > state.capUsd + 1e-9) {
252
+ return { ok: false, day: state.day, spentUsd, estimateUsd: estimate, capUsd: state.capUsd };
253
+ }
254
+ }
255
+ reservationSeq += 1;
256
+ const reservation = { id: reservationSeq, day: state.day, usd: estimate, startedAtMs };
257
+ reservations.set(reservation.id, { day: reservation.day, usd: estimate });
258
+ return { ok: true, reservation };
259
+ }
260
+
261
+ /** Drops a reservation whose try books nothing (a refusal, a throw, an abort). */
262
+ function releaseTry(reservation) {
263
+ if (reservation) reservations.delete(reservation.id);
264
+ }
265
+
266
+ function tryRecord(entry) {
267
+ if (!tokenUsageService?.recordTurn) return false;
268
+ try {
269
+ return Boolean(tokenUsageService.recordTurn(entry.args));
270
+ } catch (error) {
271
+ warnOnce(`book:${error?.message || error}`, `[image-spend] booking an image try failed; it stays counted in memory: ${error?.message || error}`);
272
+ return false;
273
+ }
274
+ }
275
+
276
+ // Books one row, after the ones that failed before; whatever the tables
277
+ // do not take stays counted for its day.
278
+ function book(entry) {
279
+ const today = localDay(now());
280
+ if (unbooked.length) {
281
+ const kept = [];
282
+ for (const earlier of unbooked) {
283
+ if (tryRecord(earlier)) continue;
284
+ if (earlier.day < today) {
285
+ logger?.warn?.(`[image-spend] an image try of ${earlier.day} (${formatOpenAIImageUsd(earlier.usd)}) could not be booked and is dropped with its day`);
286
+ continue;
287
+ }
288
+ kept.push(earlier);
289
+ }
290
+ unbooked = kept;
291
+ }
292
+ if (tryRecord(entry)) return true;
293
+ if (!tokenUsageService?.recordTurn) warnOnce('no-service', '[image-spend] no token usage storage: image costs are counted in memory only');
294
+ else warnOnce('null', '[image-spend] the token usage tables are not there: image costs are counted in memory until they are');
295
+ unbooked.push(entry);
296
+ return false;
297
+ }
298
+
299
+ function bookingEntry({ conversationId, turnId, runId, model, atMs, charge }) {
300
+ const nowMs = now();
301
+ const dayMs = Number.isFinite(atMs) && Math.abs(atMs - nowMs) <= AT_SKEW_MS ? atMs : nowMs;
302
+ return {
303
+ day: localDay(dayMs),
304
+ usd: usd(charge.costUsd),
305
+ estimate: charge.costSource === OPENAI_IMAGE_COST_SOURCES.estimate,
306
+ unknown: charge.costUnknown === true,
307
+ conversationId: String(conversationId || ''),
308
+ turnId: String(turnId || ''),
309
+ runId,
310
+ args: {
311
+ conversationId: String(conversationId || ''),
312
+ provider: OPENAI_IMAGE_BOOKING_PROVIDER,
313
+ billing: OPENAI_IMAGE_BOOKING_BILLING,
314
+ report: {
315
+ turnId: String(turnId || ''),
316
+ runId,
317
+ nativeSessionId: null,
318
+ responseMessageId: null,
319
+ at: new Date(dayMs).toISOString(),
320
+ models: [{
321
+ model: openAIImageBookingModel(model),
322
+ inputTokens: charge.inputTokens ?? null,
323
+ outputTokens: charge.outputTokens ?? null,
324
+ costUsd: usd(charge.costUsd),
325
+ costSource: charge.costSource,
326
+ costUnknown: charge.costUnknown === true,
327
+ }],
328
+ },
329
+ },
330
+ };
331
+ }
332
+
333
+ /**
334
+ * A try ended: its reservation dropped and its cost (openAIImageTryCharge;
335
+ * null books nothing) booked, synchronously. Returns whether a row was
336
+ * written (false: none, or kept in memory).
337
+ */
338
+ function settleTry(reservation, { conversationId, turnId, runId, model, charge = null } = {}) {
339
+ releaseTry(reservation);
340
+ if (!charge) return false;
341
+ const written = book(bookingEntry({ conversationId, turnId, runId, model, atMs: reservation?.startedAtMs, charge }));
342
+ notifyImageSpendChanged();
343
+ return written;
344
+ }
345
+
346
+ /**
347
+ * The recovery found an attempt cut off while it ran (`started`): its
348
+ * estimate, as `image:<attemptId>:recovered`, unless a try of that attempt
349
+ * was booked already. Returns whether it booked.
350
+ */
351
+ function bookRecovered({ conversationId, turnId, attemptId, model, startedAt, request }) {
352
+ if (!attemptId || !turnId) return false;
353
+ const prefix = `image:${attemptId}:`;
354
+ let rows = [];
355
+ try {
356
+ rows = tokenUsageService?.readTurn?.(turnId)?.rows || [];
357
+ } catch {}
358
+ if (rows.some((row) => String(row.runId || '').startsWith(prefix))) return false;
359
+ if (unbooked.some((entry) => entry.runId.startsWith(prefix))) return false;
360
+ const estimate = estimateOpenAIImageCost({ ...request, model });
361
+ book(bookingEntry({
362
+ conversationId,
363
+ turnId,
364
+ runId: openAIImageRecoveredRunId(attemptId),
365
+ model,
366
+ atMs: Date.parse(String(startedAt || '')),
367
+ charge: {
368
+ inputTokens: null,
369
+ outputTokens: null,
370
+ costUsd: estimate.costUsd,
371
+ costSource: OPENAI_IMAGE_COST_SOURCES.estimate,
372
+ costUnknown: estimate.costUnknown,
373
+ },
374
+ }));
375
+ notifyImageSpendChanged();
376
+ return true;
377
+ }
378
+
379
+ function imageRows(rows) {
380
+ return (rows || []).filter((row) => row.provider === OPENAI_IMAGE_BOOKING_PROVIDER && row.billing === OPENAI_IMAGE_BOOKING_BILLING);
381
+ }
382
+
383
+ /**
384
+ * What one turn's tries were booked at (the reply line):
385
+ * `{ costUsd, estimated, unknownPrice, tries }`, or null when none.
386
+ */
387
+ function turnCost(turnId) {
388
+ const id = String(turnId || '');
389
+ let rows = [];
390
+ try {
391
+ rows = imageRows(tokenUsageService?.readTurn?.(id)?.rows);
392
+ } catch {}
393
+ const parts = [
394
+ ...rows.map((row) => ({ usd: Number(row.costUsd || 0), estimate: row.costSource === OPENAI_IMAGE_COST_SOURCES.estimate, unknown: row.costUnknown === true })),
395
+ ...unbooked.filter((entry) => entry.turnId === id).map((entry) => ({ usd: entry.usd, estimate: entry.estimate, unknown: entry.unknown })),
396
+ ];
397
+ if (!parts.length) return null;
398
+ return {
399
+ costUsd: usd(parts.reduce((sum, part) => sum + part.usd, 0)),
400
+ estimated: parts.some((part) => part.estimate),
401
+ unknownPrice: parts.some((part) => part.unknown),
402
+ tries: parts.length,
403
+ };
404
+ }
405
+
406
+ function readImageTurnRows(filter) {
407
+ try {
408
+ return imageRows(tokenUsageService?.readTurnRows?.({
409
+ provider: OPENAI_IMAGE_BOOKING_PROVIDER,
410
+ billing: OPENAI_IMAGE_BOOKING_BILLING,
411
+ ...filter,
412
+ }));
413
+ } catch {
414
+ return [];
415
+ }
416
+ }
417
+
418
+ function conversationImages(conversationId) {
419
+ if (!db?.prepare) return 0;
420
+ try {
421
+ const rows = db.prepare(`SELECT attachments FROM messages WHERE conversation_id = ? AND role = 'assistant' AND attachments IS NOT NULL`).all(conversationId) || [];
422
+ return rows.reduce((sum, row) => sum + countGeneratedImages(row.attachments), 0);
423
+ } catch {
424
+ return 0;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * The spend state (shared/openai-image-spend.mjs); with `conversationId`
430
+ * also that chat's `{ costUsd, estimatedUsd, images, tries }`. The estimate
431
+ * and unknown-price shares come from the turn rows (the day rows keep no
432
+ * cost_source): a deleted chat's rows are gone from them.
433
+ */
434
+ function buildState({ conversationId = null, nowMs = now() } = {}) {
435
+ const state = snapshot(nowMs);
436
+ const todayRows = readImageTurnRows({ day: state.day });
437
+ const todayUnbooked = unbooked.filter((entry) => entry.day === state.day);
438
+ const estimatedUsd = todayRows.filter((row) => row.costSource === OPENAI_IMAGE_COST_SOURCES.estimate).reduce((sum, row) => sum + Number(row.costUsd || 0), 0)
439
+ + todayUnbooked.filter((entry) => entry.estimate).reduce((sum, entry) => sum + entry.usd, 0);
440
+ const unknownPriceUsd = todayRows.filter((row) => row.costUnknown).reduce((sum, row) => sum + Number(row.costUsd || 0), 0)
441
+ + todayUnbooked.filter((entry) => entry.unknown).reduce((sum, entry) => sum + entry.usd, 0);
442
+ const used = state.spentUsd + state.reservedUsd;
443
+ const ratio = state.capUsd !== null ? used / state.capUsd : null;
444
+ const out = {
445
+ day: state.day,
446
+ capUsd: state.capUsd,
447
+ spentUsd: usd(state.spentUsd),
448
+ reservedUsd: usd(state.reservedUsd),
449
+ estimatedUsd: usd(estimatedUsd),
450
+ unknownPriceUsd: usd(unknownPriceUsd),
451
+ monthUsd: usd(state.monthUsd),
452
+ ratio: ratio === null ? null : Math.round(ratio * 10000) / 10000,
453
+ warning: ratio !== null && ratio >= OPENAI_IMAGE_CAP_WARN_RATIO,
454
+ reached: state.capUsd !== null && used >= state.capUsd - 1e-9,
455
+ allowedToday: state.allowedToday,
456
+ allowedAt: state.allowedAt,
457
+ };
458
+ if (conversationId) {
459
+ const id = String(conversationId);
460
+ const rows = readImageTurnRows({ conversationId: id });
461
+ const pending = unbooked.filter((entry) => entry.conversationId === id);
462
+ out.conversation = {
463
+ costUsd: usd(rows.reduce((sum, row) => sum + Number(row.costUsd || 0), 0) + pending.reduce((sum, entry) => sum + entry.usd, 0)),
464
+ estimatedUsd: usd(rows.filter((row) => row.costSource === OPENAI_IMAGE_COST_SOURCES.estimate).reduce((sum, row) => sum + Number(row.costUsd || 0), 0)
465
+ + pending.filter((entry) => entry.estimate).reduce((sum, entry) => sum + entry.usd, 0)),
466
+ images: conversationImages(id),
467
+ tries: rows.length + pending.length,
468
+ };
469
+ }
470
+ return out;
471
+ }
472
+
473
+ /**
474
+ * A send to an image model (POST /api/message), any sender: null, or
475
+ * `{ code, error }` while today's booked and reserved spend has reached
476
+ * the cap and today is not allowed.
477
+ */
478
+ function sendRefusal({ nowMs = now() } = {}) {
479
+ const state = snapshot(nowMs);
480
+ if (state.capUsd === null || state.allowedToday) return null;
481
+ if (state.unreadable) {
482
+ return {
483
+ code: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
484
+ error: 'The relay could not read today\'s OpenAI image spend, so it cannot check the daily cap. Nothing was sent.',
485
+ };
486
+ }
487
+ const used = state.spentUsd + state.reservedUsd;
488
+ if (used < state.capUsd - 1e-9) return null;
489
+ return {
490
+ code: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
491
+ error: `Today's estimated OpenAI image spend on this relay, ${formatOpenAIImageUsd(used)}, has reached the daily cap of ${formatOpenAIImageUsd(state.capUsd)} (${CAP_PLACE}). Nothing was sent. Allow more for today there, or wait until tomorrow (relay time).`,
492
+ };
493
+ }
494
+
495
+ /** Emits the spend state to the owner's clients: after a booking, a cap or an override change. */
496
+ function notifyImageSpendChanged() {
497
+ if (typeof emit !== 'function') return;
498
+ try {
499
+ emit(OPENAI_IMAGE_USAGE_EVENT, buildState());
500
+ } catch (error) {
501
+ warnOnce(`emit:${error?.message || error}`, `[image-spend] the spend state could not be sent: ${error?.message || error}`);
502
+ }
503
+ }
504
+
505
+ return {
506
+ localDay: (ms = now()) => localDay(ms),
507
+ reserveTry,
508
+ releaseTry,
509
+ settleTry,
510
+ bookRecovered,
511
+ turnCost,
512
+ buildState,
513
+ sendRefusal,
514
+ notifyImageSpendChanged,
515
+ };
516
+ }
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // The server-sent events of a streamed OpenAI image request (`stream: true`
4
+ // on /images/generations or /images/edits), read off the fetch Response body
5
+ // chunk by chunk.
6
+ //
7
+ // A data line carries a whole image as base64, several million characters:
8
+ // the reader splits lines with indexOf/slice only, keeps the pieces of an
9
+ // unfinished line and joins them once per line, and never runs a regexp over
10
+ // a line (a long-running relay compiles regexps unoptimised, and `[..]+`
11
+ // overflows the regexp stack at 4,194,304 characters). One event may not
12
+ // grow past `maxEventChars`, so a stream that never ends a line cannot take
13
+ // the relay's memory.
14
+ //
15
+ // SSE rules as far as OpenAI uses them: lines end in LF or CRLF (a lone CR
16
+ // is not a line end here), a blank line ends an event, `data:` lines join
17
+ // with LF, a line starting with `:` is a comment, the one space after the
18
+ // field's colon goes, `id` and `retry` are ignored. At the end of the body an
19
+ // unfinished last event still counts.
20
+
21
+ export const SSE_MAX_EVENT_CHARS = 64 * 1024 * 1024;
22
+ export const SSE_EVENT_TOO_LARGE_CODE = 'SSE_EVENT_TOO_LARGE';
23
+
24
+ function eventTooLarge(maxEventChars) {
25
+ const error = new Error(`A stream event grew past ${maxEventChars} characters`);
26
+ error.code = SSE_EVENT_TOO_LARGE_CODE;
27
+ return error;
28
+ }
29
+
30
+ /**
31
+ * A push parser: `push(text)` with each decoded chunk, `end()` once the body
32
+ * ended; `onEvent({ event, data })` gets every event that has data (`event`
33
+ * is 'message' when the stream names none). Throws SSE_EVENT_TOO_LARGE_CODE.
34
+ */
35
+ export function createSseParser({ onEvent, maxEventChars = SSE_MAX_EVENT_CHARS } = {}) {
36
+ const limit = Math.max(1, Number(maxEventChars) || SSE_MAX_EVENT_CHARS);
37
+ let pieces = [];
38
+ let pendingChars = 0;
39
+ let eventName = '';
40
+ let dataLines = [];
41
+ let dataChars = 0;
42
+
43
+ const dispatch = () => {
44
+ if (dataLines.length) onEvent?.({ event: eventName || 'message', data: dataLines.join('\n') });
45
+ eventName = '';
46
+ dataLines = [];
47
+ dataChars = 0;
48
+ };
49
+
50
+ const processLine = (line) => {
51
+ if (line === '') {
52
+ dispatch();
53
+ return;
54
+ }
55
+ if (line.charCodeAt(0) === 58) return; // ':' comment
56
+ const colon = line.indexOf(':');
57
+ const field = colon < 0 ? line : line.slice(0, colon);
58
+ let value = colon < 0 ? '' : line.slice(colon + 1);
59
+ if (value.charCodeAt(0) === 32) value = value.slice(1);
60
+ if (field === 'data') {
61
+ dataLines.push(value);
62
+ dataChars += value.length + 1;
63
+ if (dataChars > limit) throw eventTooLarge(limit);
64
+ } else if (field === 'event') {
65
+ eventName = value;
66
+ }
67
+ };
68
+
69
+ const takeLine = (tail) => {
70
+ let line = tail;
71
+ if (pieces.length) {
72
+ pieces.push(tail);
73
+ line = pieces.join('');
74
+ pieces = [];
75
+ pendingChars = 0;
76
+ }
77
+ if (line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
78
+ processLine(line);
79
+ };
80
+
81
+ return {
82
+ push(text) {
83
+ const chunk = String(text ?? '');
84
+ let start = 0;
85
+ while (start < chunk.length) {
86
+ const newline = chunk.indexOf('\n', start);
87
+ if (newline < 0) {
88
+ const rest = chunk.slice(start);
89
+ pieces.push(rest);
90
+ pendingChars += rest.length;
91
+ if (pendingChars + dataChars > limit) throw eventTooLarge(limit);
92
+ return;
93
+ }
94
+ takeLine(chunk.slice(start, newline));
95
+ start = newline + 1;
96
+ }
97
+ },
98
+ end() {
99
+ if (pieces.length) takeLine('');
100
+ dispatch();
101
+ },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The events of a body (a web ReadableStream, or any async iterable of
107
+ * Uint8Array or string chunks), in order. A body that breaks off throws its
108
+ * error from the iteration; a UTF-8 sequence split across chunks is kept
109
+ * whole.
110
+ */
111
+ export async function* readSseEvents(body, { maxEventChars = SSE_MAX_EVENT_CHARS } = {}) {
112
+ const ready = [];
113
+ const parser = createSseParser({ onEvent: (event) => ready.push(event), maxEventChars });
114
+ const decoder = new TextDecoder('utf-8');
115
+ for await (const chunk of body) {
116
+ parser.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
117
+ for (let index = 0; index < ready.length; index += 1) yield ready[index];
118
+ ready.length = 0;
119
+ }
120
+ parser.push(decoder.decode());
121
+ parser.end();
122
+ for (const event of ready) yield event;
123
+ }
124
+
125
+ /** True when a fetch Response is a server-sent event stream. */
126
+ export function isEventStreamResponse(response) {
127
+ const contentType = String(response?.headers?.get?.('content-type') || '').trim().toLowerCase();
128
+ return contentType.startsWith('text/event-stream') && Boolean(response?.body);
129
+ }
130
+
131
+ /**
132
+ * What one event of an image stream is, for generations
133
+ * (`image_generation.*`) and edits (`image_edit.*`) alike, by the payload's
134
+ * `type` (else the SSE event name):
135
+ * - `{ type: 'partial', name, payload }`: `.partial_image` (b64_json,
136
+ * partial_image_index, size, quality, output_format, background);
137
+ * - `{ type: 'completed', name, payload }`: `.completed` (b64_json, usage, …);
138
+ * - `{ type: 'error', name, error }`: an `error` event, or a payload with an
139
+ * `error` object (`error` is `{ message, type, code }` as far as given);
140
+ * - `{ type: 'invalid', name }`: data that is not a JSON object;
141
+ * - `{ type: 'ignored', name }`: anything else.
142
+ */
143
+ export function interpretOpenAIImageStreamEvent({ event = '', data = '' } = {}) {
144
+ const sseName = String(event || '');
145
+ const text = String(data ?? '');
146
+ if (text === '[DONE]') return { type: 'ignored', name: sseName };
147
+ let payload;
148
+ try {
149
+ payload = JSON.parse(text);
150
+ } catch {
151
+ return { type: 'invalid', name: sseName };
152
+ }
153
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { type: 'invalid', name: sseName };
154
+ const name = String(payload.type || sseName || '');
155
+ if (name.endsWith('.partial_image')) return { type: 'partial', name, payload };
156
+ if (name.endsWith('.completed')) return { type: 'completed', name, payload };
157
+ if (name === 'error' || name.endsWith('.failed') || (payload.error && typeof payload.error === 'object')) {
158
+ const error = payload.error && typeof payload.error === 'object' ? payload.error : payload;
159
+ return {
160
+ type: 'error',
161
+ name,
162
+ error: {
163
+ message: String(error.message || '').slice(0, 240),
164
+ type: String(error.type || '').slice(0, 80),
165
+ code: String(error.code || '').slice(0, 80),
166
+ },
167
+ };
168
+ }
169
+ return { type: 'ignored', name };
170
+ }