@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.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- 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/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- 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 +289 -94
- 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/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/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/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- 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/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- 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/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- 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/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -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/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -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 +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
|
+
}
|